소스 검색

Major tidy-up of reviews branch.

Move into product app
Rewrote views to use generic classes
Tidied up POST actions of forms
master
David Winterbottom 14 년 전
부모
커밋
fe944298a6

+ 1
- 1
examples/recreate_project_tables.sh 파일 보기

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

+ 1
- 1
examples/vanilla/settings.py 파일 보기

209
     'oscar.apps.promotions',
209
     'oscar.apps.promotions',
210
     'oscar.apps.reports',
210
     'oscar.apps.reports',
211
     'oscar.apps.search',
211
     'oscar.apps.search',
212
-    'oscar.apps.reviews',
212
+    'oscar.apps.product.reviews',
213
     'pyzen',
213
     'pyzen',
214
     'sorl.thumbnail',
214
     'sorl.thumbnail',
215
 )
215
 )

oscar/apps/reviews/__init__.py → oscar/apps/product/reviews/__init__.py 파일 보기


+ 126
- 0
oscar/apps/product/reviews/abstract_models.py 파일 보기

1
+from django.db import models
2
+from django.utils.translation import gettext as _
3
+from django.core.urlresolvers import reverse
4
+from django.core.exceptions import ValidationError
5
+from django.conf import settings
6
+
7
+from oscar.apps.product.reviews.managers import (ApprovedReviewsManager, RecentReviewsManager, 
8
+                                                 TopScoredReviewsManager, TopVotedReviewsManager)
9
+
10
+
11
+class AbstractProductReview(models.Model):
12
+    """
13
+    Superclass ProductReview. Some key aspects have been implemented from the original spec.
14
+    * Each product can have reviews attached to it. Each review has a title, a body and a score from 1-5.
15
+    * Signed in users can always submit reviews, anonymous users can only submit reviews if a setting
16
+      OSCAR_ALLOW_ANON_REVIEWS is set to true - it should default to false.
17
+    * If anon users can submit reviews, then we require their name, email address and an (optional) URL.
18
+    * By default, reviews must be approved before they are live.
19
+      However, if a setting OSCAR_MODERATE_REVIEWS is set to false, then they don't need moderation.
20
+    * Each review should have a permalink, ie it has its own page.
21
+    * Each reviews can be voted up or down by other users
22
+    * Only signed in users can vote
23
+    * A user can only vote once on each product once
24
+    """
25
+    
26
+    # Note we keep the review even if the product is deleted
27
+    product = models.ForeignKey('product.Item', related_name='product', null=True, on_delete=models.SET_NULL)
28
+    
29
+    SCORE_CHOICES = tuple([(x, x) for x in range(0, 6)])
30
+    score = models.SmallIntegerField(_("Score"), choices=SCORE_CHOICES)
31
+    title = models.CharField(_("Title"), max_length=255)
32
+    body = models.TextField(_("Body"))
33
+    
34
+    # User information.  We include fields to handle anonymous users
35
+    user = models.ForeignKey('auth.User', related_name='reviews', null=True, blank=True)
36
+    name = models.CharField(_("Name"), max_length=255, null=True, blank=True)
37
+    email = models.EmailField(_("Email"), null=True, blank=True)
38
+    homepage = models.URLField(_("URL"), null=True, blank=True)
39
+    
40
+    FOR_MODERATION, APPROVED, REJECTED = range(0, 3)
41
+    STATUS_CHOICES = (
42
+        (FOR_MODERATION, _("Requires moderation")),
43
+        (APPROVED, _("Approved")),
44
+        (REJECTED, _("Rejected")), 
45
+    ) 
46
+    default_status = FOR_MODERATION if settings.OSCAR_MODERATE_REVIEWS else APPROVED
47
+    status = models.SmallIntegerField(_("Status"), choices=STATUS_CHOICES, default=default_status)
48
+    
49
+    # Denormalised vote totals
50
+    total_votes = models.IntegerField(_("Total Votes"), default=0)  # upvotes + down votes
51
+    delta_votes = models.IntegerField(_("Delta Votes"), default=0, db_index=True)  # upvotes - down votes  
52
+    
53
+    date_created = models.DateTimeField(auto_now_add=True)
54
+    
55
+    # Managers
56
+    objects = models.Manager()
57
+    approved = ApprovedReviewsManager()
58
+
59
+    class Meta:
60
+        abstract = True
61
+        ordering = ['-delta_votes']
62
+
63
+    @models.permalink
64
+    def get_absolute_url(self):
65
+        return ('oscar-product-review', (), {
66
+            'item_class_slug': self.product.get_item_class().slug,
67
+            'item_slug': self.product.slug,
68
+            'item_id': self.product.id,
69
+            'pk': self.id})
70
+
71
+    def __unicode__(self):
72
+        return self.title
73
+
74
+    def save(self, *args, **kwargs):
75
+        if not self.user and not (self.name and self.email):  
76
+            raise ValidationError("Anonymous review must have a name and an email")
77
+        super(AbstractProductReview, self).save(*args, **kwargs)
78
+
79
+    def has_votes(self):
80
+        return self.total_votes > 0
81
+
82
+    def num_up_votes(self):
83
+        """Returns the total up votes"""
84
+        return int((self.total_votes + self.delta_votes) / 2)
85
+    
86
+    def num_down_votes(self):
87
+        """Returns the total down votes"""
88
+        return int((self.total_votes - self.delta_votes) / 2)
89
+
90
+    def update_totals(self, vote):
91
+        """Updates total and delta votes"""
92
+        self.total_votes += 1
93
+        self.delta_votes += vote.delta
94
+        self.save()
95
+
96
+
97
+class AbstractVote(models.Model):
98
+    """
99
+    Records user ratings as yes/no vote.
100
+    * Only signed-in users can vote.
101
+    * Each user can vote only once.
102
+    """
103
+    review = models.ForeignKey('reviews.ProductReview', related_name='votes')
104
+    user = models.ForeignKey('auth.User', related_name='review_votes')
105
+    UP, DOWN = 1, -1
106
+    VOTE_CHOICES = (
107
+        (UP, _("Up")),
108
+        (DOWN, _("Down"))
109
+    )
110
+    delta = models.SmallIntegerField(choices=VOTE_CHOICES)
111
+    date_created = models.DateTimeField(auto_now_add=True)
112
+
113
+    class Meta:
114
+        abstract = True
115
+        ordering = ['-date_created']
116
+        unique_together = (('user', 'review'),)
117
+
118
+    def __unicode__(self):
119
+        return u"%s vote for %s" % (self.delta, self.review)
120
+
121
+    def save(self, *args, **kwargs):
122
+        u"""
123
+        Validates model and raises error if validation fails
124
+        """
125
+        self.review.update_totals(self)
126
+        super(AbstractVote, self).save(*args, **kwargs)

