Parcourir la source

Added reviews app

master
M O Faruque Sarker il y a 14 ans
Parent
révision
4cf2974ac1

+ 1
- 0
examples/vanilla/settings.py Voir le fichier

@@ -209,6 +209,7 @@ INSTALLED_APPS = (
209 209
     'oscar.apps.promotions',
210 210
     'oscar.apps.reports',
211 211
     'oscar.apps.search',
212
+    'oscar.apps.reviews',
212 213
     'pyzen',
213 214
     'sorl.thumbnail',
214 215
 )

+ 11
- 1
oscar/apps/product/urls.py Voir le fichier

@@ -2,9 +2,19 @@ from django.conf.urls.defaults import *
2 2
 
3 3
 from oscar.core.decorators import class_based_view
4 4
 from oscar.core.loading import import_module
5
-import_module('product.views', ['ItemDetailView', 'ProductListView', 'ItemClassListView'], locals())
5
+import_module('product.views', ['ItemDetailView', 'ProductListView', 'ItemClassListView'], locals())  # basic product stuffs
6
+import_module('product.views', ['ProductReviewView', 'ProductReviewDetailView',\
7
+                                 'ProductReviewListView', 'ProductReviewVoteView'], locals())  # product review stuff  
8
+
9
+product_url = r'(?P<item_class_slug>[\w-]+)/(?P<item_slug>[\w-]*)-(?P<item_id>\d+)/'
6 10
 
