From 4387d3f9eb1d9238e14db23680caae92496e8452 Mon Sep 17 00:00:00 2001 From: Lorin Hochstein Date: Tue, 8 Jan 2013 22:26:52 -0500 Subject: [PATCH 1/3] Add tests for product view This commit adds some tests of the Product view code. --- cartridge/shop/tests.py | 113 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 110 insertions(+), 3 deletions(-) diff --git a/cartridge/shop/tests.py b/cartridge/shop/tests.py index c16abd449..e8d3d8a44 100644 --- a/cartridge/shop/tests.py +++ b/cartridge/shop/tests.py @@ -5,9 +5,11 @@ from operator import mul from django.core.urlresolvers import reverse +from django.db.models import F from django.test import TestCase from django.test.client import RequestFactory from mezzanine.conf import settings +from mezzanine.core.models import CONTENT_STATUS_DRAFT from mezzanine.core.models import CONTENT_STATUS_PUBLISHED from mezzanine.utils.tests import run_pyflakes_for_package from mezzanine.utils.tests import run_pep8_for_package @@ -41,13 +43,12 @@ def setUp(self): def test_views(self): """ Test the main shop views for errors. + + Product view tests are in the ProductViewTests class """ # Category. response = self.client.get(self._category.get_absolute_url()) self.assertEqual(response.status_code, 200) - # Product. - response = self.client.get(self._product.get_absolute_url()) - self.assertEqual(response.status_code, 200) # Cart. response = self.client.get(reverse("shop_cart")) self.assertEqual(response.status_code, 200) @@ -352,6 +353,112 @@ def test_syntax(self): self.fail("Syntax warnings!\n\n%s" % "\n".join(warnings)) +class ProductViewTests(TestCase): + """ + Test Product views + """ + + def setUp(self): + """ + Set up test data - product, options and variations + """ + self._published = {"status": CONTENT_STATUS_PUBLISHED} + self._product = Product.objects.create(**self._published) + self._product.available = True + self._product.save() + ProductOption.objects.create(type=1, name="Small") + ProductOption.objects.create(type=1, name="Medium") + ProductOption.objects.create(type=2, name="Blue") + ProductOption.objects.create(type=2, name="Read") + product_options = ProductOption.objects.as_fields() + self._product.variations.create_from_options(product_options) + self._product.variations.manage_empty() + self._product.variations.update(unit_price=F("id") + "10000") + self._product.variations.update(unit_price=F("unit_price") / "1000.0") + self._product.variations.update(num_in_stock=TEST_STOCK) + + product_variation = ProductVariation.objects.get(option1='Small', + option2='Blue') + product_variation.sku = "widget-small-blue" + product_variation.save() + + def test_get(self): + """ + Test the product view + """ + # Product. + response = self.client.get(self._product.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + # Check if quantity defaults correctly + self.assertContains(response, + '', + html=True) + + def test_404_on_not_published(self): + """ + Test that we get a 404 if it's not published + """ + self._product.status = CONTENT_STATUS_DRAFT + self._product.save() + + response = self.client.get(self._product.get_absolute_url()) + self.assertEqual(response.status_code, 404) + + def test_not_available(self): + """ + Test that we get the appropriate message when the product is not + available + """ + self._product.available = False + self._product.save() + response = self.client.get(self._product.get_absolute_url()) + self.assertContains(response, + "This product is currently unavailable.") + + def test_add_to_cart(self): + """ + Test that the item gets added to the cart when clicking the buy + button + """ + self._product.save() + + # Test initial cart. + cart = Cart.objects.from_request(self.client) + self.assertFalse(cart.has_items()) + self.assertEqual(cart.total_quantity(), 0) + + url = self._product.get_absolute_url() + response = self.client.get(url) + payload = {'quantity': '1', + 'option1': 'Small', + 'option2': 'Blue', + 'add_cart': 'Buy'} + response = self.client.post(url, payload, follow=True) + self.assertRedirects(response, "/shop/cart/") + cart = Cart.objects.from_request(self.client) + + self.assertEqual(cart.skus(), ["widget-small-blue"]) + + def test_add_to_wishlist(self): + """ + Test that the item gets add to the wishlist when clicking the wishlist + button + """ + self._product.available = True + self._product.save() + + url = self._product.get_absolute_url() + response = self.client.get(url) + payload = {'quantity': '1', + 'option1': 'Small', + 'option2': 'Blue', + 'add_wishlist': 'Save for later'} + response = self.client.post(url, payload, follow=True) + self.assertRedirects(response, "/shop/wishlist/") + self.assertEqual(response._request.wishlist, ["widget-small-blue"]) + + class SaleTests(TestCase): def setUp(self): From f8b502af5024110c7967aafc205cbc597f652338 Mon Sep 17 00:00:00 2001 From: Lorin Hochstein Date: Tue, 8 Jan 2013 22:43:57 -0500 Subject: [PATCH 2/3] Remove some unnecessary code from tests --- cartridge/shop/tests.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/cartridge/shop/tests.py b/cartridge/shop/tests.py index e8d3d8a44..2dad8670a 100644 --- a/cartridge/shop/tests.py +++ b/cartridge/shop/tests.py @@ -421,7 +421,6 @@ def test_add_to_cart(self): Test that the item gets added to the cart when clicking the buy button """ - self._product.save() # Test initial cart. cart = Cart.objects.from_request(self.client) @@ -445,8 +444,6 @@ def test_add_to_wishlist(self): Test that the item gets add to the wishlist when clicking the wishlist button """ - self._product.available = True - self._product.save() url = self._product.get_absolute_url() response = self.client.get(url) From fa089a832ecfdc88aea7bfc6d9bbcd6f563c1db0 Mon Sep 17 00:00:00 2001 From: Lorin Hochstein Date: Tue, 8 Jan 2013 22:45:07 -0500 Subject: [PATCH 3/3] Refactor product view to use class-based views This is a proposed refactoring of the product view code to use class-based views. There are no (intentional) changes to the behavior in this commit. The motivation behind this proposed change is to make it easier for users to extend Cartridge through subclassing. For example, if a Cartridge user wanted to be able to use a subclass of the Product class in their template, all they would need to do is: 1. subclass the proposed ProductDetailView class 2. Override the get_object method in the subclass 3. Add a line in urls.py in their project to use the subclass The main change is replacing the cartridge.shop.views.product function with the cartridge.shop.views.ProductDetailView class. The only other required change was the cartridge.shop.forms.AddProductForm.__init__ method. This change was needed because the Django class-based views don't support passing a positional argument to the __init__ method of forms. However, the Django class-based view code automatically passes request.POST as an argument called 'data' (see django.views.generic.edit.FormMixin.get_form_kwargs), so I made a change to use `data` as a keyword argument as a replacement for the positional arguemnt. --- cartridge/shop/forms.py | 7 +- cartridge/shop/urls.py | 5 +- cartridge/shop/views.py | 162 ++++++++++++++++++++++++++++------------ 3 files changed, 121 insertions(+), 53 deletions(-) diff --git a/cartridge/shop/forms.py b/cartridge/shop/forms.py index 5986264c9..7e5710d17 100644 --- a/cartridge/shop/forms.py +++ b/cartridge/shop/forms.py @@ -38,7 +38,7 @@ class AddProductForm(forms.Form): quantity = forms.IntegerField(label=_("Quantity"), min_value=1) sku = forms.CharField(required=False, widget=forms.HiddenInput()) - def __init__(self, *args, **kwargs): + def __init__(self, **kwargs): """ Handles adding a variation to the cart or wishlist. @@ -59,9 +59,10 @@ def __init__(self, *args, **kwargs): """ self._product = kwargs.pop("product", None) self._to_cart = kwargs.pop("to_cart") - super(AddProductForm, self).__init__(*args, **kwargs) + super(AddProductForm, self).__init__(**kwargs) + data = kwargs.get('data') # Adding from the wishlist with a sku, bail out. - if args[0] is not None and args[0].get("sku", None): + if data is not None and data.get("sku", None): return # Adding from the product page, remove the sku field # and build the choice fields for the variations. diff --git a/cartridge/shop/urls.py b/cartridge/shop/urls.py index a6e7a09f9..a6a57d5a4 100644 --- a/cartridge/shop/urls.py +++ b/cartridge/shop/urls.py @@ -1,8 +1,9 @@ - +from cartridge.shop.views import ProductDetailView from django.conf.urls.defaults import patterns, url urlpatterns = patterns("cartridge.shop.views", - url("^product/(?P.*)/$", "product", name="shop_product"), + url("^product/(?P.*)/$", ProductDetailView.as_view(), + name="shop_product"), url("^wishlist/$", "wishlist", name="shop_wishlist"), url("^cart/$", "cart", name="shop_cart"), url("^checkout/$", "checkout_steps", name="shop_checkout"), diff --git a/cartridge/shop/views.py b/cartridge/shop/views.py index d068880b2..fd57532d8 100644 --- a/cartridge/shop/views.py +++ b/cartridge/shop/views.py @@ -1,4 +1,3 @@ - from collections import defaultdict from django.contrib.auth.decorators import login_required @@ -12,6 +11,8 @@ from django.utils import simplejson from django.utils.translation import ugettext as _ from django.views.decorators.cache import never_cache +from django.views.generic import DetailView +from django.views.generic.edit import BaseFormView from mezzanine.conf import settings from mezzanine.utils.importing import import_dotted_path @@ -31,55 +32,120 @@ order_handler = handler(settings.SHOP_HANDLER_ORDER) -def product(request, slug, template="shop/product.html"): +class DetailViewWithForm(DetailView, BaseFormView): """ - Display a product - convert the product variations to JSON as well as - handling adding the product to either the cart or the wishlist. + A detail view of an object, with form processing """ - published_products = Product.objects.published(for_user=request.user) - product = get_object_or_404(published_products, slug=slug) - fields = [f.name for f in ProductVariation.option_fields()] - variations = product.variations.all() - variations_json = simplejson.dumps([dict([(f, getattr(v, f)) - for f in fields + ["sku", "image_id"]]) - for v in variations]) - to_cart = (request.method == "POST" and - request.POST.get("add_wishlist") is None) - initial_data = {} - if variations: - initial_data = dict([(f, getattr(variations[0], f)) for f in fields]) - initial_data["quantity"] = 1 - add_product_form = AddProductForm(request.POST or None, product=product, - initial=initial_data, to_cart=to_cart) - if request.method == "POST": - if add_product_form.is_valid(): - if to_cart: - quantity = add_product_form.cleaned_data["quantity"] - request.cart.add_item(add_product_form.variation, quantity) - recalculate_discount(request) - info(request, _("Item added to cart")) - return redirect("shop_cart") - else: - skus = request.wishlist - sku = add_product_form.variation.sku - if sku not in skus: - skus.append(sku) - info(request, _("Item added to wishlist")) - response = redirect("shop_wishlist") - set_cookie(response, "wishlist", ",".join(skus)) - return response - context = { - "product": product, - "editable_obj": product, - "images": product.images.all(), - "variations": variations, - "variations_json": variations_json, - "has_available_variations": any([v.has_price() for v in variations]), - "related_products": product.related_products.published( - for_user=request.user), - "add_product_form": add_product_form - } - return render(request, template, context) + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + return BaseFormView.get(self, request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + return BaseFormView.post(self, request, *args, **kwargs) + + def get_context_data(self, **kwargs): + return DetailView.get_context_data(self, **kwargs) + + +class ProductDetailView(DetailViewWithForm): + """ + Display a product + + Handle adding the product to either the cart or the wishlist. + """ + + context_object_name = "product" + model = Product + template_name = "shop/product.html" + form_class = AddProductForm + + def get_initial(self): + """ + Returns the dict that will be passed to the AddProductForm + constructor as the `initial` parameter + """ + product = self.object + variations = product.variations.all() + fields = [f.name for f in ProductVariation.option_fields()] + initial_data = {} + if variations: + initial_data = dict([(f, getattr(variations[0], f)) + for f in fields]) + initial_data["quantity"] = 1 + return initial_data + + def get_form_kwargs(self): + """ + Return the argumentst that will be passed to AddProductForm + constructor, other than `initial` and `data` + """ + kwargs = super(ProductDetailView, self).get_form_kwargs() + kwargs['product'] = self.object + kwargs['to_cart'] = self.to_cart() + return kwargs + + def get_queryset(self): + """ + Restrict viewable products to ones that have been published for + the user + """ + return Product.objects.published(for_user=self.request.user) + + def to_cart(self): + """ + Return True if product should be added to cart, False if should be + added to wishlist + """ + return (self.request.method == "POST" and + self.request.POST.get("add_wishlist") is None) + + def form_valid(self, form): + """ + Called after form has been validated. + """ + add_product_form = form + if self.to_cart(): + quantity = add_product_form.cleaned_data["quantity"] + self.request.cart.add_item(add_product_form.variation, quantity) + recalculate_discount(self.request) + info(self.request, _("Item added to cart")) + return redirect("shop_cart") + else: + skus = self.request.wishlist + sku = add_product_form.variation.sku + if sku not in skus: + skus.append(sku) + info(self.request, _("Item added to wishlist")) + response = redirect("shop_wishlist") + set_cookie(response, "wishlist", ",".join(skus)) + return response + + def get_context_data(self, **kwargs): + """ + Return additional variables to be passed to the template + """ + context = super(ProductDetailView, self).get_context_data(**kwargs) + product = self.object + fields = [f.name for f in ProductVariation.option_fields()] + variations = product.variations.all() + variations_json = simplejson.dumps([dict([(f, getattr(v, f)) + for f in fields + ["sku", "image_id"]]) + for v in variations]) + context["editable_obj"] = product + context["images"] = product.images.all() + context["variations"] = variations + context["variations_json"] = variations_json + context["has_available_variations"] = any([v.has_price() for v in + variations]) + context["related_products"] = product.related_products.published( + for_user=self.request.user) + + # Since the existing template uses add_product_form, we switch + # the form key to add_product_form + context["add_product_form"] = context.pop("form") + return context @never_cache