+ 15
- 0
oscar/apps/product/reviews/admin.py 파일 보기

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

oscar/apps/reviews/fixtures/sample-product.json → oscar/apps/product/reviews/fixtures/sample-product.json 파일 보기


oscar/apps/reviews/fixtures/sample-reviews.json → oscar/apps/product/reviews/fixtures/sample-reviews.json 파일 보기


oscar/apps/reviews/forms.py → oscar/apps/product/reviews/forms.py 파일 보기

1
 from django.forms import BaseForm, ModelForm, CharField, EmailField
1
 from django.forms import BaseForm, ModelForm, CharField, EmailField
2
 
2
 
3
-from oscar.apps.reviews.models import ProductReview
3
+from oscar.apps.product.reviews.models import ProductReview
4
 
4
 
5
 
5
 
6
 class ProductReviewForm(ModelForm):
6
 class ProductReviewForm(ModelForm):
7
+    
7
     class Meta:
8
     class Meta:
8
         model = ProductReview
9
         model = ProductReview
9
         fields = ('title', 'score', 'body')
10
         fields = ('title', 'score', 'body')
19
         fields['name'] = CharField(max_length=100)
20
         fields['name'] = CharField(max_length=100)
20
         fields['email'] = EmailField()
21
         fields['email'] = EmailField()
21
         fields['url'] = CharField(max_length=100, required=False)
22
         fields['url'] = CharField(max_length=100, required=False)