7 11
 urlpatterns = patterns('oscar.product.views',
12
+    #product reviews stuff    
13
+    url(product_url + r'review/(?P<review_id>\d+)/vote/$', class_based_view(ProductReviewVoteView), name='oscar-vote-review'),
14
+    url(product_url + r'review/(?P<review_id>\d+)/$', ProductReviewDetailView.as_view(), name='oscar-product-review'),
15
+    url(product_url + r'reviews/$', ProductReviewListView.as_view(), name='oscar-product-reviews'),
16
+    url(product_url + r'add-review/$', class_based_view(ProductReviewView), name='oscar-product-review-add'),
17
+    #basic product item stuff
8 18
     url(r'(?P<item_class_slug>[\w-]+)/(?P<item_slug>[\w-]*)-(?P<item_id>\d+)/$', ItemDetailView.as_view(), name='oscar-product-item'),
9 19
     url(r'(?P<item_class_slug>[\w-]+)/$', ItemClassListView.as_view(), name='oscar-product-item-class'),
10 20
     url(r'^$', ProductListView.as_view(), name='oscar-products'),

+ 188
- 1
oscar/apps/product/views.py Voir le fichier

@@ -1,18 +1,24 @@
1 1
 from django.conf import settings
2 2
 from django.http import HttpResponse, Http404, HttpResponsePermanentRedirect, HttpResponseRedirect
3 3
 from django.template import Context, loader, RequestContext
4
-from django.shortcuts import get_object_or_404
4
+from django.shortcuts import get_object_or_404, render
5 5
 from django.core.urlresolvers import reverse
6 6
 from django.core.paginator import Paginator, InvalidPage, EmptyPage
7 7
 from django.views.generic import ListView, DetailView
8 8
 from django.template.response import TemplateResponse
9
+from django.db.models import Avg
10
+from django.core.exceptions import ObjectDoesNotExist
11
+from django.contrib import messages
9 12
 
10 13
 from oscar.core.loading import import_module
14
+from oscar.apps.reviews.models import ProductReview, Vote
15
+from oscar.apps.reviews.forms import make_review_form, ProductReviewForm
11 16
 
12 17
 product_models = import_module('product.models', ['Item', 'ItemClass'])
13 18
 product_signals = import_module('product.signals', ['product_viewed', 'product_search'])
14 19
 basket_forms = import_module('basket.forms', ['FormFactory'])
15 20
 history_helpers = import_module('customer.history_helpers', ['receive_product_view'])
21
+review_models = import_module('reviews.models', ['ProductReview', 'Vote'])
16 22
 
17 23
 
18 24
 class ItemDetailView(DetailView):
@@ -64,12 +70,33 @@ class ItemDetailView(DetailView):
64 70
     def get_context_data(self, **kwargs):
65 71
         context = super(ItemDetailView, self).get_context_data(**kwargs)
66 72
         context['basket_form'] = self.get_add_to_basket_form()
73
+        # product reviews stuffs
74
+        context['reviews'] = self.get_product_review()
75
+        context['avg_score'] = self.get_avg_review()
76
+        context['review_votes'] = self.get_review_votes()
67 77
         return context
68 78
     
69 79
     def get_add_to_basket_form(self):
70 80
         factory = basket_forms.FormFactory()
71 81
         return factory.create(self.object)
72 82
     
83
+    def get_product_review(self):
84
+        if settings.OSCAR_MODERATE_REVIEWS:        
85
+            return ProductReview.approved_only.all()
86
+        else:
87
+            return ProductReview.objects.all()
88
+    
89
+    def get_avg_review(self):
90
+        if settings.OSCAR_MODERATE_REVIEWS:        
91
+            avg = ProductReview.approved_only.aggregate(Avg('score'))
92
+        else:
93
+            avg = ProductReview.objects.aggregate(Avg('score'))
94
+        return avg['score__avg']
95
+    
96
+    def get_review_votes(self):
97
+        return Vote.objects.all()
98
+
99
+    
73 100
 class ItemClassListView(ListView):
74 101
     u"""View products filtered by item-class."""
75 102
     context_object_name = "products"
@@ -114,3 +141,163 @@ class ProductListView(ListView):
114 141
             context['summary'] = "Products matching '%s'" % q
115 142
             context['search_term'] = q
116 143
         return context
144
+
145
+
146
+class ProductReviewView(object):
147
+    u"""
148
+    A separate product review page
149
+    * The URL for browsing a products offers should be the normal product URL with /reviews appended at the end
150
+    * The product page shows the average score based on the reviews
151
+    """
152
+    template_name = "oscar/reviews/add_review.html"
153
+    def _is_review_done(self):
154
+        u"""
155
+        Check if the user already reviewed this product
156
+        """                
157
+        try:
158
+            review = review_models.ProductReview.objects.get(product=self.kwargs['item_id'], user=self.request.user.id)
159
+            if review:
160
+                return True
161
+        except ObjectDoesNotExist:                
162
+            return False
163
+    
164
+    def __call__(self, request, *args, **kwargs):        
165
+        self.request = request
166
+        self.args = args
167
+        self.kwargs = kwargs
168
+        self.user = self.request.user
169
+        # get the product                
170
+        item = get_object_or_404(product_models.Item, pk=self.kwargs['item_id'])                
171
+        if (self.user.is_authenticated()) and self._is_review_done():            
172
+            messages.info(self.request, "Your have already reviewed this product!")
173
+            url = item.get_absolute_url()             
174
+            return HttpResponsePermanentRedirect(url) 
175
+        # process request
176
+        if self.request.method == 'POST':        
177
+            review_form = make_review_form(self.user, self.request.POST)            
178
+            if review_form.is_valid():
179
+                if self.user.is_authenticated():
180
+                    review = ProductReview(product=item, user=self.request.user)                   
181
+                elif settings.OSCAR_ALLOW_ANON_REVIEWS:                    
182
+                    review = ProductReview(product=item, user=None, name=self.request.POST['name'], email=self.request.POST['email'])
183
+                else:
184
+                    messages.info(self.request, "Please login to submit a review!")
185
+                    return HttpResponsePermanentRedirect(item.get_absolute_url())
186
+                
187
+                rform = ProductReviewForm(self.request.POST, instance=review)
188
+                rform.save()
189
+                messages.info(self.request, "Your review has been submitted successfully!")
190
+                return HttpResponsePermanentRedirect(item.get_absolute_url())                                                   
191
+        else:            
192
+            review_form = make_review_form(self.request.user)
193
+        
194
+        return render(self.request, self.template_name, {
195
+                    "item" : item,
196
+                    "review_form": review_form,
197
+                    })
198
+
199
+
200
+class ProductReviewDetailView(DetailView):
201
+    u"""
202
+    Places each review on its own page
203
+    """
204
+    template_name = "oscar/reviews/review.html"
205
+    _review = None
206
+    
207
+    def get(self, request, **kwargs):
208
+        u"""
209
+        Ensures that the correct URL is used
210
+        """
211
+        # get the product review                
212
+        review = self.get_object()        
213
+        correct_path = review.get_absolute_url()
214
+        if correct_path != request.path:
215
+            return HttpResponsePermanentRedirect(correct_path)        
216
+        return super(ProductReviewDetailView, self).get(request, **kwargs)
217
+    
218
+    def get_object(self):
219
+        u"""
220
+        Return a review object
221
+        """
222
+        try:            
223
+            self._review = review_models.ProductReview.objects.get(pk=self.kwargs['review_id'])            
224
+            return self._review
225
+        except ObjectDoesNotExist:                
226
+            raise
227
+    
228
+    def get_context_data(self, **kwargs):
229
+        context = super(ProductReviewDetailView, self).get_context_data(**kwargs)        
230
+        context['review'] = self.get_object()
231
+        return context
232
+
233
+    
234
+class ProductReviewListView(ListView):
235
+    u"""A list of reviews for a particular product
236
+    * The review browsing page allows reviews to be sorted by score, or recency.
237
+    """    
238
+    context_object_name = "reviews"
239
+    model = ProductReview
240
+    template_name = 'oscar/reviews/reviews.html'
241
+    paginate_by = 3
242
+     
243
+    def get_queryset(self):
244
+        if 'sort_by' in self.request.GET:
245
+            if self.request.GET['sort_by'] == 'score':
246
+                 self.objects = review_models.ProductReview.top_scored.filter(product=self.kwargs['item_id'])
247
+            elif self.request.GET['sort_by'] == 'recency':
248
+                 self.objects = review_models.ProductReview.recent.filter(product=self.kwargs['item_id'])
249
+        else:
250
+            self.objects = review_models.ProductReview.objects.filter(product=self.kwargs['item_id'])
251
+        return self.objects 
252
+     
253
+    def get_context_data(self, **kwargs):
254
+        context = super(ProductReviewListView, self).get_context_data(**kwargs)
255
+        item = get_object_or_404(product_models.Item, pk=self.kwargs['item_id'])    
256
+        context['item'] = item
257
+        context['avg_score'] = self.objects.aggregate(Avg('score'))           
258
+        return context
259
+
260
+
261
+class ProductReviewVoteView(object):
262
+    u"""Processes voting of product reviews
263
+    """
264
+    
265
+    template_name = "oscar/product/detail.html"   
266
+    
267
+    def _is_vote_done(self):
268
+        u"""
269
+        Check if the user already reviewed this product
270
+        """                
271
+        try:
272
+            vote = review_models.Vote.objects.get(review=self.kwargs['review_id'])
273
+            if vote:
274
+                return True
275
+        except ObjectDoesNotExist:                
276
+            return False
277
+    
278
+    def __call__(self, request, *args, **kwargs):        
279
+        self.request = request
280
+        self.args = args
281
+        self.kwargs = kwargs
282
+        template_name = "oscar/product/detail.html" 
283
+        # get the product                    
284
+        item = get_object_or_404(product_models.Item, pk=self.kwargs['item_id'])
285
+        review = get_object_or_404(review_models.ProductReview, pk=self.kwargs['review_id'])
286
+        if self.request.method == 'POST':
287
+            if self._is_vote_done():
288
+                messages.info(self.request, "Your have already voted for this product!")         
289
+                return HttpResponsePermanentRedirect(item.get_absolute_url()) 
290
+            else:                                
291
+                vote = Vote.objects.create(review=review, user=self.request.user, choice=0)                        
292
+                if self.request.POST['action'] == 'voteup':
293
+                    vote.choice = 1                    
294
+                elif self.request.POST['action'] == 'votedown':
295
+                    vote.choice = -1
296
+                vote.save()
297
+                messages.info(self.request, "Your vote has been submitted successfully!")
298
+                return HttpResponsePermanentRedirect(item.get_absolute_url())                                                   
299
+        reviews = review_models.ProductReview.approved_only.filter(product=self.kwargs['item_id'])
300
+        return render(self.request, template_name, {
301
+                    "item" : item,
302
+                    "reviews": reviews,
303
+                    })

+ 0
- 0
oscar/apps/reviews/__init__.py Voir le fichier


+ 130
- 0
oscar/apps/reviews/abstract_models.py Voir le fichier

@@ -0,0 +1,130 @@
1
+"""
2
+Core product reviews
3
+"""
4
+from django.db import models
5
+from django.utils.translation import gettext as _
6
+from django.core.urlresolvers import reverse
7
+from django.core.exceptions import ValidationError
8
+
9
+from oscar.apps.reviews.managers import ApprovedReviewsManager,\
10
+ RecentReviewsManager, TopScoredReviewsManager, TopVotedReviewsManager
11
+
12
+
13
+class AbstractProductReview(models.Model):
14
+    u"""
15
+    Superclass ProductReview. Some key aspects have been implemented from the original spec.
16
+    * Each product can have reviews attached to it. Each review has a title, a body and a score from 1-5.
17
+    * Signed in users can always submit reviews, anonymous users can only submit reviews if a setting
18
+      OSCAR_ALLOW_ANON_REVIEWS is set to true - it should default to false.
19
+    * If anon users can submit reviews, then we require their name, email address and an (optional) URL.
20
+    * By default, reviews must be approved before they are live.
21
+      However, if a setting OSCAR_MODERATE_REVIEWS is set to false, then they don't need moderation.
22
+    * Each review should have a permalink, ie it has its own page.
23
+    * Each reviews can be voted up or down by other users
24
+    * Only signed in users can vote
25
+    * A user can only vote once on each product once
26
+    """
27
+    SCORE_CHOICES = (
28
+        ('1', 1),
29
+        ('2', 2),
30
+        ('3', 3),
31
+        ('4', 4),
32
+        ('5', 5)
33
+    )
34
+    product = models.ForeignKey('product.Item', related_name='product')
35
+    user = models.ForeignKey('auth.User', related_name='review', null=True, blank=True)
36
+    # anonymous users
37
+    name = models.CharField(_("Name"), max_length=100, null=True, blank=True)
38
+    email = models.EmailField(_("Email"), null=True, blank=True, unique=True)
39
+    url = models.URLField(_("URL"), null=True, blank=True)
40
+    # real review stuffs
41
+    title = models.CharField(_("Title"), max_length=100)
42
+    body = models.TextField(_("Comment"), max_length=300, blank=True)
43
+    score = models.CharField(_("Score"), max_length=1, choices=SCORE_CHOICES)
44
+    approved = models.BooleanField(default=False)
45
+    date_created = models.DateTimeField(auto_now_add=True)
46
+    # vote statistics
47
+    total_votes = models.IntegerField(_("Total Votes"), default=0, blank=True)  # upvotes + down votes
48
+    delta_votes = models.IntegerField(_("Delta Votes"), default=0, blank=True)  # upvotes - down votes
49
+    # mangers
50
+    objects = models.Manager()
51
+    approved_only = ApprovedReviewsManager()
52
+    recent = RecentReviewsManager()
53
+    top_scored = TopScoredReviewsManager()
54
+    top_voted = TopVotedReviewsManager()
55
+
56
+    class Meta:
57
+        abstract = True
58
+        ordering = ['-delta_votes']
59
+
60
+    def get_absolute_url(self):
61
+        args = {'review_id': self.id,
62
+                'item_class_slug': self.product.get_item_class().slug,
63
+                'item_slug': self.product.slug,
64
+                'item_id': self.product.id}
65
+        return reverse('oscar-product-review',  kwargs=args)
66
+
67
+    def get_vote_url(self):
68
+        return reverse('oscar-vote-review',
69
+                       kwargs={'review_id': self.id,
70
+                               'item_class_slug': str(self.product.item_class),
71
+                               'item_slug': self.product.slug,
72
+                                'item_id': str(self.product.id)})
73
+
74
+    def __unicode__(self):
75
+        return self.title
76
+
77
+    def save(self, *args, **kwargs):
78
+        if not (self.title and self.score):
79
+            raise ValidationError("Review must have a title")
80
+        if not self.user:  # anonymous review
81
+            if not (self.name and self.email):
82
+                raise ValidationError("Anonymous review must have a name and an email")
83
+        super(AbstractProductReview, self).save(*args, **kwargs)
84
+
85
+    def get_upvotes(self):
86
+        u"""returns the total yes votes"""
87
+        return int((self.total_votes + self.delta_votes) / 2)
88
+
89
+    def update_votes(self, choice):
90
+        u""" updates total and delta votes"""
91
+        self.total_votes += 1
92
+        self.delta_votes += choice
93
+        self.save()
94
+
95
+
96
+class AbstractVote(models.Model):
97
+    u"""
98
+    Records user ratings as yes/no vote.
99
+    * Only signed-in users can vote.
100
+    * Each user can vote only once.
101
+    """
102
+    VOTE_CHOICES = ((1, 1), (-1, -1), (0, 0))
103
+    user = models.ForeignKey('auth.User', related_name='vote')
104
+    review = models.ForeignKey('reviews.ProductReview', related_name='review')
105
+    choice = models.SmallIntegerField(choices=VOTE_CHOICES)
106
+    date_created = models.DateTimeField(auto_now_add=True)
107
+
108
+    objects = models.Manager()
109
+
110
+    class Meta:
111
+        abstract = True
112
+        ordering = ['-date_created']
113
+        unique_together = (('user', 'review'),)
114
+
115
+    def __unicode__(self):
116
+        return self.review.title
117
+
118
+    def save(self, *args, **kwargs):
119
+        u"""
120
+        Validates model and raises error if validation fails
121
+        """
122
+        if not self.user.is_authenticated():
123
+                raise ValidationError("Only logged-in users can vote!")
124
+        if self.choice == None:
125
+            raise ValidationError("Votes must have a choice (Yes/No)")
126
+        if self.choice == 0:  # empty vote
127
+            return
128
+        else:
129
+            self.review.update_votes(self.choice)
130
+        super(AbstractVote, self).save(*args, **kwargs)

+ 16
- 0
oscar/apps/reviews/admin.py Voir le fichier

@@ -0,0 +1,16 @@
1
+from django.contrib import admin
2
+
3
+from oscar.apps.reviews.models import ProductReview, Vote
4
+
5
+
6
+class ProductReviewAdmin(admin.ModelAdmin):
7
+    list_display = ('title', 'score', 'approved', 'date_created', 'total_votes', 'delta_votes')
8
+    read_only = ('title', 'body', 'score', 'total_votes', 'delta_votes')
9
+
10
+
11
+class VoteAdmin(admin.ModelAdmin):
12
+    list_display = ('review', 'user', 'choice', 'date_created')
13
+    read_only = ('review', 'user', 'choice', 'date_created')
14
+
15
+admin.site.register(ProductReview, ProductReviewAdmin)
16
+admin.site.register(Vote, VoteAdmin)

+ 44
- 0
oscar/apps/reviews/fixtures/sample-product.json Voir le fichier

@@ -0,0 +1,44 @@
1
+[
2
+    {
3
+        "pk": 1, 
4
+        "model": "product.itemclass", 
5
+        "fields": {
6
+            "options": [], 
7
+            "name": "books", 
8
+            "slug": "books"
9
+        }
10
+    }, 
11
+    {
12
+        "pk": 1, 
13
+        "model": "product.item", 
14
+        "fields": {
15
+            "description": "", 
16
+            "parent": null, 
17
+            "title": "The Django Book v2", 
18
+            "date_updated": "2011-05-19 17:33:27", 
19
+            "upc": "978-1430219361", 
20
+            "item_class": 1, 
21
+            "item_options": [], 
22
+            "date_created": "2011-05-19 17:32:37", 
23
+            "slug": "books"
24
+        }
25
+    }, 
26
+    {
27
+        "pk": 1, 
28
+        "model": "product.attributetype", 
29
+        "fields": {
30
+            "code": "price", 
31
+            "name": "price", 
32
+            "has_choices": false
33
+        }
34
+    }, 
35
+    {
36
+        "pk": 1, 
37
+        "model": "product.itemattributevalue", 
38
+        "fields": {
39
+            "product": 1, 
40
+            "type": 1, 
41
+            "value": "25"
42
+        }
43
+    }
44
+]

+ 57
- 0
oscar/apps/reviews/fixtures/sample-reviews.json Voir le fichier

@@ -0,0 +1,57 @@
1
+[
2
+    {
3
+        "pk": 2, 
4
+        "model": "reviews.productreview", 
5
+        "fields": {
6
+            "body": "Test review 1 says product is good", 
7
+            "product": 1, 
8
+            "name": "", 
9
+            "title": "Test review 1", 
10
+            "url": "", 
11
+            "approved": true, 
12
+            "score": "4", 
13
+            "user": 1, 
14
+            "date_created": "2011-05-19 18:00:48", 
15
+            "delta_votes": 1, 
16
+            "email": "", 
17
+            "total_votes": 1
18
+        }
19
+    }, 
20
+    {
21
+        "pk": 4, 
22
+        "model": "reviews.productreview", 
23
+        "fields": {
24
+            "body": "anon says we don't know about this product", 
25
+            "product": 1, 
26
+            "name": "Anon2", 
27
+            "title": "Anon review 2", 
28
+            "url": "", 
29
+            "approved": true, 
30
+            "score": "3", 
31
+            "user": null, 
32
+            "date_created": "2011-05-20 10:51:54", 
33
+            "delta_votes": 0, 
34
+            "email": "axxx@b.com", 
35
+            "total_votes": 0
36
+        }
37
+    }, 
38
+    {
39
+        "pk": 3, 
40
+        "model": "reviews.productreview", 
41
+        "fields": {
42
+            "body": "we rate it 5", 
43
+            "product": 1, 
44
+            "name": "Anon1", 
45
+            "title": "another review", 
46
+            "url": "", 
47
+            "approved": true, 
48
+            "score": "5", 
49
+            "user": null, 
50
+            "date_created": "2011-05-20 10:10:46", 
51
+            "delta_votes": -1, 
52
+            "email": "a@bcd.com", 
53
+            "total_votes": 1
54
+        }
55
+    } 
56
+    
57
+]

+ 23
- 0
oscar/apps/reviews/forms.py Voir le fichier

@@ -0,0 +1,23 @@
1
+from django.forms import BaseForm, ModelForm, CharField, EmailField
2
+
3
+from oscar.apps.reviews.models import ProductReview
4
+
5
+
6
+class ProductReviewForm(ModelForm):
7
+    class Meta:
8
+        model = ProductReview
9
+        fields = ('title', 'score', 'body')
10
+
11
+    def __init__(self, *args, **kwargs):
12
+        super(ProductReviewForm, self).__init__(*args, **kwargs)
13
+
14
+
15
+def make_review_form(user, values=None):
16
+    form = ProductReviewForm()
17
+    fields = form.fields
18
+    if not user.is_authenticated():
19
+        fields['name'] = CharField(max_length=100)
20
+        fields['email'] = EmailField()
21
+        fields['url'] = CharField(max_length=100, required=False)
22
+    form_class = type('ProductReviewForm', (BaseForm,), {'base_fields': fields})
23
+    return form_class(values)

+ 21
- 0
oscar/apps/reviews/managers.py Voir le fichier

@@ -0,0 +1,21 @@
1
+from django.db import models
2
+
3
+
4
+class ApprovedReviewsManager(models.Manager):
5
+    def get_query_set(self):
6
+        return super(ApprovedReviewsManager, self).get_query_set().filter(approved=True)
7
+
8
+
9
+class RecentReviewsManager(models.Manager):
10
+    def get_query_set(self):
11
+        return super(RecentReviewsManager, self).get_query_set().filter(approved=True).order_by('-date_created')
12
+
13
+
14
+class TopScoredReviewsManager(models.Manager):
15
+    def get_query_set(self):
16
+        return super(TopScoredReviewsManager, self).get_query_set().filter(approved=True).order_by('-score')
17
+
18
+
19
+class TopVotedReviewsManager(models.Manager):
20
+    def get_query_set(self):
21
+        return super(TopVotedReviewsManager, self).get_query_set().filter(approved=True).order_by('-delta_votes')

+ 9
- 0
oscar/apps/reviews/models.py Voir le fichier

@@ -0,0 +1,9 @@
1
+from oscar.apps.reviews.abstract_models import AbstractProductReview, AbstractVote
2
+
3
+
4
+class ProductReview(AbstractProductReview):
5
+    pass
6
+
7
+
8
+class Vote(AbstractVote):
9
+    pass

+ 149
- 0
oscar/apps/reviews/tests.py Voir le fichier

@@ -0,0 +1,149 @@
1
+from random import randint
2
+from sys import maxint
3
+from django.test import TestCase, Client
4
+from django.core.exceptions import ValidationError
5
+from django.contrib.auth.models import User, AnonymousUser
6
+from django.db import IntegrityError
7
+from django.core.urlresolvers import reverse
8
+from django.utils import unittest
9
+
10
+from oscar.apps.product.models import Item, ItemClass
11
+from oscar.apps.reviews.models import ProductReview, Vote
12
+
13
+
14
+class ProductReviewTests(unittest.TestCase):
15
+    u"""
16
+    Basic setup
17
+    """
18
+    def setUp(self):
19
+        username = str(randint(0, maxint))
20
+        self.user = User.objects.create_user(username, '%s@users.com'%username, '%spass123'%username)
21
+        self.anon_user = AnonymousUser()
22
+        self.item_class,_ = ItemClass.objects.get_or_create(name='Books')
23
+        self.item,_ = Item.objects.get_or_create(title='Django Book v2', item_class=self.item_class)
24
+        self.review,_ = ProductReview.objects.get_or_create(title='Django Book v2 Review',\
25
+                            product=self.item, user=self.user, score=3, approved=True)
26
+
27
+
28
+class TopLevelProductReviewTests(ProductReviewTests):
29
+    u"""
30
+    Basic tests for ProductReview model
31
+    """
32
+    def test_top_level_reviews_must_have_titles_and_scores(self):
33
+        self.assertRaises(ValidationError, ProductReview.objects.create, product=self.item,\
34
+                           user=self.user)
35
+
36
+    def test_top_level_anonymous_reviews_must_have_names_and_emails(self):
37
+        self.assertRaises(ValidationError, ProductReview.objects.create, product=self.item,\
38
+                           user=None, title="Anonymous review", score=3)
39
+
40
+
41
+class TopLevelProductReviewVoteTests(ProductReviewTests):
42
+    u"""
43
+    Basic tests for Vote model
44
+    """
45
+    def setUp(self):
46
+        super(TopLevelProductReviewVoteTests, self).setUp()
47
+
48
+    def test_top_level_vote_must_have_choice(self):
49
+        self.assertRaises(ValidationError, Vote.objects.create, review=self.review,\
50
+                           user=self.user)
51
+
52
+    def test_try_vote_without_login(self):
53
+        self.assertRaises(ValueError, Vote.objects.create, review=self.review, choice =-1, user=self.anon_user)
54
+
55
+    def test_try_vote_more_than_once(self):
56
+        vote1 = Vote.objects.create(review=self.review, user=self.user, choice=1)
57
+        self.assertTrue(vote1)
58
+        self.assertRaises(IntegrityError, Vote.objects.create, review=self.review, choice=-1, user=self.user)
59
+
60
+
61
+class SingleProductReviewViewTest(ProductReviewTests, TestCase):
62
+    u"""
63
+    Tests for each product review 
64
+    """
65
+    def setUp(self):
66
+        self.client = Client()
67
+        super(SingleProductReviewViewTest, self).setUp()
68
+        self.kwargs = {'item_class_slug': self.item.get_item_class().slug,
69
+                'item_slug': self.item.slug,
70
+                'item_id': str(self.item.id)}
71
+        
72
+    def test_each_product_has_review(self):
73
+        url = reverse('oscar-product-item', kwargs=self.kwargs)
74
+        response = self.client.get(url)
75
+        self.assertEquals(200, response.status_code)
76
+    
77
+    def test_user_can_add_product_review(self):
78
+        url = reverse('oscar-product-review-add', kwargs=self.kwargs)
79
+        self.client.login(username='testuser', password='secret')
80
+        response = self.client.get(url)
81
+        self.assertEquals(200, response.status_code)
82
+        # check necessary review fields for logged in user
83
+        self.assertContains(response, 'title')
84
+        self.assertContains(response, 'score')
85
+        # check additional fields for anonymous user
86
+        self.client.login(username=None)
87
+        response = self.client.get(url)
88
+        self.assertContains(response, 'name')
89
+        self.assertContains(response, 'email')
90
+        
91
+    def test_each_review_has_own_page(self):  # FIXME: broken for reverse
92
+        self.kwargs['review_id'] = self.review.id
93
+        url = reverse('oscar-product-review', kwargs = self.kwargs)
94
+        response = self.client.get(url)
95
+        self.assertEquals(200, response.status_code)
96
+
97
+
98
+class SingleProductReviewVoteViewTest(ProductReviewTests, TestCase):
99
+    u"""
100
+    Each product review can be voted up or down
101
+    """
102
+    def setUp(self):
103
+        self.client = Client()
104
+        super(SingleProductReviewVoteViewTest, self).setUp()   
105
+        self.kwargs = {'item_class_slug': self.item.get_item_class().slug, 
106
+                'item_slug': self.item.slug,
107
+                'item_id': self.item.id,
108
+                'review_id': self.review.id}
109
+        
110
+    def test_vote_up_product_review(self):
111
+        url = reverse('oscar-vote-review', kwargs=self.kwargs)
112
+        self.client.login(username='testuser', password='secret')
113
+        response = self.client.get(url)
114
+        self.assertEquals(200, response.status_code)
115
+    
116
+
117
+class ProductReviewVotingActionTests(TestCase):
118
+    u"""
119
+    Includes the behaviour tests after voting on a review
120
+    """
121
+    fixtures = ['sample-product', 'sample-reviews']    
122
+    
123
+    def setUp(self):
124
+        # get reviews
125
+        self.reviews = ProductReview.objects.all()       
126
+        # dummy voters
127
+        self.voters = 10        
128
+        self.users = []
129
+        for i in xrange(self.voters):
130
+            u = User.objects.create_user('user%d'%i, 'user%d@users.com'%i, 'userpass%d'%i)
131
+            self.users.append(u)        
132
+    
133
+    def test_upvote_can_boost_up_review(self):
134
+        # get a review
135
+        old_rank = 1
136
+        self.assertTrue(self.reviews)
137
+        self.review = self.reviews[old_rank]        
138
+        review_id = self.review.id
139
+        old_votes = self.review.total_votes
140
+        # vote up
141
+        for i in xrange(self.voters):
142
+            vote = Vote.objects.create(review=self.review, user=self.users[i], choice=1)
143
+            self.assertTrue(vote)
144
+        # test vote count
145
+        self.failUnlessEqual(self.review.total_votes, (self.voters + old_votes))
146
+        # test rank
147
+        reviews = ProductReview.top_voted.all()
148
+        new_rank = list(reviews.values_list('id', flat=True)).index(review_id)
149
+        self.failUnless(new_rank < old_rank)

+ 5
- 1
oscar/defaults.py Voir le fichier

@@ -26,4 +26,8 @@ OSCAR_PROMOTION_MERCHANDISING_BLOCK_TYPES = (
26 26
     (LIST, "Horizontal list"),
27 27
     (TABBED_BLOCK, "Tabbed block"),
28 28
     (SINGLE_PRODUCT, "Single product"),
29
-)   
29
+)
30
+
31
+# Reviews
32
+OSCAR_ALLOW_ANON_REVIEWS = True
33
+OSCAR_MODERATE_REVIEWS = False

+ 40
- 0
oscar/templates/oscar/product/detail.html Voir le fichier

@@ -52,6 +52,10 @@
52 52
         <th>{{ attribute.type.name }}</th>
53 53
         <th>{{ attribute.value }}</th>
54 54
     </tr>
55
+    <tr>
56
+        <td><a href="{{ item.get_absolute_url }}add-review/"> Add a review</a></td>
57
+        <td><a href="{{ item.get_absolute_url }}reviews/"> See all reviews</a></td>
58
+    </tr>
55 59
     {% endfor %}
56 60
 </table>
57 61
 
@@ -87,5 +91,41 @@
87 91
 </div>
88 92
 {% endif %}
89 93
 
94
+{% block product_review %}
95
+
96
+{% if reviews %}
97
+	{% if avg_score %}
98
+		<h3> Product Reviews </h3>
99
+		<h4> Average score: {{ avg_score|floatformat:1  }} / 5.0 </h4>
100
+	{% endif %}
101
+	{% for review in reviews|slice:":5" %}		
102
+		<h4> {{ forloop.counter}}. {{ review.title }} </h4>
103
+		<p> Score: {{ review.score|floatformat:1 }} / 5.0</p>
104
+		<p> Comments: {{ review.body|truncatewords:50 }}</p>		
105
+		<p> Date created: {{ review.date_created }}</p>
106
+		<a href="{{ review.get_absolute_url }}"> View full review</a>			
107
+	{% if review.total_votes %}
108
+		<h5> {{ review.get_upvotes }} out of {{ review.total_votes }} customers found this review useful.</h5>
109
+	{% endif %}
110
+	{% if user.is_anonymous %}
111
+		<h5> Please log-in to rate this review.</h5>
112
+	{% endif %}	
113
+	{% if user.is_authenticated %}		
114
+		<h4> Was this review helpful to you? </h4>
115
+		<form  action="{{ review.get_vote_url }}" method="post">
116
+			{% csrf_token %}               
117
+            <input type="hidden" name="action" id="action_id" value="voteup"/>
118
+            <input type="submit" value="Yes" /> 
119
+        </form>
120
+		<form  action="{{ review.get_vote_url }}" method="post">
121
+			{% csrf_token %}                
122
+            <input type="hidden" name="action" id="action_id" value="votedown"/>
123
+            <input type="submit" value="No" /> 
124
+        </form>
125
+	{% endif %}			
126
+	{% endfor %}
127
+{% endif %}
128
+{% endblock product_review %}
129
+
90 130
 {% endblock content %}