22
-    form_class = type('ProductReviewForm', (BaseForm,), {'base_fields': fields})
23
-    return form_class(values)
23
+    return type('ProductReviewForm', (BaseForm,), {'base_fields': fields})

oscar/apps/reviews/managers.py → oscar/apps/product/reviews/managers.py 파일 보기

3
 
3
 
4
 class ApprovedReviewsManager(models.Manager):
4
 class ApprovedReviewsManager(models.Manager):
5
     def get_query_set(self):
5
     def get_query_set(self):
6
-        return super(ApprovedReviewsManager, self).get_query_set().filter(approved=True)
6
+        return super(ApprovedReviewsManager, self).get_query_set().filter(status=1)
7
+
7
 
8
 
8
 
9
 
9
 class RecentReviewsManager(models.Manager):
10
 class RecentReviewsManager(models.Manager):

oscar/apps/reviews/models.py → oscar/apps/product/reviews/models.py 파일 보기

1
-from oscar.apps.reviews.abstract_models import AbstractProductReview, AbstractVote
1
+from oscar.apps.product.reviews.abstract_models import AbstractProductReview, AbstractVote
2
 
2
 
3
 
3
 
4
 class ProductReview(AbstractProductReview):
4
 class ProductReview(AbstractProductReview):

oscar/apps/reviews/tests.py → oscar/apps/product/reviews/tests.py 파일 보기


+ 12
- 0
oscar/apps/product/reviews/urls.py 파일 보기

1
+from django.conf.urls.defaults import *
2
+
3
+from oscar.core.decorators import class_based_view
4
+from oscar.core.loading import import_module
5
+import_module('product.reviews.views', ['CreateProductReviewView', 'ProductReviewDetailView',
6
+                                        'ProductReviewListView'], locals())  
7
+
8
+urlpatterns = patterns('oscar.product.reviews.views',
9
+    url(r'(?P<pk>\d+)/$', ProductReviewDetailView.as_view(), name='oscar-product-review'),
10
+    url(r'add/$', CreateProductReviewView.as_view(), name='oscar-product-review-add'),
11
+    url(r'all/$', ProductReviewListView.as_view(), name='oscar-product-reviews'),
12
+)

+ 85
- 0
oscar/apps/product/reviews/views.py 파일 보기

1
+from django.conf import settings
2
+from django.http import HttpResponse, Http404, HttpResponsePermanentRedirect, HttpResponseRedirect
3
+from django.template import Context, loader, RequestContext
4
+from django.shortcuts import get_object_or_404, render
5
+from django.core.urlresolvers import reverse
6
+from django.core.paginator import Paginator, InvalidPage, EmptyPage
7
+from django.views.generic import ListView, DetailView, CreateView
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
12
+
13
+from oscar.view.generic import PostActionMixin
14
+from oscar.core.loading import import_module
15
+import_module('product.models', ['Item', 'ItemClass'], locals())
16
+import_module('basket.forms', ['FormFactory'], locals())
17
+import_module('product.reviews.models', ['ProductReview', 'Vote'], locals())
18
+
19
+
20
+class CreateProductReviewView(CreateView):
21
+    template_name = "oscar/reviews/add_review.html"
22
+    model = ProductReview
23
+    
24
+    def get_context_data(self, **kwargs):
25
+        context = super(CreateProductReviewView, self).get_context_data(**kwargs)
26
+        context['item'] = get_object_or_404(Item, pk=self.kwargs['item_id'])
27
+        return context
28
+    
29
+    def _get_form_class(self):
30
+        return make_review_form(self.request.user)
31
+
32
+
33
+class ProductReviewDetailView(DetailView, PostActionMixin):
34
+    """
35
+    Places each review on its own page
36
+    """
37
+    template_name = "oscar/reviews/review.html"
38
+    context_object_name = 'review'
39
+    model = ProductReview
40
+    
41
+    def get_context_data(self, **kwargs):
42
+        context = super(ProductReviewDetailView, self).get_context_data(**kwargs)
43
+        context['item'] = get_object_or_404(Item, pk=self.kwargs['item_id'])
44
+        return context
45
+    
46
+    def do_vote_up(self, review):
47
+        return self.vote_on_review(review, Vote.UP)
48
+    
49
+    def do_vote_down(self, review):
50
+        return self.vote_on_review(review, Vote.DOWN)   
51
+    
52
+    def vote_on_review(self, review, delta):
53
+        user = self.request.user
54
+        self.response = HttpResponseRedirect(review.get_absolute_url())
55
+        if review.user == user:
56
+            messages.info(self.request, "You cannot vote on your own reviews!")
57
+        else:
58
+            try:
59
+                Vote.objects.get(review=review, user=user)
60
+                messages.info(self.request, "You have already voted on this review!") 
61
+            except Vote.DoesNotExist:
62
+                Vote.objects.create(review=review, user=user, delta=delta)
63
+                messages.info(self.request, "Thanks for voting!")   
64
+
65
+    
66
+class ProductReviewListView(ListView):
67
+    u"""A list of reviews for a particular product
68
+    * The review browsing page allows reviews to be sorted by score, or recency.
69
+    """    
70
+    template_name = 'oscar/reviews/reviews.html'
71
+    context_object_name = "reviews"
72
+    model = ProductReview
73
+    paginate_by = 20
74
+     
75
+    def get_queryset(self):
76
+        qs = ProductReview.approved.filter(product=self.kwargs['item_id'])
77
+        if 'sort_by' in self.request.GET and self.request.GET['sort_by'] == 'score':
78
+            return qs.order_by('-score')
79
+        return qs.order_by('-date_created')
80
+     
81
+    def get_context_data(self, **kwargs):
82
+        context = super(ProductReviewListView, self).get_context_data(**kwargs)
83
+        context['item'] = get_object_or_404(Item, pk=self.kwargs['item_id'])  
84
+        context['avg_score'] = self.object_list.aggregate(Avg('score'))           
85
+        return context