91 131
 

+ 68
- 0
oscar/templates/oscar/reviews/add_review.html Voir le fichier

@@ -0,0 +1,68 @@
1
+{% extends "layout.html" %}
2
+
3
+{% load currency_filters %}
4
+
5
+{% block header %}
6
+<h2>{{item.get_title}}</h2>
7
+{% endblock header %}
8
+
9
+{% block content%}
10
+
11
+<div class="images">
12
+    {% for image in item.images.all %}
13
+    <img src="{{ image.fullsize_url }}" title="{{ item.get_title }}" />
14
+    <div class="caption">{{ image.caption }}</div>
15
+    {% endfor %}
16
+</div>
17
+
18
+<table>
19
+    <caption>Product details</caption>
20
+    <tr>
21
+        <th>UPC</th><td>{{item.upc}}</td>
22
+    </tr>
23
+    <tr>
24
+        <th>Product class</th><td><a href="{{ item.item_class.get_absolute_url }}">{{item.item_class.name}}</a></td>
25
+    </tr>
26
+{% if item.stockrecord %}
27
+    <tr>
28
+        <th>Price (excl. tax)</th><td>{{item.stockrecord.price_incl_tax|currency}}</td>
29
+    </tr>
30
+    <tr>
31
+        <th>Price (incl. tax)</th><td>{{item.stockrecord.price_excl_tax|currency}}</td>
32
+    </tr>
33
+    <tr>
34
+        <th>Availability</th>
35
+        <td>{{ item.stockrecord.availability }}</td>
36
+    </tr>
37
+{% endif %}
38
+    <tr>
39
+        <th>Product type</th>
40
+        <td>
41
+        {% if item.is_group %}Product group{% else %}{% if item.is_variant %}Variant{% else %}Stand-alone{% endif %}{% endif %}
42
+        </td>
43
+    </tr>
44
+    {% for attribute in item.attributes.all %}
45
+    <tr>
46
+        <th>{{ attribute.type.name }}</th>
47
+        <th>{{ attribute.value }}</th>
48
+    </tr>
49
+    {% endfor %}
50
+</table>
51
+
52
+{% if item.stockrecord %}
53
+<form action="{% url oscar-basket %}" method="post">
54
+    {% csrf_token %}
55
+    {{ form.as_p }}
56
+    <input type="submit" value="Add to basket" />
57
+</form>
58
+{% endif %}
59
+
60
+
61
+
62
+<h4>Leave a product review</h4>
63
+<form method="post" action="">
64
+	{% csrf_token %}
65
+    {{ review_form.as_p }}
66
+     <input type="submit" value="Submit" /> 
67
+</form>
68
+{% endblock content %}

+ 9
- 0
oscar/templates/oscar/reviews/review.html Voir le fichier

@@ -0,0 +1,9 @@
1
+{% extends "oscar/product/detail.html" %}
2
+
3
+{% block product_review %}
4
+{% if review %}		
5
+		<h4> {{ review.title }} </h4>
6
+		<p> Score: {{ review.score|floatformat:1 }} / 5.0</p>
7
+		<p> Comments: {{ review.body }}</p>						
8
+{% endif %}
9
+{% endblock %}

+ 45
- 0
oscar/templates/oscar/reviews/reviews.html Voir le fichier

@@ -0,0 +1,45 @@
1
+{% extends "oscar/product/detail.html" %}
2
+
3
+{% block product_review %}
4
+{% if reviews %}
5
+	{% if avg_score %}
6
+		<h3> All Product Reviews ({{ reviews.count }}) </h3>
7
+		<h4> Sort by: </h4>
8
+		<form  action="{{ request.get_full_path }}" method="get">
9
+			{% csrf_token %}
10
+            <select name="sort_by">
11
+            	<option value="score"> Score </option>
12
+            	<option value="recency"> Recency </option>
13
+            	<input type="submit" value="Go" /> 
14
+            </select>            
15
+        </form>
16
+		<h4> Average score: {{ avg_score|floatformat:1  }} / 5.0 </h4>
17
+	{% endif %}
18
+	{% for review in reviews  %}		
19
+		<h4> {{ forloop.counter}}. {{ review.title }} </h4>
20
+		<p> Score: {{ review.score|floatformat:1 }} / 5.0</p>
21
+		<p> Comments: {{ review.body }}</p>
22
+		<p> Date created: {{ review.date_created }}</p>		
23
+	{% endfor %}
24
+	
25
+{% if page_obj %}
26
+<div class="pagination">
27
+    <span class="step-links">
28
+    
29
+        {% if page_obj.has_previous %}
30
+            <a href="?page={{ page_obj.previous_page_number }}">Previous</a>
31
+        {% endif %}
32
+
33
+        <span class="current">
34
+            Page {{ page_obj.number }} of {{ paginator.num_pages }}.
35
+        </span>
36
+
37
+        {% if page_obj.has_next %}
38
+            <a href="?page={{ page_obj.next_page_number }}">Next</a>
39
+        {% endif %}
40
+    </span>
41
+</div>
42
+{% endif %}
43
+							
44
+{% endif %}
45
+{% endblock %}

Chargement…
Annuler
Enregistrer