+ 2
- 11
oscar/apps/product/urls.py 파일 보기

2
 
2
 
3
 from oscar.core.decorators import class_based_view
3
 from oscar.core.decorators import class_based_view
4
 from oscar.core.loading import import_module
4
 from oscar.core.loading import import_module
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+)/'
5
+import_module('product.views', ['ItemDetailView', 'ProductListView', 'ItemClassListView'], locals())  
10
 
6
 
11
 urlpatterns = patterns('oscar.product.views',
7
 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
+    url(r'(?P<item_class_slug>[\w-]+)/(?P<item_slug>[\w-]*)-(?P<item_id>\d+)/review/', include('oscar.apps.product.reviews.urls')),
18
     url(r'(?P<item_class_slug>[\w-]+)/(?P<item_slug>[\w-]*)-(?P<item_id>\d+)/$', ItemDetailView.as_view(), name='oscar-product-item'),
9
     url(r'(?P<item_class_slug>[\w-]+)/(?P<item_slug>[\w-]*)-(?P<item_id>\d+)/$', ItemDetailView.as_view(), name='oscar-product-item'),
19
     url(r'(?P<item_class_slug>[\w-]+)/$', ItemClassListView.as_view(), name='oscar-product-item-class'),
10
     url(r'(?P<item_class_slug>[\w-]+)/$', ItemClassListView.as_view(), name='oscar-product-item-class'),
20
     url(r'^$', ProductListView.as_view(), name='oscar-products'),
11
     url(r'^$', ProductListView.as_view(), name='oscar-products'),

+ 21
- 195
oscar/apps/product/views.py 파일 보기

4
 from django.shortcuts import get_object_or_404, render
4
 from django.shortcuts import get_object_or_404, render
5
 from django.core.urlresolvers import reverse
5
 from django.core.urlresolvers import reverse
6
 from django.core.paginator import Paginator, InvalidPage, EmptyPage
6
 from django.core.paginator import Paginator, InvalidPage, EmptyPage
7
-from django.views.generic import ListView, DetailView
7
+from django.views.generic import ListView, DetailView, CreateView
8
 from django.template.response import TemplateResponse
8
 from django.template.response import TemplateResponse
9
 from django.db.models import Avg
9
 from django.db.models import Avg
10
 from django.core.exceptions import ObjectDoesNotExist
10
 from django.core.exceptions import ObjectDoesNotExist
11
 from django.contrib import messages
11
 from django.contrib import messages
12
 
12
 
13
+from oscar.view.generic import PostActionMixin
13
 from oscar.core.loading import import_module
14
 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
16
-
17
-product_models = import_module('product.models', ['Item', 'ItemClass'])
18
-product_signals = import_module('product.signals', ['product_viewed', 'product_search'])
19
-basket_forms = import_module('basket.forms', ['FormFactory'])
20
-history_helpers = import_module('customer.history_helpers', ['receive_product_view'])
21
-review_models = import_module('reviews.models', ['ProductReview', 'Vote'])
15
+import_module('product.models', ['Item', 'ItemClass'], locals())
16
+import_module('product.signals', ['product_viewed', 'product_search'], locals())
17
+import_module('basket.forms', ['FormFactory'], locals())
18
+import_module('customer.history_helpers', ['receive_product_view'], locals())
19
+import_module('product.reviews.models', ['ProductReview', 'Vote'], locals())
22
 
20
 
23
 
21
 
24
 class ItemDetailView(DetailView):
22
 class ItemDetailView(DetailView):
38
         response = super(ItemDetailView, self).get(request, **kwargs)
36
         response = super(ItemDetailView, self).get(request, **kwargs)
39
         
37
         
40
         # Send signal to record the view of this product
38
         # Send signal to record the view of this product
41
-        product_signals.product_viewed.send(sender=self, product=item, user=request.user, request=request, response=response)
39
+        product_viewed.send(sender=self, product=item, user=request.user, request=request, response=response)
42
         return response;
40
         return response;
43
     
41
     
44
     def get_template_names(self):
42
     def get_template_names(self):
64
         
62
         
65
         We cache the object as this method gets called twice."""
63
         We cache the object as this method gets called twice."""
66
         if not self._item:
64
         if not self._item:
67
-            self._item = get_object_or_404(product_models.Item, pk=self.kwargs['item_id'])
65
+            self._item = get_object_or_404(Item, pk=self.kwargs['item_id'])
68
         return self._item
66
         return self._item
69
     
67
     
70
     def get_context_data(self, **kwargs):
68
     def get_context_data(self, **kwargs):
71
         context = super(ItemDetailView, self).get_context_data(**kwargs)
69
         context = super(ItemDetailView, self).get_context_data(**kwargs)
72
         context['basket_form'] = self.get_add_to_basket_form()
70
         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()
71
+        context['reviews'] = self.get_reviews()
72
+        context['avg_score'] = self.get_avg_review_score()
77
         return context
73
         return context
78
     
74
     
79
     def get_add_to_basket_form(self):
75
     def get_add_to_basket_form(self):
80
-        factory = basket_forms.FormFactory()
76
+        factory = FormFactory()
81
         return factory.create(self.object)
77
         return factory.create(self.object)
82
     
78
     
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()
79
+    def get_reviews(self):
80
+        return ProductReview.approved.filter(product=self.get_object())
88
     
81
     
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()
82
+    def get_avg_review_score(self):
83
+        return ProductReview.approved.aggregate(Avg('score'))['score__avg']
98
 
84
 
99
     
85
     
100
 class ItemClassListView(ListView):
86
 class ItemClassListView(ListView):
104
     paginate_by = 20
90
     paginate_by = 20
105
 
91
 
106
     def get_queryset(self):
92
     def get_queryset(self):
107
-        item_class = get_object_or_404(product_models.ItemClass, slug=self.kwargs['item_class_slug'])
108
-        return product_models.Item.browsable.filter(item_class=item_class)
93
+        item_class = get_object_or_404(ItemClass, slug=self.kwargs['item_class_slug'])
94
+        return Item.browsable.filter(item_class=item_class)
109
 
95
 
110
 
96
 
111
 class ProductListView(ListView):
97
 class ProductListView(ListView):
126
         q = self.get_search_query()
112
         q = self.get_search_query()
127
         if q:
113
         if q:
128
             # Send signal to record the view of this product
114
             # Send signal to record the view of this product
129
-            product_signals.product_search.send(sender=self, query=q, user=self.request.user)
115
+            product_search.send(sender=self, query=q, user=self.request.user)
130
             
116
             
131
-            return product_models.Item.browsable.filter(title__icontains=q)
117
+            return Item.browsable.filter(title__icontains=q)
132
         else:
118
         else:
133
-            return product_models.Item.browsable.all()
119
+            return Item.browsable.all()
134
         
120
         
135
     def get_context_data(self, **kwargs):
121
     def get_context_data(self, **kwargs):
136
         context = super(ProductListView, self).get_context_data(**kwargs)
122
         context = super(ProductListView, self).get_context_data(**kwargs)
141
             context['summary'] = "Products matching '%s'" % q
127
             context['summary'] = "Products matching '%s'" % q
142
             context['search_term'] = q
128
             context['search_term'] = q
143
         return context
129
         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
- 130
oscar/apps/reviews/abstract_models.py 파일 보기

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)

+ 0
- 16
oscar/apps/reviews/admin.py 파일 보기

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)

+ 37
- 40
oscar/templates/oscar/product/detail.html 파일 보기

5
 {% load thumbnail %}
5
 {% load thumbnail %}
6
 
6
 
7
 {% block header %}
7
 {% block header %}
8
-<h2>{{item.get_title}}</h2>
8
+<h2>{{ item.get_title }}</h2>
9
 {% endblock header %}
9
 {% endblock header %}
10
 
10
 
11
 
11
 
29
     <tr>
29
     <tr>
30
         <th>Product class</th><td><a href="{{ item.item_class.get_absolute_url }}">{{item.item_class.name}}</a></td>
30
         <th>Product class</th><td><a href="{{ item.item_class.get_absolute_url }}">{{item.item_class.name}}</a></td>
31
     </tr>
31
     </tr>
32
-{% if item.stockrecord %}
32
+    {% if item.stockrecord %}
33
     <tr>
33
     <tr>
34
         <th>Price (excl. tax)</th><td>{{item.stockrecord.price_incl_tax|currency}}</td>
34
         <th>Price (excl. tax)</th><td>{{item.stockrecord.price_incl_tax|currency}}</td>
35
     </tr>
35
     </tr>
40
         <th>Availability</th>
40
         <th>Availability</th>
41
         <td>{{ item.stockrecord.availability }}</td>
41
         <td>{{ item.stockrecord.availability }}</td>
42
     </tr>
42
     </tr>
43
-{% endif %}
43
+    {% endif %}
44
     <tr>
44
     <tr>
45
         <th>Product type</th>
45
         <th>Product type</th>
46
         <td>
46
         <td>
52
         <th>{{ attribute.type.name }}</th>
52
         <th>{{ attribute.type.name }}</th>
53
         <th>{{ attribute.value }}</th>
53
         <th>{{ attribute.value }}</th>
54
     </tr>
54
     </tr>
55
+    {% endfor %}
55
     <tr>
56
     <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>
57
+        <td><a href="{% url oscar-product-review-add item.item_class.slug item.slug item.id %}">Add a review</a></td>
58
+        <td><a href="{% url oscar-product-reviews item.item_class.slug item.slug item.id %}">See all reviews</a></td>
58
     </tr>
59
     </tr>
59
-    {% endfor %}
60
+    
60
 </table>
61
 </table>
61
 
62
 
62
 {% if item.stockrecord %}
63
 {% if item.stockrecord %}
67
 </form>
68
 </form>
68
 {% endif %}
69
 {% endif %}
69
 
70
 
70
-{% recently_viewed_products %}
71
-
72
 {% if item.related_items.count %}
71
 {% if item.related_items.count %}
73
 <div class="products">
72
 <div class="products">
74
     <h4>Related items</h4>
73
     <h4>Related items</h4>
93
 
92
 
94
 {% block product_review %}
93
 {% block product_review %}
95
 
94
 
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 %}
95
+    {% if reviews %}
96
+    	<h3> Product Reviews </h3>
97
+    	<h4> Average score: {{ avg_score|floatformat:1  }} / 5.0 </h4>
98
+    	{% for review in reviews|slice:":5" %}		
99
+    		<h4> {{ forloop.counter}}. {{ review.title }} </h4>
100
+    		<p> Score: {{ review.score|floatformat:1 }} / 5.0</p>
101
+    		<p> Comments: {{ review.body|truncatewords:50 }}</p>		
102
+    		<p> Date created: {{ review.date_created }}</p>
103
+    		<a href="{{ review.get_absolute_url }}">View full review</a>			
104
+            {% if review.has_votes %}
105
+            	<h5> {{ review.num_up_votes }} out of {{ review.total_votes }} customers found this review useful.</h5>
106
+            {% endif %}
107
+            {% if user.is_authenticated %}
108
+        		<h4>Was this review helpful to you?</h4>
109
+        		<form action="{{ review.get_absolute_url }}" method="post">
110
+        			{% csrf_token %}               
111
+                    <input type="hidden" name="action" value="vote_up"/>
112
+                    <input type="submit" value="Yes" /> 
113
+                </form>
114
+        		<form  action="{{ review.get_absolute_url }}" method="post">
115
+        			{% csrf_token %}                
116
+                    <input type="hidden" name="action" value="vote_down"/>
117
+                    <input type="submit" value="No" /> 
118
+                </form>
119
+    	    {% endif %}			
120
+    	{% endfor %}
121
+    {% endif %}
122
+
128
 {% endblock product_review %}
123
 {% endblock product_review %}
129
 
124
 
125
+{% recently_viewed_products %}
126
+
130
 {% endblock content %}
127
 {% endblock content %}
131
 
128
 

+ 9
- 65
oscar/templates/oscar/reviews/add_review.html 파일 보기

1
-{% extends "layout.html" %}
1
+{% extends "oscar/product/detail.html" %}
2
 
2
 
3
-{% load currency_filters %}
3
+{% block product_review %}
4
 
4
 
5
-{% block header %}
6
-<h2>{{item.get_title}}</h2>
7
-{% endblock header %}
5
+    <h4>Leave a product review</h4>
6
+    <form method="post" action="{% url oscar-product-review-add item.item_class.slug item.slug item.id %}">
7
+        {% csrf_token %}
8
+        {{ form.as_p }}
9
+        <input type="submit" value="Submit" /> 
10
+    </form>
8
 
11
 
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 %}
12
+{% endblock %}

+ 26
- 22
oscar/templates/oscar/reviews/reviews.html 파일 보기

1
 {% extends "oscar/product/detail.html" %}
1
 {% extends "oscar/product/detail.html" %}
2
 
2
 
3
 {% block product_review %}
3
 {% block product_review %}
4
+
4
 {% if reviews %}
5
 {% if reviews %}
5
 	{% if avg_score %}
6
 	{% if avg_score %}
6
 		<h3> All Product Reviews ({{ reviews.count }}) </h3>
7
 		<h3> All Product Reviews ({{ reviews.count }}) </h3>
7
 		<h4> Sort by: </h4>
8
 		<h4> Sort by: </h4>
8
-		<form  action="{{ request.get_full_path }}" method="get">
9
-			{% csrf_token %}
9
+		<form action="{{ request.get_full_path }}" method="get">
10
             <select name="sort_by">
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>            
11
+            	<option value="score">Score</option>
12
+            	<option value="recency">Recency</option>
13
+            </select>
14
+            <input type="submit" value="Go" />             
15
         </form>
15
         </form>
16
 		<h4> Average score: {{ avg_score|floatformat:1  }} / 5.0 </h4>
16
 		<h4> Average score: {{ avg_score|floatformat:1  }} / 5.0 </h4>
17
 	{% endif %}
17
 	{% endif %}
22
 		<p> Date created: {{ review.date_created }}</p>		
22
 		<p> Date created: {{ review.date_created }}</p>		
23
 	{% endfor %}
23
 	{% endfor %}
24
 	
24
 	
25
-{% if page_obj %}
26
-<div class="pagination">
27
-    <span class="step-links">
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 %}
28
     
32
     
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 }}.
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 %}
35
         </span>
40
         </span>
41
+    </div>
42
+    {% endif %}
43
+		
44
+{% else %}
36
 
45
 
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
-							
46
+<p>This product does not have any reviews yet.</p>        
47
+        					
44
 {% endif %}
48
 {% endif %}
45
 {% endblock %}
49
 {% endblock %}

Loading…
취소
저장