Repository: saleor/saleor

Issue:
Increase Menu Tests coverage
Dashboard Menu views were not tested for permissions required
Increase Dashboard Product tests coverage
Views with following names do not appear in tests:
variant-add
variant-update
variant-details
variant-images
variant-stock-add
variant-stock-details
product-image-list
product-images-upload
product-bulk-update

We should test both, views + views permissions


Discussion:

Resolving patch:
diff --git a/saleor/dashboard/product/forms.py b/saleor/dashboard/product/forms.py
--- a/saleor/dashboard/product/forms.py
+++ b/saleor/dashboard/product/forms.py
@@ -273,8 +273,7 @@ class ProductVariantForm(forms.ModelForm, AttributesMixin):
 
     class Meta:
         model = ProductVariant
-        exclude = [
-            'attributes', 'product', 'images', 'name', 'quantity_allocated']
+        fields = ['sku', 'price_override', 'quantity', 'cost_price']
         labels = {
             'sku': pgettext_lazy('SKU', 'SKU'),
             'price_override': pgettext_lazy(
@@ -447,7 +446,7 @@ def save(self, commit=True):
 
 
 class ProductBulkUpdate(forms.Form):
-    """Performs one selected bulk action on all selected products."""
+    """Perform one selected bulk action on all selected products."""
 
     action = forms.ChoiceField(choices=ProductBulkAction.CHOICES)
     products = forms.ModelMultipleChoiceField(queryset=Product.objects.all())
diff --git a/saleor/dashboard/product/urls.py b/saleor/dashboard/product/urls.py
--- a/saleor/dashboard/product/urls.py
+++ b/saleor/dashboard/product/urls.py
@@ -7,18 +7,20 @@
         views.product_list, name='product-list'),
     url(r'^(?P<pk>[0-9]+)/$',
         views.product_details, name='product-details'),
-    url(r'^(?P<pk>[0-9]+)/publish/$', views.product_toggle_is_published,
-        name='product-publish'),
+    url(r'^(?P<pk>[0-9]+)/publish/$',
+        views.product_toggle_is_published, name='product-publish'),
+    url(r'^add/select-type/$',
+        views.product_select_type, name='product-add-select-type'),
+    url(r'^add/(?P<type_pk>[0-9]+)/$',
+        views.product_create, name='product-add'),
     url(r'^(?P<pk>[0-9]+)/update/$',
         views.product_edit, name='product-update'),
     url(r'^(?P<pk>[0-9]+)/delete/$',
         views.product_delete, name='product-delete'),
-    url(r'^add/(?P<type_pk>[0-9]+)/$',
-        views.product_create, name='product-add'),
     url(r'^bulk-update/$',
         views.product_bulk_update, name='product-bulk-update'),
-    url(r'^add/select-type/$',
-        views.product_select_type, name='product-add-select-type'),
+    url(r'^ajax/products/$',
+        views.ajax_products_list, name='ajax-products'),
 
     url(r'^types/$',
         views.product_type_list, name='product-type-list'),
@@ -29,26 +31,27 @@
     url(r'^types/(?P<pk>[0-9]+)/delete/$',
         views.product_type_delete, name='product-type-delete'),
 
-    url(r'^(?P<product_pk>[0-9]+)/variants/add/$',
-        views.variant_add, name='variant-add'),
     url(r'^(?P<product_pk>[0-9]+)/variants/(?P<variant_pk>[0-9]+)/$',
         views.variant_details, name='variant-details'),
+    url(r'^(?P<product_pk>[0-9]+)/variants/add/$',
+        views.variant_create, name='variant-add'),
     url(r'^(?P<product_pk>[0-9]+)/variants/(?P<variant_pk>[0-9]+)/update/$',
         views.variant_edit, name='variant-update'),
     url(r'^(?P<product_pk>[0-9]+)/variants/(?P<variant_pk>[0-9]+)/delete/$',
         views.variant_delete, name='variant-delete'),
     url(r'^(?P<product_pk>[0-9]+)/variants/(?P<variant_pk>[0-9]+)/images/$',
         views.variant_images, name='variant-images'),
+    url(r'^ajax/variants/$',
+        views.ajax_available_variants_list, name='ajax-available-variants'),
 
-    url(r'^(?P<product_pk>[0-9]+)/images/$', views.product_images,
-        name='product-image-list'),
+    url(r'^(?P<product_pk>[0-9]+)/images/$',
+        views.product_images, name='product-image-list'),
     url(r'^(?P<product_pk>[0-9]+)/images/add/$',
-        views.product_image_add, name='product-image-add'),
+        views.product_image_create, name='product-image-add'),
     url(r'^(?P<product_pk>[0-9]+)/images/(?P<img_pk>[0-9]+)/$',
         views.product_image_edit, name='product-image-update'),
     url(r'^(?P<product_pk>[0-9]+)/images/(?P<img_pk>[0-9]+)/delete/$',
         views.product_image_delete, name='product-image-delete'),
-
     url(r'^(?P<product_pk>[0-9]+)/images/reorder/$',
         views.ajax_reorder_product_images, name='product-images-reorder'),
     url(r'^(?P<product_pk>[0-9]+)/images/upload/$',
@@ -59,22 +62,17 @@
     url(r'attributes/(?P<pk>[0-9]+)/$',
         views.attribute_details, name='product-attribute-details'),
     url(r'attributes/add/$',
-        views.attribute_add, name='product-attribute-add'),
+        views.attribute_create, name='product-attribute-add'),
     url(r'attributes/(?P<pk>[0-9]+)/update/$',
         views.attribute_edit, name='product-attribute-update'),
     url(r'attributes/(?P<pk>[0-9]+)/delete/$',
         views.attribute_delete, name='product-attribute-delete'),
     url(r'attributes/(?P<attribute_pk>[0-9]+)/value/add/$',
-        views.attribute_choice_value_add,
+        views.attribute_choice_value_create,
         name='product-attribute-value-add'),
     url(r'attributes/(?P<attribute_pk>[0-9]+)/value/(?P<value_pk>[0-9]+)/update/$',  # noqa
         views.attribute_choice_value_edit,
         name='product-attribute-value-update'),
     url(r'attributes/(?P<attribute_pk>[0-9]+)/value/(?P<value_pk>[0-9]+)/delete/$',  # noqa
         views.attribute_choice_value_delete,
-        name='product-attribute-value-delete'),
-
-    url(r'^ajax/variants/$',
-        views.ajax_available_variants_list, name='ajax-available-variants'),
-    url(r'^ajax/products/$',
-        views.ajax_products_list, name='ajax-products')]
+        name='product-attribute-value-delete')]
diff --git a/saleor/dashboard/product/views.py b/saleor/dashboard/product/views.py
--- a/saleor/dashboard/product/views.py
+++ b/saleor/dashboard/product/views.py
@@ -21,88 +21,6 @@
 from .filters import ProductAttributeFilter, ProductFilter, ProductTypeFilter
 
 
-@staff_member_required
-@permission_required('product.view_properties')
-def product_type_list(request):
-    types = ProductType.objects.all().prefetch_related(
-        'product_attributes', 'variant_attributes').order_by('name')
-    type_filter = ProductTypeFilter(request.GET, queryset=types)
-    form = forms.ProductTypeForm(request.POST or None)
-    if form.is_valid():
-        return redirect('dashboard:product-type-add')
-    types = get_paginator_items(
-        type_filter.qs, settings.DASHBOARD_PAGINATE_BY,
-        request.GET.get('page'))
-    types.object_list = [
-        (pc.pk, pc.name, pc.has_variants, pc.product_attributes.all(),
-         pc.variant_attributes.all())
-        for pc in types.object_list]
-    ctx = {
-        'form': form, 'product_types': types, 'filter_set': type_filter,
-        'is_empty': not type_filter.queryset.exists()}
-    return TemplateResponse(
-        request,
-        'dashboard/product/product_type/list.html',
-        ctx)
-
-
-@staff_member_required
-@permission_required('product.edit_properties')
-def product_type_create(request):
-    product_type = ProductType()
-    form = forms.ProductTypeForm(
-        request.POST or None, instance=product_type)
-    if form.is_valid():
-        product_type = form.save()
-        msg = pgettext_lazy(
-            'Dashboard message', 'Added product type %s') % (product_type,)
-        messages.success(request, msg)
-        return redirect('dashboard:product-type-list')
-    ctx = {'form': form, 'product_type': product_type}
-    return TemplateResponse(
-        request,
-        'dashboard/product/product_type/form.html',
-        ctx)
-
-
-@staff_member_required
-@permission_required('product.edit_properties')
-def product_type_edit(request, pk):
-    product_type = get_object_or_404(ProductType, pk=pk)
-    form = forms.ProductTypeForm(
-        request.POST or None, instance=product_type)
-    if form.is_valid():
-        product_type = form.save()
-        msg = pgettext_lazy(
-            'Dashboard message', 'Updated product type %s') % (product_type,)
-        messages.success(request, msg)
-        return redirect('dashboard:product-type-update', pk=pk)
-    ctx = {'form': form, 'product_type': product_type}
-    return TemplateResponse(
-        request,
-        'dashboard/product/product_type/form.html',
-        ctx)
-
-
-@staff_member_required
-@permission_required('product.edit_properties')
-def product_type_delete(request, pk):
-    product_type = get_object_or_404(ProductType, pk=pk)
-    if request.method == 'POST':
-        product_type.delete()
-        msg = pgettext_lazy(
-            'Dashboard message', 'Removed product type %s') % (product_type,)
-        messages.success(request, msg)
-        return redirect('dashboard:product-type-list')
-    ctx = {
-        'product_type': product_type,
-        'products': product_type.products.all()}
-    return TemplateResponse(
-        request,
-        'dashboard/product/product_type/modal/confirm_delete.html',
-        ctx)
-
-
 @staff_member_required
 @permission_required('product.view_product')
 def product_list(request):
@@ -121,6 +39,44 @@ def product_list(request):
     return TemplateResponse(request, 'dashboard/product/list.html', ctx)
 
 
+@staff_member_required
+@permission_required('product.view_product')
+def product_details(request, pk):
+    products = Product.objects.prefetch_related('variants', 'images').all()
+    product = get_object_or_404(products, pk=pk)
+    variants = product.variants.all()
+    images = product.images.all()
+    availability = get_availability(
+        product, discounts=request.discounts, taxes=request.taxes)
+    sale_price = availability.price_range_undiscounted
+    discounted_price = availability.price_range
+    purchase_cost, margin = get_product_costs_data(product)
+
+    # no_variants is True for product types that doesn't require variant.
+    # In this case we're using the first variant under the hood to allow stock
+    # management.
+    no_variants = not product.product_type.has_variants
+    only_variant = variants.first() if no_variants else None
+    ctx = {
+        'product': product, 'sale_price': sale_price,
+        'discounted_price': discounted_price, 'variants': variants,
+        'images': images, 'no_variants': no_variants,
+        'only_variant': only_variant, 'purchase_cost': purchase_cost,
+        'margin': margin, 'is_empty': not variants.exists()}
+    return TemplateResponse(request, 'dashboard/product/detail.html', ctx)
+
+
+@require_POST
+@staff_member_required
+@permission_required('product.edit_product')
+def product_toggle_is_published(request, pk):
+    product = get_object_or_404(Product, pk=pk)
+    product.is_published = not product.is_published
+    product.save(update_fields=['is_published'])
+    return JsonResponse(
+        {'success': True, 'is_published': product.is_published})
+
+
 @staff_member_required
 @permission_required('product.edit_product')
 def product_select_type(request):
@@ -173,44 +129,6 @@ def product_create(request, type_pk):
     return TemplateResponse(request, 'dashboard/product/form.html', ctx)
 
 
-@staff_member_required
-@permission_required('product.view_product')
-def product_details(request, pk):
-    products = Product.objects.prefetch_related('variants', 'images').all()
-    product = get_object_or_404(products, pk=pk)
-    variants = product.variants.all()
-    images = product.images.all()
-    availability = get_availability(
-        product, discounts=request.discounts, taxes=request.taxes)
-    sale_price = availability.price_range_undiscounted
-    discounted_price = availability.price_range
-    purchase_cost, margin = get_product_costs_data(product)
-
-    # no_variants is True for product types that doesn't require variant.
-    # In this case we're using the first variant under the hood to allow stock
-    # management.
-    no_variants = not product.product_type.has_variants
-    only_variant = variants.first() if no_variants else None
-    ctx = {
-        'product': product, 'sale_price': sale_price,
-        'discounted_price': discounted_price, 'variants': variants,
-        'images': images, 'no_variants': no_variants,
-        'only_variant': only_variant, 'purchase_cost': purchase_cost,
-        'margin': margin, 'is_empty': not variants.exists()}
-    return TemplateResponse(request, 'dashboard/product/detail.html', ctx)
-
-
-@require_POST
-@staff_member_required
-@permission_required('product.edit_product')
-def product_toggle_is_published(request, pk):
-    product = get_object_or_404(Product, pk=pk)
-    product.is_published = not product.is_published
-    product.save(update_fields=['is_published'])
-    return JsonResponse(
-        {'success': True, 'is_published': product.is_published})
-
-
 @staff_member_required
 @permission_required('product.edit_product')
 def product_edit(request, pk):
@@ -242,26 +160,268 @@ def product_edit(request, pk):
 
 
 @staff_member_required
-@permission_required('product.edit_product')
-def product_delete(request, pk):
-    product = get_object_or_404(Product, pk=pk)
-    if request.method == 'POST':
-        product.delete()
-        msg = pgettext_lazy(
-            'Dashboard message', 'Removed product %s') % (product,)
-        messages.success(request, msg)
-        return redirect('dashboard:product-list')
-    return TemplateResponse(
-        request,
-        'dashboard/product/modal/confirm_delete.html',
-        {'product': product})
+@permission_required('product.edit_product')
+def product_delete(request, pk):
+    product = get_object_or_404(Product, pk=pk)
+    if request.method == 'POST':
+        product.delete()
+        msg = pgettext_lazy(
+            'Dashboard message', 'Removed product %s') % (product,)
+        messages.success(request, msg)
+        return redirect('dashboard:product-list')
+    return TemplateResponse(
+        request,
+        'dashboard/product/modal/confirm_delete.html',
+        {'product': product})
+
+
+@require_POST
+@staff_member_required
+@permission_required('product.edit_product')
+def product_bulk_update(request):
+    form = forms.ProductBulkUpdate(request.POST)
+    if form.is_valid():
+        form.save()
+        count = len(form.cleaned_data['products'])
+        msg = npgettext_lazy(
+            'Dashboard message',
+            '%(count)d product has been updated',
+            '%(count)d products have been updated',
+            number=count) % {'count': count}
+        messages.success(request, msg)
+    return redirect('dashboard:product-list')
+
+
+@staff_member_required
+def ajax_products_list(request):
+    """Return products filtered by request GET parameters.
+
+    Response format is that of a Select2 JS widget.
+    """
+    queryset = (
+        Product.objects.all() if request.user.has_perm('product.view_product')
+        else Product.objects.available_products())
+    search_query = request.GET.get('q', '')
+    if search_query:
+        queryset = queryset.filter(Q(name__icontains=search_query))
+    products = [
+        {'id': product.id, 'text': str(product)} for product in queryset]
+    return JsonResponse({'results': products})
+
+
+@staff_member_required
+@permission_required('product.view_properties')
+def product_type_list(request):
+    types = ProductType.objects.all().prefetch_related(
+        'product_attributes', 'variant_attributes').order_by('name')
+    type_filter = ProductTypeFilter(request.GET, queryset=types)
+    types = get_paginator_items(
+        type_filter.qs, settings.DASHBOARD_PAGINATE_BY,
+        request.GET.get('page'))
+    types.object_list = [
+        (pt.pk, pt.name, pt.product_attributes.all(),
+         pt.variant_attributes.all()) for pt in types.object_list]
+    ctx = {
+        'product_types': types, 'filter_set': type_filter,
+        'is_empty': not type_filter.queryset.exists()}
+    return TemplateResponse(
+        request,
+        'dashboard/product/product_type/list.html',
+        ctx)
+
+
+@staff_member_required
+@permission_required('product.edit_properties')
+def product_type_create(request):
+    product_type = ProductType()
+    form = forms.ProductTypeForm(request.POST or None, instance=product_type)
+    if form.is_valid():
+        product_type = form.save()
+        msg = pgettext_lazy(
+            'Dashboard message', 'Added product type %s') % (product_type,)
+        messages.success(request, msg)
+        return redirect('dashboard:product-type-list')
+    ctx = {'form': form, 'product_type': product_type}
+    return TemplateResponse(
+        request,
+        'dashboard/product/product_type/form.html',
+        ctx)
+
+
+@staff_member_required
+@permission_required('product.edit_properties')
+def product_type_edit(request, pk):
+    product_type = get_object_or_404(ProductType, pk=pk)
+    form = forms.ProductTypeForm(
+        request.POST or None, instance=product_type)
+    if form.is_valid():
+        product_type = form.save()
+        msg = pgettext_lazy(
+            'Dashboard message', 'Updated product type %s') % (product_type,)
+        messages.success(request, msg)
+        return redirect('dashboard:product-type-update', pk=pk)
+    ctx = {'form': form, 'product_type': product_type}
+    return TemplateResponse(
+        request,
+        'dashboard/product/product_type/form.html',
+        ctx)
+
+
+@staff_member_required
+@permission_required('product.edit_properties')
+def product_type_delete(request, pk):
+    product_type = get_object_or_404(ProductType, pk=pk)
+    if request.method == 'POST':
+        product_type.delete()
+        msg = pgettext_lazy(
+            'Dashboard message', 'Removed product type %s') % (product_type,)
+        messages.success(request, msg)
+        return redirect('dashboard:product-type-list')
+    ctx = {
+        'product_type': product_type,
+        'products': product_type.products.all()}
+    return TemplateResponse(
+        request,
+        'dashboard/product/product_type/modal/confirm_delete.html',
+        ctx)
+
+
+@staff_member_required
+@permission_required('product.view_product')
+def variant_details(request, product_pk, variant_pk):
+    product = get_object_or_404(Product, pk=product_pk)
+    variant = get_object_or_404(product.variants.all(), pk=variant_pk)
+
+    # If the product type of this product assumes no variants, redirect to
+    # product details page that has special UI for products without variants.
+    if not product.product_type.has_variants:
+        return redirect('dashboard:product-details', pk=product.pk)
+
+    images = variant.images.all()
+    margin = get_margin_for_variant(variant)
+    discounted_price = variant.get_price(discounts=Sale.objects.all()).gross
+    ctx = {
+        'images': images, 'product': product, 'variant': variant,
+        'margin': margin, 'discounted_price': discounted_price}
+    return TemplateResponse(
+        request,
+        'dashboard/product/product_variant/detail.html',
+        ctx)
+
+
+@staff_member_required
+@permission_required('product.edit_product')
+def variant_create(request, product_pk):
+    product = get_object_or_404(Product.objects.all(), pk=product_pk)
+    variant = ProductVariant(product=product)
+    form = forms.ProductVariantForm(request.POST or None, instance=variant)
+    if form.is_valid():
+        form.save()
+        msg = pgettext_lazy(
+            'Dashboard message', 'Saved variant %s') % (variant.name,)
+        messages.success(request, msg)
+        return redirect(
+            'dashboard:variant-details', product_pk=product.pk,
+            variant_pk=variant.pk)
+    ctx = {'form': form, 'product': product, 'variant': variant}
+    return TemplateResponse(
+        request,
+        'dashboard/product/product_variant/form.html',
+        ctx)
+
+
+@staff_member_required
+@permission_required('product.edit_product')
+def variant_edit(request, product_pk, variant_pk):
+    product = get_object_or_404(Product.objects.all(), pk=product_pk)
+    variant = get_object_or_404(product.variants.all(), pk=variant_pk)
+    form = forms.ProductVariantForm(request.POST or None, instance=variant)
+    if form.is_valid():
+        form.save()
+        msg = pgettext_lazy(
+            'Dashboard message', 'Saved variant %s') % (variant.name,)
+        messages.success(request, msg)
+        return redirect(
+            'dashboard:variant-details', product_pk=product.pk,
+            variant_pk=variant.pk)
+    ctx = {'form': form, 'product': product, 'variant': variant}
+    return TemplateResponse(
+        request,
+        'dashboard/product/product_variant/form.html',
+        ctx)
+
+
+@staff_member_required
+@permission_required('product.edit_product')
+def variant_delete(request, product_pk, variant_pk):
+    product = get_object_or_404(Product, pk=product_pk)
+    variant = get_object_or_404(product.variants, pk=variant_pk)
+    if request.method == 'POST':
+        variant.delete()
+        msg = pgettext_lazy(
+            'Dashboard message', 'Removed variant %s') % (variant.name,)
+        messages.success(request, msg)
+        return redirect('dashboard:product-details', pk=product.pk)
+    ctx = {
+        'is_only_variant': product.variants.count() == 1, 'product': product,
+        'variant': variant}
+    return TemplateResponse(
+        request,
+        'dashboard/product/product_variant/modal/confirm_delete.html',
+        ctx)
+
+
+@staff_member_required
+@permission_required('product.view_product')
+def variant_images(request, product_pk, variant_pk):
+    product = get_object_or_404(Product, pk=product_pk)
+    qs = product.variants.prefetch_related('images')
+    variant = get_object_or_404(qs, pk=variant_pk)
+    form = forms.VariantImagesSelectForm(request.POST or None, variant=variant)
+    if form.is_valid():
+        form.save()
+        return redirect(
+            'dashboard:variant-details', product_pk=product.pk,
+            variant_pk=variant.pk)
+    ctx = {'form': form, 'product': product, 'variant': variant}
+    return TemplateResponse(
+        request,
+        'dashboard/product/product_variant/modal/select_images.html',
+        ctx)
+
+
+@staff_member_required
+def ajax_available_variants_list(request):
+    """Return variants filtered by request GET parameters.
+
+    Response format is that of a Select2 JS widget.
+    """
+    available_products = Product.objects.available_products().prefetch_related(
+        'category',
+        'product_type__product_attributes')
+    queryset = ProductVariant.objects.filter(
+        product__in=available_products).prefetch_related(
+            'product__category',
+            'product__product_type__product_attributes')
+
+    search_query = request.GET.get('q', '')
+    if search_query:
+        queryset = queryset.filter(
+            Q(sku__icontains=search_query) |
+            Q(name__icontains=search_query) |
+            Q(product__name__icontains=search_query))
+
+    variants = [
+        {'id': variant.id, 'text': variant.get_ajax_label(request.discounts)}
+        for variant in queryset]
+    return JsonResponse({'results': variants})
 
 
 @staff_member_required
 @permission_required('product.view_product')
 def product_images(request, product_pk):
-    product = get_object_or_404(
-        Product.objects.prefetch_related('images'), pk=product_pk)
+    products = Product.objects.prefetch_related('images')
+    product = get_object_or_404(products, pk=product_pk)
     images = product.images.all()
     ctx = {
         'product': product, 'images': images, 'is_empty': not images.exists()}
@@ -271,7 +431,7 @@ def product_images(request, product_pk):
 
 @staff_member_required
 @permission_required('product.edit_product')
-def product_image_add(request, product_pk):
+def product_image_create(request, product_pk):
     product = get_object_or_404(Product, pk=product_pk)
     product_image = ProductImage(product=product)
     form = forms.ProductImageForm(
@@ -328,108 +488,37 @@ def product_image_delete(request, product_pk, img_pk):
         {'product': product, 'image': image})
 
 
+@require_POST
 @staff_member_required
 @permission_required('product.edit_product')
-def variant_add(request, product_pk):
-    product = get_object_or_404(Product.objects.all(), pk=product_pk)
-    variant = ProductVariant(product=product)
-    form = forms.ProductVariantForm(request.POST or None, instance=variant)
-    if form.is_valid():
-        form.save()
-        msg = pgettext_lazy(
-            'Dashboard message', 'Saved variant %s') % (variant.name,)
-        messages.success(request, msg)
-        return redirect(
-            'dashboard:variant-details', product_pk=product.pk,
-            variant_pk=variant.pk)
-    ctx = {'form': form, 'product': product, 'variant': variant}
-    return TemplateResponse(
-        request,
-        'dashboard/product/product_variant/form.html',
-        ctx)
-
-
-@staff_member_required
-@permission_required('product.edit_product')
-def variant_edit(request, product_pk, variant_pk):
-    product = get_object_or_404(Product.objects.all(), pk=product_pk)
-    variant = get_object_or_404(product.variants.all(), pk=variant_pk)
-    form = forms.ProductVariantForm(request.POST or None, instance=variant)
-    if form.is_valid():
-        form.save()
-        msg = pgettext_lazy(
-            'Dashboard message', 'Saved variant %s') % (variant.name,)
-        messages.success(request, msg)
-        return redirect(
-            'dashboard:variant-details', product_pk=product.pk,
-            variant_pk=variant.pk)
-    ctx = {'form': form, 'product': product, 'variant': variant}
-    return TemplateResponse(
-        request,
-        'dashboard/product/product_variant/form.html',
-        ctx)
-
-
-@staff_member_required
-@permission_required('product.view_product')
-def variant_details(request, product_pk, variant_pk):
-    product = get_object_or_404(Product, pk=product_pk)
-    variant = get_object_or_404(product.variants.all(), pk=variant_pk)
-
-    # If the product type of this product assumes no variants, redirect to
-    # product details page that has special UI for products without variants.
-    if not product.product_type.has_variants:
-        return redirect('dashboard:product-details', pk=product.pk)
-
-    images = variant.images.all()
-    margin = get_margin_for_variant(variant)
-    discounted_price = variant.get_price(discounts=Sale.objects.all()).gross
-    ctx = {
-        'images': images, 'product': product, 'variant': variant,
-        'margin': margin, 'discounted_price': discounted_price}
-    return TemplateResponse(
-        request,
-        'dashboard/product/product_variant/detail.html',
-        ctx)
-
-
-@staff_member_required
-@permission_required('product.view_product')
-def variant_images(request, product_pk, variant_pk):
+def ajax_reorder_product_images(request, product_pk):
     product = get_object_or_404(Product, pk=product_pk)
-    qs = product.variants.prefetch_related('images')
-    variant = get_object_or_404(qs, pk=variant_pk)
-    form = forms.VariantImagesSelectForm(request.POST or None, variant=variant)
+    form = forms.ReorderProductImagesForm(request.POST, instance=product)
+    status = 200
+    ctx = {}
     if form.is_valid():
         form.save()
-        return redirect(
-            'dashboard:variant-details', product_pk=product.pk,
-            variant_pk=variant.pk)
-    ctx = {'form': form, 'product': product, 'variant': variant}
-    return TemplateResponse(
-        request,
-        'dashboard/product/product_variant/modal/select_images.html',
-        ctx)
+    elif form.errors:
+        status = 400
+        ctx = {'error': form.errors}
+    return JsonResponse(ctx, status=status)
 
 
+@require_POST
 @staff_member_required
-@permission_required('product.edit_product')
-def variant_delete(request, product_pk, variant_pk):
+def ajax_upload_image(request, product_pk):
     product = get_object_or_404(Product, pk=product_pk)
-    variant = get_object_or_404(product.variants, pk=variant_pk)
-    if request.method == 'POST':
-        variant.delete()
-        msg = pgettext_lazy(
-            'Dashboard message', 'Removed variant %s') % (variant.name,)
-        messages.success(request, msg)
-        return redirect('dashboard:product-details', pk=product.pk)
-    ctx = {
-        'is_only_variant': product.variants.count() == 1, 'product': product,
-        'variant': variant}
-    return TemplateResponse(
-        request,
-        'dashboard/product/product_variant/modal/confirm_delete.html',
-        ctx)
+    form = forms.UploadImageForm(
+        request.POST or None, request.FILES or None, product=product)
+    ctx = {}
+    status = 200
+    if form.is_valid():
+        image = form.save()
+        ctx = {'id': image.pk, 'image': None, 'order': image.order}
+    elif form.errors:
+        status = 400
+        ctx = {'error': form.errors}
+    return JsonResponse(ctx, status=status)
 
 
 @staff_member_required
@@ -455,16 +544,15 @@ def attribute_list(request):
 def attribute_details(request, pk):
     attributes = ProductAttribute.objects.prefetch_related('values').all()
     attribute = get_object_or_404(attributes, pk=pk)
-    ctx = {
-        'attribute': attribute,
-        'is_empty': not attributes.exists()}
+    values = attribute.values.all()
+    ctx = {'attribute': attribute, 'values': values}
     return TemplateResponse(
         request, 'dashboard/product/product_attribute/detail.html', ctx)
 
 
 @staff_member_required
 @permission_required('product.edit_properties')
-def attribute_add(request):
+def attribute_create(request):
     attribute = ProductAttribute()
     form = forms.ProductAttributeForm(request.POST or None, instance=attribute)
     if form.is_valid():
@@ -515,7 +603,7 @@ def attribute_delete(request, pk):
 
 @staff_member_required
 @permission_required('product.edit_properties')
-def attribute_choice_value_add(request, attribute_pk):
+def attribute_choice_value_create(request, attribute_pk):
     attribute = get_object_or_404(ProductAttribute, pk=attribute_pk)
     value = AttributeChoiceValue(attribute_id=attribute_pk)
     form = forms.AttributeChoiceValueForm(request.POST or None, instance=value)
@@ -566,95 +654,3 @@ def attribute_choice_value_delete(request, attribute_pk, value_pk):
         request,
         'dashboard/product/product_attribute/values/modal/confirm_delete.html',
         {'value': value, 'attribute_pk': attribute_pk})
-
-
-@require_POST
-@staff_member_required
-def ajax_reorder_product_images(request, product_pk):
-    product = get_object_or_404(Product, pk=product_pk)
-    form = forms.ReorderProductImagesForm(request.POST, instance=product)
-    status = 200
-    ctx = {}
-    if form.is_valid():
-        form.save()
-    elif form.errors:
-        status = 400
-        ctx = {'error': form.errors}
-    return JsonResponse(ctx, status=status)
-
-
-@require_POST
-@staff_member_required
-def ajax_upload_image(request, product_pk):
-    product = get_object_or_404(Product, pk=product_pk)
-    form = forms.UploadImageForm(
-        request.POST or None, request.FILES or None, product=product)
-    status = 200
-    if form.is_valid():
-        image = form.save()
-        ctx = {'id': image.pk, 'image': None, 'order': image.order}
-    elif form.errors:
-        status = 400
-        ctx = {'error': form.errors}
-    return JsonResponse(ctx, status=status)
-
-
-@require_POST
-@staff_member_required
-def product_bulk_update(request):
-    form = forms.ProductBulkUpdate(request.POST)
-    if form.is_valid():
-        form.save()
-        count = len(form.cleaned_data['products'])
-        msg = npgettext_lazy(
-            'Dashboard message',
-            '%(count)d product has been updated',
-            '%(count)d products have been updated',
-            number=count) % {'count': count}
-        messages.success(request, msg)
-    return redirect('dashboard:product-list')
-
-
-@staff_member_required
-def ajax_available_variants_list(request):
-    """Return variants filtered by request GET parameters.
-
-    Response format is that of a Select2 JS widget.
-    """
-    available_products = Product.objects.available_products().prefetch_related(
-        'category',
-        'product_type__product_attributes')
-    queryset = ProductVariant.objects.filter(
-        product__in=available_products).prefetch_related(
-            'product__category',
-            'product__product_type__product_attributes')
-
-    search_query = request.GET.get('q', '')
-    if search_query:
-        queryset = queryset.filter(
-            Q(sku__icontains=search_query) |
-            Q(name__icontains=search_query) |
-            Q(product__name__icontains=search_query))
-
-    variants = [
-        {'id': variant.id, 'text': variant.get_ajax_label(request.discounts)}
-        for variant in queryset]
-    return JsonResponse({'results': variants})
-
-
-@staff_member_required
-def ajax_products_list(request):
-    """Return products filtered by request GET parameters.
-
-    Response format is that of a Select2 JS widget.
-    """
-    queryset = (
-        Product.objects.all() if request.user.has_perm('product.view_product')
-        else Product.objects.available_products())
-    search_query = request.GET.get('q', '')
-    if search_query:
-        queryset = queryset.filter(Q(name__icontains=search_query))
-    products = [
-        {'id': product.id, 'text': str(product)} for product in queryset
-    ]
-    return JsonResponse({'results': products})


Test patch:
diff --git a/tests/dashboard/test_category.py b/tests/dashboard/test_category.py
--- a/tests/dashboard/test_category.py
+++ b/tests/dashboard/test_category.py
@@ -10,31 +10,31 @@ def test_category_list(admin_client, default_category):
 
 
 def test_category_add(admin_client):
-    assert len(Category.objects.all()) == 0
+    assert Category.objects.count() == 0
     url = reverse('dashboard:category-add')
     data = {'name': 'Cars', 'description': 'Fastest cars'}
     response = admin_client.post(url, data, follow=True)
     assert response.status_code == 200
-    assert len(Category.objects.all()) == 1
+    assert Category.objects.count() == 1
 
 
 def test_category_add_not_valid(admin_client):
-    assert len(Category.objects.all()) == 0
+    assert Category.objects.count() == 0
     url = reverse('dashboard:category-add')
     data = {}
     response = admin_client.post(url, data, follow=True)
     assert response.status_code == 200
-    assert len(Category.objects.all()) == 0
+    assert Category.objects.count() == 0
 
 
 def test_category_add_subcategory(admin_client, default_category):
-    assert len(Category.objects.all()) == 1
+    assert Category.objects.count() == 1
     url = reverse('dashboard:category-add',
                   kwargs={'root_pk': default_category.pk})
     data = {'name': 'Cars', 'description': 'Fastest cars'}
     response = admin_client.post(url, data, follow=True)
     assert response.status_code == 200
-    assert len(Category.objects.all()) == 2
+    assert Category.objects.count() == 2
     default_category.refresh_from_db()
     subcategories = default_category.get_children()
     assert len(subcategories) == 1
@@ -42,18 +42,18 @@ def test_category_add_subcategory(admin_client, default_category):
 
 
 def test_category_edit(admin_client, default_category):
-    assert len(Category.objects.all()) == 1
+    assert Category.objects.count() == 1
     url = reverse('dashboard:category-edit',
                   kwargs={'root_pk': default_category.pk})
     data = {'name': 'Cars', 'description': 'Super fast!'}
     response = admin_client.post(url, data, follow=True)
     assert response.status_code == 200
-    assert len(Category.objects.all()) == 1
+    assert Category.objects.count() == 1
     assert Category.objects.all()[0].name == 'Cars'
 
 
 def test_category_details(admin_client, default_category):
-    assert len(Category.objects.all()) == 1
+    assert Category.objects.count() == 1
     url = reverse('dashboard:category-details',
                   kwargs={'pk': default_category.pk})
     response = admin_client.post(url, follow=True)
@@ -61,9 +61,9 @@ def test_category_details(admin_client, default_category):
 
 
 def test_category_delete(admin_client, default_category):
-    assert len(Category.objects.all()) == 1
+    assert Category.objects.count() == 1
     url = reverse('dashboard:category-delete',
                   kwargs={'pk': default_category.pk})
     response = admin_client.post(url, follow=True)
     assert response.status_code == 200
-    assert len(Category.objects.all()) == 0
+    assert Category.objects.count() == 0
diff --git a/tests/dashboard/test_discounts.py b/tests/dashboard/test_discounts.py
--- a/tests/dashboard/test_discounts.py
+++ b/tests/dashboard/test_discounts.py
@@ -25,7 +25,7 @@ def test_vouchers_list(admin_client, voucher):
 
 
 def test_voucher_shipping_add(admin_client):
-    assert len(Voucher.objects.all()) == 0
+    assert Voucher.objects.count() == 0
     url = reverse('dashboard:voucher-add')
     data = {
         'code': 'TESTVOUCHER', 'name': 'Test Voucher',
@@ -35,7 +35,7 @@ def test_voucher_shipping_add(admin_client):
         'shipping-limit': '59.99'}
     response = admin_client.post(url, data, follow=True)
     assert response.status_code == 200
-    assert len(Voucher.objects.all()) == 1
+    assert Voucher.objects.count() == 1
 
     voucher = Voucher.objects.all()[0]
     assert voucher.type == VoucherType.SHIPPING
diff --git a/tests/dashboard/test_permissions.py b/tests/dashboard/test_permissions.py
--- a/tests/dashboard/test_permissions.py
+++ b/tests/dashboard/test_permissions.py
@@ -173,25 +173,6 @@ def test_staff_cant_view_products_attribute_delete(
     assert response.status_code == 302
 
 
-def test_admin_can_view_product_image_update(admin_client, product_with_image):
-    product_image = product_with_image.images.all()[0]
-    url = reverse('dashboard:product-image-update',
-                  kwargs={'img_pk': product_image.pk,
-                          'product_pk': product_with_image.pk})
-    response = admin_client.get(url)
-    assert response.status_code == 200
-
-
-def test_staff_cant_view_product_image_update(
-        staff_client, product_with_image):
-    product_image = product_with_image.images.all()[0]
-    url = reverse('dashboard:product-image-update',
-                  kwargs={'img_pk': product_image.pk,
-                          'product_pk': product_with_image.pk})
-    response = staff_client.get(url)
-    assert response.status_code == 302
-
-
 def test_admin_can_view_shipping_methods_list(admin_client):
     response = admin_client.get(reverse('dashboard:shipping-methods'))
     assert response.status_code == 200
@@ -273,94 +254,152 @@ def test_staff_cant_view_customer_create(staff_client):
 
 
 def test_staff_cant_access_product_list(staff_client, staff_user):
-    assert not staff_user.has_perm("product.view_product")
+    assert not staff_user.has_perm('product.view_product')
     response = staff_client.get(reverse('dashboard:product-list'))
     assert response.status_code == 302
 
 
 def test_staff_can_access_product_list(
         staff_client, staff_user, permission_view_product):
-    assert not staff_user.has_perm("product.view_product")
+    assert not staff_user.has_perm('product.view_product')
     staff_user.user_permissions.add(permission_view_product)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("product.view_product")
+    assert staff_user.has_perm('product.view_product')
     response = staff_client.get(reverse('dashboard:product-list'))
     assert response.status_code == 200
 
 
-def test_staff_cant_access_product_details(
-        staff_client, staff_user, product):
-    assert not staff_user.has_perm("product.view_product")
-    response = staff_client.get(reverse('dashboard:product-details',
-                                        args=[product.pk]))
-    assert response.status_code == 302
+def test_staff_can_access_product_details(
+        staff_client, staff_user, permission_view_product, product):
+    assert not staff_user.has_perm('product.view_product')
+    url = reverse('dashboard:product-details', kwargs={'pk': product.pk})
 
+    response = staff_client.get(url)
+    assert response.status_code == 302
 
-def test_staff_can_access_product_details(
-        staff_client, staff_user, product, permission_view_product):
-    assert not staff_user.has_perm("product.view_product")
     staff_user.user_permissions.add(permission_view_product)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("product.view_product")
-    response = staff_client.get(reverse('dashboard:product-details',
-                                        args=[product.pk]))
+    assert staff_user.has_perm('product.view_product')
+
+    response = staff_client.post(url)
+    assert response.status_code == 200
+
+
+def test_staff_can_access_product_toggle_is_published(
+        staff_client, staff_user, permission_edit_product, product):
+    assert not staff_user.has_perm('product.edit_product')
+    url = reverse('dashboard:product-publish', kwargs={'pk': product.pk})
+
+    response = staff_client.post(url)
+    assert response.status_code == 302
+
+    staff_user.user_permissions.add(permission_edit_product)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert staff_user.has_perm('product.edit_product')
+
+    response = staff_client.post(url)
+    assert response.status_code == 200
+
+
+def test_staff_can_access_product_select_type(
+        staff_client, staff_user, permission_edit_product):
+    assert not staff_user.has_perm('product.edit_product')
+    url = reverse('dashboard:product-add-select-type')
+
+    response = staff_client.get(url)
+    assert response.status_code == 302
+
+    staff_user.user_permissions.add(permission_edit_product)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert staff_user.has_perm('product.edit_product')
+
+    response = staff_client.post(url)
     assert response.status_code == 200
 
 
-def test_staff_cant_access_product_update(
-        staff_client, staff_user, product):
-    assert not staff_user.has_perm("product.edit_product")
-    response = staff_client.get(reverse('dashboard:product-update',
-                                        args=[product.pk]))
+def test_staff_can_access_product_create(
+        staff_client, staff_user, permission_edit_product, product_type):
+    assert not staff_user.has_perm('product.edit_product')
+    url = reverse('dashboard:product-add', kwargs={'type_pk': product_type.pk})
+
+    response = staff_client.get(url)
     assert response.status_code == 302
 
+    staff_user.user_permissions.add(permission_edit_product)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert staff_user.has_perm('product.edit_product')
+
+    response = staff_client.post(url)
+    assert response.status_code == 200
+
+
+def test_staff_can_access_product_edit(
+        staff_client, staff_user, permission_edit_product, product):
+    assert not staff_user.has_perm('product.edit_product')
+    url = reverse('dashboard:product-update', kwargs={'pk': product.pk})
+
+    response = staff_client.get(url)
+    assert response.status_code == 302
 
-def test_staff_can_access_product_update(
-        staff_client, staff_user, product, permission_edit_product):
-    assert not staff_user.has_perm("product.edit_product")
     staff_user.user_permissions.add(permission_edit_product)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("product.edit_product")
-    response = staff_client.get(reverse('dashboard:product-update',
-                                        args=[product.pk]))
+    assert staff_user.has_perm('product.edit_product')
+
+    response = staff_client.post(url)
+    assert response.status_code == 200
+
+
+def test_staff_can_access_product_delete(
+        staff_client, staff_user, permission_edit_product, product):
+    assert not staff_user.has_perm('product.edit_product')
+    url = reverse('dashboard:product-delete', kwargs={'pk': product.pk})
+
+    response = staff_client.get(url)
+    assert response.status_code == 302
+
+    staff_user.user_permissions.add(permission_edit_product)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert staff_user.has_perm('product.edit_product')
+
+    response = staff_client.get(url)
     assert response.status_code == 200
 
 
 def test_staff_group_member_can_view_product_list(
         staff_client, staff_user, staff_group, permission_view_product):
-    assert not staff_user.has_perm("product.view_product")
+    assert not staff_user.has_perm('product.view_product')
     response = staff_client.get(reverse('dashboard:product-list'))
     assert response.status_code == 302
     staff_group.permissions.add(permission_view_product)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("product.view_product")
+    assert staff_user.has_perm('product.view_product')
     response = staff_client.get(reverse('dashboard:product-list'))
     assert response.status_code == 200
 
 
 def test_staff_group_member_can_view_category_list(
         staff_client, staff_user, staff_group, permission_view_category):
-    assert not staff_user.has_perm("product.view_category")
+    assert not staff_user.has_perm('product.view_category')
     response = staff_client.get(reverse('dashboard:category-list'))
     assert response.status_code == 302
     staff_group.permissions.add(permission_view_category)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("product.view_category")
+    assert staff_user.has_perm('product.view_category')
     response = staff_client.get(reverse('dashboard:category-list'))
     assert response.status_code == 200
 
 
 def test_staff_group_member_can_view_category_add_root(
         staff_client, staff_user, staff_group, permission_edit_category):
-    assert not staff_user.has_perm("product.edit_category")
+    assert not staff_user.has_perm('product.edit_category')
     response = staff_client.get(reverse('dashboard:category-add'))
     assert response.status_code == 302
     staff_group.permissions.add(permission_edit_category)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("product.edit_category")
+    assert staff_user.has_perm('product.edit_category')
     response = staff_client.get(reverse('dashboard:category-add'))
     assert response.status_code == 200
 
@@ -368,14 +407,14 @@ def test_staff_group_member_can_view_category_add_root(
 def test_staff_group_member_can_view_category_add_subcategory(
         staff_client, staff_user, staff_group, permission_edit_category,
         default_category):
-    assert not staff_user.has_perm("product.edit_category")
+    assert not staff_user.has_perm('product.edit_category')
     response = staff_client.get(reverse('dashboard:category-add',
                                         args=[default_category.pk]))
     assert response.status_code == 302
     staff_group.permissions.add(permission_edit_category)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("product.edit_category")
+    assert staff_user.has_perm('product.edit_category')
     response = staff_client.get(reverse('dashboard:category-add',
                                         args=[default_category.pk]))
     assert response.status_code == 200
@@ -384,14 +423,14 @@ def test_staff_group_member_can_view_category_add_subcategory(
 def test_staff_group_member_can_view_category_edit(
         staff_client, staff_user, staff_group, permission_edit_category,
         default_category):
-    assert not staff_user.has_perm("product.edit_category")
+    assert not staff_user.has_perm('product.edit_category')
     response = staff_client.get(reverse('dashboard:category-edit',
                                         args=[default_category.pk]))
     assert response.status_code == 302
     staff_group.permissions.add(permission_edit_category)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("product.edit_category")
+    assert staff_user.has_perm('product.edit_category')
     response = staff_client.get(reverse('dashboard:category-edit',
                                         args=[default_category.pk]))
     assert response.status_code == 200
@@ -400,14 +439,14 @@ def test_staff_group_member_can_view_category_edit(
 def test_staff_group_member_can_view_category_delete(
         staff_client, staff_user, staff_group, permission_edit_category,
         default_category):
-    assert not staff_user.has_perm("product.edit_category")
+    assert not staff_user.has_perm('product.edit_category')
     response = staff_client.get(reverse('dashboard:category-delete',
                                         args=[default_category.pk]))
     assert response.status_code == 302
     staff_group.permissions.add(permission_edit_category)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("product.edit_category")
+    assert staff_user.has_perm('product.edit_category')
     response = staff_client.get(reverse('dashboard:category-delete',
                                         args=[default_category.pk]))
     assert response.status_code == 200
@@ -415,27 +454,27 @@ def test_staff_group_member_can_view_category_delete(
 
 def test_staff_group_member_can_view_sale_list(
         staff_client, staff_user, staff_group, permission_view_sale):
-    assert not staff_user.has_perm("discount.view_sale")
+    assert not staff_user.has_perm('discount.view_sale')
     response = staff_client.get(reverse('dashboard:sale-list'))
     assert response.status_code == 302
     staff_group.permissions.add(permission_view_sale)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("discount.view_sale")
+    assert staff_user.has_perm('discount.view_sale')
     response = staff_client.get(reverse('dashboard:sale-list'))
     assert response.status_code == 200
 
 
 def test_staff_group_member_can_view_sale_update(
         staff_client, staff_user, staff_group, permission_edit_sale, sale):
-    assert not staff_user.has_perm("discount.edit_sale")
+    assert not staff_user.has_perm('discount.edit_sale')
     response = staff_client.get(reverse('dashboard:sale-update',
                                         args=[sale.pk]))
     assert response.status_code == 302
     staff_group.permissions.add(permission_edit_sale)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("discount.edit_sale")
+    assert staff_user.has_perm('discount.edit_sale')
     response = staff_client.get(reverse('dashboard:sale-update',
                                         args=[sale.pk]))
     assert response.status_code == 200
@@ -443,27 +482,27 @@ def test_staff_group_member_can_view_sale_update(
 
 def test_staff_group_member_can_view_sale_add(
         staff_client, staff_user, staff_group, permission_edit_sale, sale):
-    assert not staff_user.has_perm("discount.edit_sale")
+    assert not staff_user.has_perm('discount.edit_sale')
     response = staff_client.get(reverse('dashboard:sale-add'))
     assert response.status_code == 302
     staff_group.permissions.add(permission_edit_sale)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("discount.edit_sale")
+    assert staff_user.has_perm('discount.edit_sale')
     response = staff_client.get(reverse('dashboard:sale-add'))
     assert response.status_code == 200
 
 
 def test_staff_group_member_can_view_sale_delete(
         staff_client, staff_user, staff_group, permission_edit_sale, sale):
-    assert not staff_user.has_perm("discount.edit_sale")
+    assert not staff_user.has_perm('discount.edit_sale')
     response = staff_client.get(reverse('dashboard:sale-delete',
                                         args=[sale.pk]))
     assert response.status_code == 302
     staff_group.permissions.add(permission_edit_sale)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("discount.edit_sale")
+    assert staff_user.has_perm('discount.edit_sale')
     response = staff_client.get(reverse('dashboard:sale-delete',
                                         args=[sale.pk]))
     assert response.status_code == 200
@@ -471,13 +510,13 @@ def test_staff_group_member_can_view_sale_delete(
 
 def test_staff_group_member_can_view_voucher_list(
         staff_client, staff_user, staff_group, permission_view_voucher):
-    assert not staff_user.has_perm("discount.view_voucher")
+    assert not staff_user.has_perm('discount.view_voucher')
     response = staff_client.get(reverse('dashboard:voucher-list'))
     assert response.status_code == 302
     staff_group.permissions.add(permission_view_voucher)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("discount.view_voucher")
+    assert staff_user.has_perm('discount.view_voucher')
     response = staff_client.get(reverse('dashboard:voucher-list'))
     assert response.status_code == 200
 
@@ -485,14 +524,14 @@ def test_staff_group_member_can_view_voucher_list(
 def test_staff_group_member_can_view_voucher_update(
         staff_client, staff_user, staff_group, permission_edit_voucher,
         voucher):
-    assert not staff_user.has_perm("discount.edit_voucher")
+    assert not staff_user.has_perm('discount.edit_voucher')
     response = staff_client.get(reverse('dashboard:voucher-update',
                                         args=[voucher.pk]))
     assert response.status_code == 302
     staff_group.permissions.add(permission_edit_voucher)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("discount.edit_voucher")
+    assert staff_user.has_perm('discount.edit_voucher')
     response = staff_client.get(reverse('dashboard:voucher-update',
                                         args=[voucher.pk]))
     assert response.status_code == 200
@@ -500,13 +539,13 @@ def test_staff_group_member_can_view_voucher_update(
 
 def test_staff_group_member_can_view_voucher_add(
         staff_client, staff_user, staff_group, permission_edit_voucher):
-    assert not staff_user.has_perm("discount.edit_voucher")
+    assert not staff_user.has_perm('discount.edit_voucher')
     response = staff_client.get(reverse('dashboard:voucher-add'))
     assert response.status_code == 302
     staff_group.permissions.add(permission_edit_voucher)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("discount.edit_voucher")
+    assert staff_user.has_perm('discount.edit_voucher')
     response = staff_client.get(reverse('dashboard:voucher-add'))
     assert response.status_code == 200
 
@@ -514,14 +553,14 @@ def test_staff_group_member_can_view_voucher_add(
 def test_staff_group_member_can_view_voucher_delete(
         staff_client, staff_user, staff_group, permission_edit_voucher,
         voucher):
-    assert not staff_user.has_perm("discount.edit_voucher")
+    assert not staff_user.has_perm('discount.edit_voucher')
     response = staff_client.get(reverse('dashboard:voucher-delete',
                                         args=[voucher.pk]))
     assert response.status_code == 302
     staff_group.permissions.add(permission_edit_voucher)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("discount.edit_voucher")
+    assert staff_user.has_perm('discount.edit_voucher')
     response = staff_client.get(reverse('dashboard:voucher-delete',
                                         args=[voucher.pk]))
     assert response.status_code == 200
@@ -529,13 +568,13 @@ def test_staff_group_member_can_view_voucher_delete(
 
 def test_staff_group_member_can_view_order_list(
         staff_client, staff_user, staff_group, permission_view_order):
-    assert not staff_user.has_perm("order.view_order")
+    assert not staff_user.has_perm('order.view_order')
     response = staff_client.get(reverse('dashboard:orders'))
     assert response.status_code == 302
     staff_group.permissions.add(permission_view_order)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("order.view_order")
+    assert staff_user.has_perm('order.view_order')
     response = staff_client.get(reverse('dashboard:orders'))
     assert response.status_code == 200
 
@@ -543,14 +582,14 @@ def test_staff_group_member_can_view_order_list(
 def test_staff_group_member_can_view_order_details(
         staff_client, staff_user, staff_group, permission_view_order,
         order_with_lines):
-    assert not staff_user.has_perm("order.view_order")
+    assert not staff_user.has_perm('order.view_order')
     response = staff_client.get(reverse('dashboard:order-details',
                                         args=[order_with_lines.pk]))
     assert response.status_code == 302
     staff_group.permissions.add(permission_view_order)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("order.view_order")
+    assert staff_user.has_perm('order.view_order')
     response = staff_client.get(reverse('dashboard:order-details',
                                         args=[order_with_lines.pk]))
     assert response.status_code == 200
@@ -558,14 +597,14 @@ def test_staff_group_member_can_view_order_details(
 
 def test_staff_group_member_can_view_order_add_note(
         staff_client, staff_user, staff_group, permission_edit_order, order):
-    assert not staff_user.has_perm("order.edit_order")
+    assert not staff_user.has_perm('order.edit_order')
     response = staff_client.get(reverse('dashboard:order-add-note',
                                         args=[order.pk]))
     assert response.status_code == 302
     staff_group.permissions.add(permission_edit_order)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("order.edit_order")
+    assert staff_user.has_perm('order.edit_order')
     response = staff_client.get(reverse('dashboard:order-add-note',
                                         args=[order.pk]))
     assert response.status_code == 200
@@ -590,14 +629,14 @@ def test_staff_group_member_can_view_cancel_order(
 def test_staff_group_member_can_view_billing_address_edit(
         staff_client, staff_user, staff_group, permission_edit_order,
         order):
-    assert not staff_user.has_perm("order.edit_order")
+    assert not staff_user.has_perm('order.edit_order')
     response = staff_client.get(reverse('dashboard:address-edit',
                                         args=[order.pk, 'billing']))
     assert response.status_code == 302
     staff_group.permissions.add(permission_edit_order)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("order.edit_order")
+    assert staff_user.has_perm('order.edit_order')
     response = staff_client.get(reverse('dashboard:address-edit',
                                         args=[order.pk, 'billing']))
     assert response.status_code == 200
@@ -605,13 +644,13 @@ def test_staff_group_member_can_view_billing_address_edit(
 
 def test_staff_group_member_can_view_customers_list(
         staff_client, staff_user, staff_group, permission_view_user):
-    assert not staff_user.has_perm("account.view_user")
+    assert not staff_user.has_perm('account.view_user')
     response = staff_client.get(reverse('dashboard:customers'))
     assert response.status_code == 302
     staff_group.permissions.add(permission_view_user)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("account.view_user")
+    assert staff_user.has_perm('account.view_user')
     response = staff_client.get(reverse('dashboard:customers'))
     assert response.status_code == 200
 
@@ -619,14 +658,14 @@ def test_staff_group_member_can_view_customers_list(
 def test_staff_group_member_can_view_customer_details(
         staff_client, staff_user, staff_group, permission_view_user,
         customer_user, order_with_lines):
-    assert not staff_user.has_perm("account.view_user")
+    assert not staff_user.has_perm('account.view_user')
     response = staff_client.get(reverse('dashboard:customer-details',
                                         args=[customer_user.pk]))
     assert response.status_code == 302
     staff_group.permissions.add(permission_view_user)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("account.view_user")
+    assert staff_user.has_perm('account.view_user')
     response = staff_client.get(reverse('dashboard:customer-details',
                                         args=[customer_user.pk]))
     assert response.status_code == 200
@@ -637,20 +676,20 @@ def test_staff_group_member_can_view_customer_details(
 
 def test_staff_group_member_can_view_staff_members_list(
         staff_client, staff_user, staff_group, permission_view_staff):
-    assert not staff_user.has_perm("account.view_staff")
+    assert not staff_user.has_perm('account.view_staff')
     response = staff_client.get(reverse('dashboard:staff-list'))
     assert response.status_code == 302
     staff_group.permissions.add(permission_view_staff)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("account.view_staff")
+    assert staff_user.has_perm('account.view_staff')
     response = staff_client.get(reverse('dashboard:staff-list'))
     assert response.status_code == 200
 
 
 def test_staff_group_member_can_view_detail_create_and_delete_staff_members(
         staff_client, staff_user, staff_group, permission_edit_staff):
-    assert not staff_user.has_perm("account.edit_staff")
+    assert not staff_user.has_perm('account.edit_staff')
     response = staff_client.get(reverse('dashboard:staff-create'))
     assert response.status_code == 302
     response = staff_client.get(reverse('dashboard:staff-delete',
@@ -662,7 +701,7 @@ def test_staff_group_member_can_view_detail_create_and_delete_staff_members(
     staff_group.permissions.add(permission_edit_staff)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("account.edit_staff")
+    assert staff_user.has_perm('account.edit_staff')
     response = staff_client.get(reverse('dashboard:staff-create'))
     assert response.status_code == 200
     response = staff_client.get(reverse('dashboard:staff-delete',
@@ -675,20 +714,20 @@ def test_staff_group_member_can_view_detail_create_and_delete_staff_members(
 
 def test_staff_group_member_can_view_group_list_and_details(
         staff_client, staff_user, staff_group, permission_view_group):
-    assert not staff_user.has_perm("account.view_group")
+    assert not staff_user.has_perm('account.view_group')
     response = staff_client.get(reverse('dashboard:group-list'))
     assert response.status_code == 302
     staff_group.permissions.add(permission_view_group)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("account.view_group")
+    assert staff_user.has_perm('account.view_group')
     response = staff_client.get(reverse('dashboard:group-list'))
     assert response.status_code == 200
 
 
 def test_staff_with_permission_can_create_and_delete_group(
         staff_client, staff_user, staff_group, permission_edit_group):
-    assert not staff_user.has_perm("account.edit_group")
+    assert not staff_user.has_perm('account.edit_group')
     response = staff_client.get(reverse('dashboard:group-delete',
                                         args=[staff_group.pk]))
     assert response.status_code == 302
@@ -700,7 +739,7 @@ def test_staff_with_permission_can_create_and_delete_group(
     staff_group.permissions.add(permission_edit_group)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("account.edit_group")
+    assert staff_user.has_perm('account.edit_group')
     response = staff_client.get(reverse('dashboard:group-details',
                                         args=[staff_group.pk]))
     assert response.status_code == 200
@@ -711,15 +750,15 @@ def test_staff_with_permission_can_create_and_delete_group(
     assert response.status_code == 200
 
 
-def test_staff_with_permissions_can_view_product_typeses_list(
+def test_staff_with_permissions_can_view_product_types_list(
         staff_client, staff_user, staff_group, permission_view_properties):
-    assert not staff_user.has_perm("product.view_properties")
+    assert not staff_user.has_perm('product.view_properties')
     response = staff_client.get(reverse('dashboard:product-type-list'))
     assert response.status_code == 302
     staff_group.permissions.add(permission_view_properties)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("product.view_properties")
+    assert staff_user.has_perm('product.view_properties')
     response = staff_client.get(reverse('dashboard:product-type-list'))
     assert response.status_code == 200
 
@@ -727,7 +766,7 @@ def test_staff_with_permissions_can_view_product_typeses_list(
 def test_staff_with_permissions_can_edit_add_and_delete_product_types_list(
         staff_client, staff_user, staff_group, permission_edit_properties,
         product_type):
-    assert not staff_user.has_perm("product.edit_properties")
+    assert not staff_user.has_perm('product.edit_properties')
     response = staff_client.get(reverse('dashboard:product-type-update',
                                         args=[product_type.pk]))
     assert response.status_code == 302
@@ -739,7 +778,7 @@ def test_staff_with_permissions_can_edit_add_and_delete_product_types_list(
     staff_group.permissions.add(permission_edit_properties)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("product.edit_properties")
+    assert staff_user.has_perm('product.edit_properties')
     response = staff_client.get(reverse('dashboard:product-type-update',
                                         args=[product_type.pk]))
     assert response.status_code == 200
@@ -750,10 +789,182 @@ def test_staff_with_permissions_can_edit_add_and_delete_product_types_list(
     assert response.status_code == 200
 
 
+def test_staff_can_access_variant_details(
+        staff_client, staff_user, permission_view_product, product):
+    product_type = product.product_type
+    product_type.has_variants = True
+    product_type.save()
+
+    variant = product.variants.get()
+    assert not staff_user.has_perm('product.view_product')
+    url = reverse(
+        'dashboard:variant-details',
+        kwargs={'product_pk': product.pk, 'variant_pk': variant.pk})
+
+    response = staff_client.get(url)
+    assert response.status_code == 302
+
+    staff_user.user_permissions.add(permission_view_product)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert staff_user.has_perm('product.view_product')
+
+    response = staff_client.get(url)
+    assert response.status_code == 200
+
+
+def test_staff_can_access_variant_create(
+        staff_client, staff_user, permission_edit_product, product):
+    assert not staff_user.has_perm('product.edit_product')
+    url = reverse('dashboard:variant-add', kwargs={'product_pk': product.pk})
+
+    response = staff_client.get(url)
+    assert response.status_code == 302
+
+    staff_user.user_permissions.add(permission_edit_product)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert staff_user.has_perm('product.edit_product')
+
+    response = staff_client.get(url)
+    assert response.status_code == 200
+
+
+def test_staff_can_access_variant_edit(
+        staff_client, staff_user, permission_edit_product, product):
+    variant = product.variants.get()
+    assert not staff_user.has_perm('product.edit_product')
+    url = reverse(
+        'dashboard:variant-update',
+        kwargs={'product_pk': product.pk, 'variant_pk': variant.pk})
+
+    response = staff_client.get(url)
+    assert response.status_code == 302
+
+    staff_user.user_permissions.add(permission_edit_product)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert staff_user.has_perm('product.edit_product')
+
+    response = staff_client.get(url)
+    assert response.status_code == 200
+
+
+def test_staff_can_access_variant_delete(
+        staff_client, staff_user, permission_edit_product, product):
+    variant = product.variants.get()
+    assert not staff_user.has_perm('product.edit_product')
+    url = reverse(
+        'dashboard:variant-delete',
+        kwargs={'product_pk': product.pk, 'variant_pk': variant.pk})
+
+    response = staff_client.get(url)
+    assert response.status_code == 302
+
+    staff_user.user_permissions.add(permission_edit_product)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert staff_user.has_perm('product.edit_product')
+
+    response = staff_client.get(url)
+    assert response.status_code == 200
+
+
+def test_staff_can_access_variant_images(
+        staff_client, staff_user, permission_view_product, product):
+    variant = product.variants.get()
+    assert not staff_user.has_perm('product.view_product')
+    url = reverse(
+        'dashboard:variant-images',
+        kwargs={'product_pk': product.pk, 'variant_pk': variant.pk})
+
+    response = staff_client.get(url)
+    assert response.status_code == 302
+
+    staff_user.user_permissions.add(permission_view_product)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert staff_user.has_perm('product.view_product')
+
+    response = staff_client.get(url)
+    assert response.status_code == 200
+
+
+def test_staff_can_access_product_image_list(
+        staff_client, staff_user, permission_view_product, product):
+    assert not staff_user.has_perm('product.view_product')
+    url = reverse(
+        'dashboard:product-image-list', kwargs={'product_pk': product.pk})
+
+    response = staff_client.get(url)
+    assert response.status_code == 302
+
+    staff_user.user_permissions.add(permission_view_product)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert staff_user.has_perm('product.view_product')
+
+    response = staff_client.get(url)
+    assert response.status_code == 200
+
+
+def test_staff_can_access_product_image_add(
+        staff_client, staff_user, permission_edit_product, product):
+    assert not staff_user.has_perm('product.edit_product')
+    url = reverse(
+        'dashboard:product-image-add', kwargs={'product_pk': product.pk})
+
+    response = staff_client.get(url)
+    assert response.status_code == 302
+
+    staff_user.user_permissions.add(permission_edit_product)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert staff_user.has_perm('product.edit_product')
+
+    response = staff_client.get(url)
+    assert response.status_code == 200
+
+
+def test_staff_can_access_product_image_update(
+        staff_client, staff_user, permission_edit_product, product_with_image):
+    product_image = product_with_image.images.get()
+    assert not staff_user.has_perm('product.edit_product')
+    url = reverse(
+        'dashboard:product-image-update',
+        kwargs={
+            'product_pk': product_with_image.pk,
+            'img_pk': product_image.pk})
+
+    response = staff_client.get(url)
+    assert response.status_code == 302
+
+    staff_user.user_permissions.add(permission_edit_product)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert staff_user.has_perm('product.edit_product')
+
+    response = staff_client.get(url)
+    assert response.status_code == 200
+
+
+def test_staff_can_access_product_image_delete(
+        staff_client, staff_user, permission_edit_product, product_with_image):
+    product_image = product_with_image.images.get()
+    assert not staff_user.has_perm('product.edit_product')
+    url = reverse(
+        'dashboard:product-image-delete',
+        kwargs={
+            'product_pk': product_with_image.pk,
+            'img_pk': product_image.pk})
+
+    response = staff_client.get(url)
+    assert response.status_code == 302
+
+    staff_user.user_permissions.add(permission_edit_product)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert staff_user.has_perm('product.edit_product')
+
+    response = staff_client.get(url)
+    assert response.status_code == 200
+
+
 def test_staff_with_permissions_can_view_products_attributes_list(
         staff_client, staff_user, staff_group, permission_view_properties,
         color_attribute):
-    assert not staff_user.has_perm("product.view_properties")
+    assert not staff_user.has_perm('product.view_properties')
     response = staff_client.get(reverse('dashboard:product-attributes'))
     assert response.status_code == 302
     response = staff_client.get(reverse('dashboard:product-attribute-details',
@@ -762,7 +973,7 @@ def test_staff_with_permissions_can_view_products_attributes_list(
     staff_group.permissions.add(permission_view_properties)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("product.view_properties")
+    assert staff_user.has_perm('product.view_properties')
     response = staff_client.get(reverse('dashboard:product-attributes'))
     assert response.status_code == 200
     response = staff_client.get(reverse('dashboard:product-attribute-details',
@@ -773,7 +984,7 @@ def test_staff_with_permissions_can_view_products_attributes_list(
 def test_staff_with_permissions_can_update_add_and_delete_products_attributes(
         staff_client, staff_user, staff_group, permission_edit_properties,
         color_attribute):
-    assert not staff_user.has_perm("product.edit_properties")
+    assert not staff_user.has_perm('product.edit_properties')
     response = staff_client.get(reverse('dashboard:product-attribute-update',
                                         args=[color_attribute.pk]))
     assert response.status_code == 302
@@ -785,7 +996,7 @@ def test_staff_with_permissions_can_update_add_and_delete_products_attributes(
     staff_group.permissions.add(permission_edit_properties)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("product.edit_properties")
+    assert staff_user.has_perm('product.edit_properties')
     response = staff_client.get(reverse('dashboard:product-attribute-update',
                                         args=[color_attribute.pk]))
     assert response.status_code == 200
@@ -796,10 +1007,70 @@ def test_staff_with_permissions_can_update_add_and_delete_products_attributes(
     assert response.status_code == 200
 
 
+def test_staff_can_access_attribute_create(
+        staff_client, staff_user, permission_edit_properties, color_attribute):
+    assert not staff_user.has_perm('product.edit_properties')
+    url = reverse(
+        'dashboard:product-attribute-value-add',
+        kwargs={'attribute_pk': color_attribute.pk})
+
+    response = staff_client.get(url)
+    assert response.status_code == 302
+
+    staff_user.user_permissions.add(permission_edit_properties)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert staff_user.has_perm('product.edit_properties')
+
+    response = staff_client.get(url)
+    assert response.status_code == 200
+
+
+def test_staff_can_access_attribute_edit(
+        staff_client, staff_user, permission_edit_properties, color_attribute):
+    value = color_attribute.values.first()
+    assert not staff_user.has_perm('product.edit_properties')
+    url = reverse(
+        'dashboard:product-attribute-value-update',
+        kwargs={
+            'attribute_pk': color_attribute.pk,
+            'value_pk': value.pk})
+
+    response = staff_client.get(url)
+    assert response.status_code == 302
+
+    staff_user.user_permissions.add(permission_edit_properties)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert staff_user.has_perm('product.edit_properties')
+
+    response = staff_client.get(url)
+    assert response.status_code == 200
+
+
+def test_staff_can_access_attribute_delete(
+        staff_client, staff_user, permission_edit_properties, color_attribute):
+    value = color_attribute.values.first()
+    assert not staff_user.has_perm('product.edit_properties')
+    url = reverse(
+        'dashboard:product-attribute-value-delete',
+        kwargs={
+            'attribute_pk': color_attribute.pk,
+            'value_pk': value.pk})
+
+    response = staff_client.get(url)
+    assert response.status_code == 302
+
+    staff_user.user_permissions.add(permission_edit_properties)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert staff_user.has_perm('product.edit_properties')
+
+    response = staff_client.get(url)
+    assert response.status_code == 200
+
+
 def test_staff_with_permissions_can_view_shipping_methods_and_details(
         staff_client, staff_user, staff_group, permission_view_shipping,
         shipping_method):
-    assert not staff_user.has_perm("shipping.view_shipping")
+    assert not staff_user.has_perm('shipping.view_shipping')
     response = staff_client.get(reverse('dashboard:shipping-methods'))
     assert response.status_code == 302
     response = staff_client.get(reverse('dashboard:shipping-method-details',
@@ -808,7 +1079,7 @@ def test_staff_with_permissions_can_view_shipping_methods_and_details(
     staff_group.permissions.add(permission_view_shipping)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("shipping.view_shipping")
+    assert staff_user.has_perm('shipping.view_shipping')
     response = staff_client.get(reverse('dashboard:shipping-methods'))
     assert response.status_code == 200
     response = staff_client.get(reverse('dashboard:shipping-method-details',
@@ -819,7 +1090,7 @@ def test_staff_with_permissions_can_view_shipping_methods_and_details(
 def test_staff_with_permissions_can_update_add_and_delete_shipping_method(
         staff_client, staff_user, staff_group, permission_edit_shipping,
         shipping_method):
-    assert not staff_user.has_perm("shipping.edit_shipping")
+    assert not staff_user.has_perm('shipping.edit_shipping')
     response = staff_client.get(reverse('dashboard:shipping-method-update',
                                         args=[shipping_method.pk]))
     assert response.status_code == 302
@@ -831,7 +1102,7 @@ def test_staff_with_permissions_can_update_add_and_delete_shipping_method(
     staff_group.permissions.add(permission_edit_shipping)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("shipping.edit_shipping")
+    assert staff_user.has_perm('shipping.edit_shipping')
     response = staff_client.get(reverse('dashboard:shipping-method-update',
                                         args=[shipping_method.pk]))
     assert response.status_code == 200
@@ -853,14 +1124,14 @@ def test_staff_with_permissions_can_edit_customer(
     staff_group.permissions.add(permission_view_user)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("account.edit_user")
-    assert staff_user.has_perm("account.view_user")
+    assert staff_user.has_perm('account.edit_user')
+    assert staff_user.has_perm('account.view_user')
     response = staff_client.get(reverse('dashboard:customer-update',
                                         args=[customer_user.pk]))
     assert response.status_code == 200
     url = reverse('dashboard:customer-update', args=[customer_user.pk])
     data = {'email': 'newemail@example.com', 'is_active': True}
-    response = staff_client.post(url, data)
+    staff_client.post(url, data)
     customer_user = User.objects.get(pk=customer_user.pk)
     assert customer_user.email == 'newemail@example.com'
     assert customer_user.is_active
@@ -873,13 +1144,13 @@ def test_staff_with_permissions_can_add_customer(
     staff_group.permissions.add(permission_view_user)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("account.edit_user")
-    assert staff_user.has_perm("account.view_user")
+    assert staff_user.has_perm('account.edit_user')
+    assert staff_user.has_perm('account.view_user')
     response = staff_client.get(reverse('dashboard:customer-create'))
     assert response.status_code == 200
     url = reverse('dashboard:customer-create')
     data = {'email': 'newcustomer@example.com', 'is_active': True}
-    response = staff_client.post(url, data)
+    staff_client.post(url, data)
     customer = User.objects.get(email='newcustomer@example.com')
     assert customer.is_active
 
@@ -887,14 +1158,14 @@ def test_staff_with_permissions_can_add_customer(
 def test_staff_group_member_can_view_and_edit_site_settings(
         staff_client, staff_user, staff_group, site_settings,
         permission_edit_settings):
-    assert not staff_user.has_perm("site.edit_settings")
+    assert not staff_user.has_perm('site.edit_settings')
     response = staff_client.get(reverse('dashboard:site-update',
                                         args=[site_settings.pk]))
     assert response.status_code == 302
     staff_group.permissions.add(permission_edit_settings)
     staff_user.groups.add(staff_group)
     staff_user = User.objects.get(pk=staff_user.pk)
-    assert staff_user.has_perm("site.edit_settings")
+    assert staff_user.has_perm('site.edit_settings')
     response = staff_client.get(reverse('dashboard:site-update',
                                         args=[site_settings.pk]))
     assert response.status_code == 200
@@ -912,4 +1183,93 @@ def test_staff_group_member_can_view_and_edit_taxes_settings(
     staff_user = User.objects.get(pk=staff_user.pk)
     assert staff_user.has_perm('site.edit_settings')
     response = staff_client.get(url)
+
+
+def test_staff_group_member_can_view_menus_and_details(
+        staff_client, staff_user, staff_group, permission_view_menu,
+        menu_item):
+    menu_list_url = reverse('dashboard:menu-list')
+    menu_details_url = reverse(
+        'dashboard:menu-details', args=[menu_item.menu.pk])
+    menu_item_details_url = reverse(
+        'dashboard:menu-item-details', args=[menu_item.menu.pk, menu_item.pk])
+
+    assert not staff_user.has_perm('menu.view_menu')
+    response = staff_client.get(menu_list_url)
+    assert response.status_code == 302
+    response = staff_client.get(menu_details_url)
+    assert response.status_code == 302
+    response = staff_client.get(menu_item_details_url)
+    assert response.status_code == 302
+
+    staff_group.permissions.add(permission_view_menu)
+    staff_user.groups.add(staff_group)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert staff_user.has_perm('menu.view_menu')
+
+    response = staff_client.get(menu_list_url)
+    assert response.status_code == 200
+    response = staff_client.get(menu_details_url)
+    assert response.status_code == 200
+    response = staff_client.get(menu_item_details_url)
+    assert response.status_code == 200
+
+
+def test_staff_group_member_can_edit_menus(
+        staff_client, staff_user, staff_group, permission_edit_menu,
+        menu_item):
+    menu_add_url = reverse('dashboard:menu-add')
+    menu_edit_url = reverse('dashboard:menu-edit', args=[menu_item.menu.pk])
+    menu_delete_url = reverse(
+        'dashboard:menu-delete', args=[menu_item.menu.pk])
+
+    assert not staff_user.has_perm('menu.view_menu')
+    response = staff_client.get(menu_add_url)
+    assert response.status_code == 302
+    response = staff_client.get(menu_edit_url)
+    assert response.status_code == 302
+    response = staff_client.get(menu_delete_url)
+    assert response.status_code == 302
+
+    staff_group.permissions.add(permission_edit_menu)
+    staff_user.groups.add(staff_group)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert staff_user.has_perm('menu.edit_menu')
+
+    response = staff_client.get(menu_add_url)
+    assert response.status_code == 200
+    response = staff_client.get(menu_edit_url)
+    assert response.status_code == 200
+    response = staff_client.get(menu_delete_url)
+    assert response.status_code == 200
+
+
+def test_staff_group_member_can_edit_menu_items(
+        staff_client, staff_user, staff_group, permission_edit_menu,
+        menu_item):
+    menu_item_add_url = reverse(
+        'dashboard:menu-item-add', args=[menu_item.menu.pk, menu_item.pk])
+    menu_item_edit_url = reverse(
+        'dashboard:menu-item-edit', args=[menu_item.menu.pk, menu_item.pk])
+    menu_item_delete_url = reverse(
+        'dashboard:menu-item-delete', args=[menu_item.menu.pk, menu_item.pk])
+
+    assert not staff_user.has_perm('menu.view_menu')
+    response = staff_client.get(menu_item_add_url)
+    assert response.status_code == 302
+    response = staff_client.get(menu_item_edit_url)
+    assert response.status_code == 302
+    response = staff_client.get(menu_item_delete_url)
+    assert response.status_code == 302
+
+    staff_group.permissions.add(permission_edit_menu)
+    staff_user.groups.add(staff_group)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert staff_user.has_perm('menu.edit_menu')
+
+    response = staff_client.get(menu_item_add_url)
+    assert response.status_code == 200
+    response = staff_client.get(menu_item_edit_url)
+    assert response.status_code == 200
+    response = staff_client.get(menu_item_delete_url)
     assert response.status_code == 200
diff --git a/tests/dashboard/test_product.py b/tests/dashboard/test_product.py
--- a/tests/dashboard/test_product.py
+++ b/tests/dashboard/test_product.py
@@ -8,18 +8,16 @@
 from django.forms.models import model_to_dict
 from django.urls import reverse
 from PIL import Image
+from prices import Money, MoneyRange, TaxedMoney, TaxedMoneyRange
+from tests.utils import get_redirect_location
 
 from saleor.dashboard.product import ProductBulkAction
-from saleor.dashboard.product.forms import (
-    ProductBulkUpdate, ProductForm, ProductTypeForm, ProductVariantForm)
+from saleor.dashboard.product.forms import ProductForm, ProductVariantForm
 from saleor.product.forms import VariantChoiceField
 from saleor.product.models import (
     AttributeChoiceValue, Collection, Product, ProductAttribute, ProductImage,
     ProductType, ProductVariant)
 
-HTTP_STATUS_OK = 200
-HTTP_REDIRECTION = 302
-
 
 def create_image():
     img_data = BytesIO()
@@ -31,379 +29,712 @@ def create_image():
     return image, image_name
 
 
-def test_product_variant_form(product):
-    variant = product.variants.first()
-    variant.name = ''
-    variant.save()
-    example_size = 'Small Size'
-    data = {'attribute-size': example_size, 'sku': '1111', 'quantity': 2}
-    form = ProductVariantForm(data, instance=variant)
-    assert form.is_valid()
-    form.save()
-    variant.refresh_from_db()
-    assert variant.name == example_size
+def test_view_product_list_with_filters(admin_client, product_list):
+    url = reverse('dashboard:product-list')
+    data = {
+        'price_1': [''], 'price_0': [''], 'is_featured': [''],
+        'name': ['Test'], 'sort_by': [''], 'is_published': ['']}
+
+    response = admin_client.get(url, data)
+
+    assert response.status_code == 200
+    assert list(response.context['filter_set'].qs) == product_list
+
+
+def test_view_product_list_with_filters_sort_by(admin_client, product_list):
+    url = reverse('dashboard:product-list')
+    data = {
+        'price_1': [''], 'price_0': [''], 'is_featured': [''],
+        'name': ['Test'], 'sort_by': ['name'], 'is_published': ['']}
+
+    response = admin_client.get(url, data)
+
+    assert response.status_code == 200
+    assert list(response.context['filter_set'].qs) == product_list
+
+    data['sort_by'] = ['-name']
+    url = reverse('dashboard:product-list')
+
+    response = admin_client.get(url, data)
+
+    assert response.status_code == 200
+    assert list(response.context['filter_set'].qs) == product_list[::-1]
+
+
+def test_view_product_list_with_filters_is_published(
+        admin_client, product_list, default_category):
+    url = reverse('dashboard:product-list')
+    data = {
+        'price_1': [''], 'price_0': [''], 'is_featured': [''],
+        'name': ['Test'], 'sort_by': ['name'], 'category': default_category.pk,
+        'is_published': ['1']}
+
+    response = admin_client.get(url, data)
+
+    assert response.status_code == 200
+    result = list(response.context['filter_set'].qs)
+    assert result == [product_list[0], product_list[2]]
+
+
+def test_view_product_list_with_filters_no_results(admin_client, product_list):
+    url = reverse('dashboard:product-list')
+    data = {
+        'price_1': [''], 'price_0': [''], 'is_featured': [''],
+        'name': ['BADTest'], 'sort_by': [''], 'is_published': ['']}
+
+    response = admin_client.get(url, data)
+
+    assert response.status_code == 200
+    assert list(response.context['filter_set'].qs) == []
+
+
+def test_view_product_list_pagination(admin_client, product_list):
+    settings.DASHBOARD_PAGINATE_BY = 1
+    url = reverse('dashboard:product-list')
+    data = {'page': '1'}
+
+    response = admin_client.get(url, data)
+
+    assert response.status_code == 200
+    assert not response.context['filter_set'].is_bound_unsorted
+
+    data = {'page': '2'}
+
+    response = admin_client.get(url, data)
+
+    assert response.status_code == 200
+    assert not response.context['filter_set'].is_bound_unsorted
+
+
+def test_view_product_list_pagination_with_filters(admin_client, product_list):
+    settings.DASHBOARD_PAGINATE_BY = 1
+    url = reverse('dashboard:product-list')
+    data = {
+        'page': '1', 'price_1': [''], 'price_0': [''], 'is_featured': [''],
+        'name': ['Test'], 'sort_by': ['name'], 'is_published': ['']}
+
+    response = admin_client.get(url, data)
+
+    assert response.status_code == 200
+    assert list(response.context['products'])[0] == product_list[0]
+
+    data['page'] = '2'
+
+    response = admin_client.get(url, data)
+
+    assert response.status_code == 200
+    assert list(response.context['products'])[0] == product_list[1]
+
+
+def test_view_product_details(admin_client, product):
+    price = TaxedMoney(net=Money(10, 'USD'), gross=Money(10, 'USD'))
+    sale_price = TaxedMoneyRange(start=price, stop=price)
+    purchase_cost = MoneyRange(start=Money(1, 'USD'), stop=Money(1, 'USD'))
+    url = reverse('dashboard:product-details', kwargs={'pk': product.pk})
+
+    response = admin_client.get(url)
+
+    assert response.status_code == 200
+    context = response.context
+    assert context['product'] == product
+    assert context['sale_price'] == sale_price
+    assert context['purchase_cost'] == purchase_cost
+    assert context['margin'] == (90, 90)
+
+
+def test_view_product_toggle_publish(db, admin_client, product):
+    url = reverse('dashboard:product-publish', kwargs={'pk': product.pk})
+    expected_response = {'success': True, 'is_published': False}
+
+    response = admin_client.post(url)
+
+    assert response.status_code == 200
+    assert json.loads(response.content.decode('utf8')) == expected_response
+    product.refresh_from_db()
+    assert not product.is_published
+
+    admin_client.post(url)
+
+    product.refresh_from_db()
+    assert product.is_published
+
+
+def test_view_product_select_type_display_modal(admin_client):
+    url = reverse('dashboard:product-add-select-type')
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+
+def test_view_product_select_type(admin_client, product_type):
+    url = reverse('dashboard:product-add-select-type')
+    data = {'product_type': product_type.pk}
+
+    response = admin_client.post(url, data)
+
+    assert get_redirect_location(response) == reverse(
+        'dashboard:product-add', kwargs={'type_pk': product_type.pk})
+    assert response.status_code == 302
+
+
+def test_view_product_select_type_by_ajax(admin_client, product_type):
+    url = reverse('dashboard:product-add-select-type')
+    data = {'product_type': product_type.pk}
+
+    response = admin_client.post(
+        url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+
+    assert response.status_code == 200
+    resp_decoded = json.loads(response.content.decode('utf-8'))
+    assert resp_decoded.get('redirectUrl') == reverse(
+        'dashboard:product-add', kwargs={'type_pk': product_type.pk})
+
+
+def test_view_product_create(admin_client, product_type, default_category):
+    url = reverse('dashboard:product-add', kwargs={'type_pk': product_type.pk})
+    data = {
+        'name': 'Product', 'description': 'This is product description.',
+        'price': 10, 'category': default_category.pk, 'variant-sku': '123',
+        'variant-quantity': 2}
+
+    response = admin_client.post(url, data)
+
+    assert response.status_code == 302
+    product = Product.objects.first()
+    assert get_redirect_location(response) == reverse(
+        'dashboard:product-details', kwargs={'pk': product.pk})
+    assert Product.objects.count() == 1
 
 
-def test_valid_product_type_form(color_attribute, size_attribute):
+def test_view_product_edit(admin_client, product):
+    url = reverse('dashboard:product-update', kwargs={'pk': product.pk})
     data = {
-        'name': "Testing Type",
+        'name': 'Product second name', 'description': 'Product description.',
+        'price': 10, 'category': product.category.pk, 'variant-sku': '123',
+        'variant-quantity': 10}
+
+    response = admin_client.post(url, data)
+
+    assert response.status_code == 302
+    product.refresh_from_db()
+    assert get_redirect_location(response) == reverse(
+        'dashboard:product-details', kwargs={'pk': product.pk})
+    assert product.name == 'Product second name'
+
+
+def test_view_product_delete(db, admin_client, product):
+    url = reverse('dashboard:product-delete', kwargs={'pk': product.pk})
+
+    response = admin_client.post(url)
+
+    assert response.status_code == 302
+    assert not Product.objects.filter(pk=product.pk)
+
+
+def test_view_product_not_deleted_before_confirmation(
+        db, admin_client, product):
+    url = reverse('dashboard:product-delete', kwargs={'pk': product.pk})
+
+    response = admin_client.get(url)
+
+    assert response.status_code == 200
+    product.refresh_from_db()
+
+
+def test_view_product_bulk_update_publish(admin_client, product_list):
+    url = reverse('dashboard:product-bulk-update')
+    products =  [product.pk for product in product_list]
+    data = {'action': ProductBulkAction.PUBLISH, 'products': products}
+
+    response = admin_client.post(url, data)
+
+    assert response.status_code == 302
+    assert get_redirect_location(response) == reverse('dashboard:product-list')
+
+    for p in product_list:
+        p.refresh_from_db()
+        assert p.is_published
+
+
+def test_view_product_bulk_update_unpublish(admin_client, product_list):
+    url = reverse('dashboard:product-bulk-update')
+    products =  [product.pk for product in product_list]
+    data = {'action': ProductBulkAction.UNPUBLISH, 'products': products}
+
+    response = admin_client.post(url, data)
+
+    assert response.status_code == 302
+    assert get_redirect_location(response) == reverse('dashboard:product-list')
+
+    for p in product_list:
+        p.refresh_from_db()
+        assert not p.is_published
+
+
+def test_view_ajax_products_list(admin_client, product):
+    url = reverse('dashboard:ajax-products')
+
+    response = admin_client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+
+    assert response.status_code == 200
+    resp_decoded = json.loads(response.content.decode('utf-8'))
+    assert resp_decoded.get('results') == [
+        {'id': product.id, 'text': str(product)}]
+
+
+def test_view_product_type_list(admin_client, product_type):
+    url = reverse('dashboard:product-type-list')
+
+    response = admin_client.get(url)
+
+    assert response.status_code == 200
+    assert len(response.context['product_types']) == 1
+
+
+def test_view_product_type_list_with_filters(admin_client, product_type):
+    url = reverse('dashboard:product-type-list')
+    data = {'name': ['Default Ty'], 'sort_by': ['']}
+
+    response = admin_client.get(url, data)
+
+    assert response.status_code == 200
+    assert product_type in response.context['filter_set'].qs
+    assert len(response.context['filter_set'].qs) == 1
+
+
+def test_view_product_type_create(
+        admin_client, color_attribute, size_attribute):
+    url = reverse('dashboard:product-type-add')
+    data = {
+        'name': 'Testing Type',
         'product_attributes': [color_attribute.pk],
         'variant_attributes': [size_attribute.pk],
         'has_variants': True}
-    form = ProductTypeForm(data)
-    assert form.is_valid()
 
+    response = admin_client.post(url, data)
+
+    assert response.status_code == 302
+    assert get_redirect_location(response) == reverse(
+        'dashboard:product-type-list')
+    assert ProductType.objects.count() == 1
+
+
+def test_view_product_type_create_invalid(
+        admin_client, color_attribute, size_attribute):
+    url = reverse('dashboard:product-type-add')
     # Don't allow same attribute in both fields
-    data['variant_attributes'] = [color_attribute.pk, size_attribute.pk]
-    data['product_attributes'] = [size_attribute.pk]
-    form = ProductTypeForm(data)
-    assert not form.is_valid()
+    data = {
+        'name': 'Testing Type',
+        'product_attributes': [size_attribute.pk],
+        'variant_attributes': [color_attribute.pk, size_attribute.pk],
+        'has_variants': True}
 
+    response = admin_client.post(url, data)
 
-def test_product_type_form_missing_variant_attributes(
-        color_attribute, size_attribute, product_type):
-    product_type.has_variants = True
-    product_type.save()
+    assert response.status_code == 200
+    assert ProductType.objects.count() == 0
+
+
+def test_view_product_type_create_missing_variant_attributes(
+        admin_client, color_attribute, size_attribute):
+    url = reverse('dashboard:product-type-add')
     data = {
-        'name': "Testing Type",
+        'name': 'Testing Type',
         'product_attributes': [color_attribute.pk],
         'variant_attributes': [size_attribute.pk],
         'has_variants': False}
-    form = ProductTypeForm(data, instance=product_type)
-    assert not form.is_valid()
+    response = admin_client.post(url, data)
+
+    assert response.status_code == 200
+    assert ProductType.objects.count() == 0
 
 
-def test_variantless_product_type_form(color_attribute, size_attribute):
+def test_view_product_type_create_variantless(
+        admin_client, color_attribute, size_attribute):
+    url = reverse('dashboard:product-type-add')
     data = {
-        'name': "Testing Type",
+        'name': 'Testing Type',
         'product_attributes': [color_attribute.pk],
         'variant_attributes': [],
         'has_variants': False}
-    form = ProductTypeForm(data)
-    assert form.is_valid()
+    response = admin_client.post(url, data)
+
+    assert response.status_code == 302
+    assert get_redirect_location(response) == reverse(
+        'dashboard:product-type-list')
+    assert ProductType.objects.count() == 1
+
 
+def test_view_product_type_create_variantless_invalid(
+        admin_client, color_attribute, size_attribute):
+    url = reverse('dashboard:product-type-add')
     # Don't allow variant attributes when no variants
     data = {
-        'name': "Testing Type",
+        'name': 'Testing Type',
         'product_attributes': [color_attribute.pk],
         'variant_attributes': [size_attribute.pk],
         'has_variants': False}
-    form = ProductTypeForm(data)
-    assert not form.is_valid()
+    response = admin_client.post(url, data)
+
+    assert response.status_code == 200
+    assert ProductType.objects.count() == 0
 
 
-def test_edit_used_product_type(db, default_category):
+def test_view_product_type_edit_to_no_variants_valid(admin_client, product):
     product_type = ProductType.objects.create(
-        name='New class', has_variants=True)
-    product = Product.objects.create(
-        name='Test product', price=10, product_type=product_type,
-        category=default_category)
-    ProductVariant.objects.create(product=product, sku='1234')
+        name='New product type', has_variants=True)
+    product.product_type = product_type
+    product.save()
 
+    url = reverse(
+        'dashboard:product-type-update', kwargs={'pk': product_type.pk})
     # When all products have only one variant you can change
     # has_variants to false
-    assert product.variants.all().count() == 1
     data = {
         'name': product_type.name,
-        'product_attributes': product_type.product_attributes.all(),
-        'variant_attributes': product_type.variant_attributes.all(),
+        'product_attributes': product_type.product_attributes.values_list(
+            'pk', flat=True),
+        'variant_attributes': product_type.variant_attributes.values_list(
+            'pk', flat=True),
         'has_variants': False}
-    form = ProductTypeForm(data, instance=product_type)
-    assert form.is_valid()
 
-    data = {
-        'name': product_type.name,
-        'product_attributes': product_type.product_attributes.all(),
-        'variant_attributes': product_type.variant_attributes.all(),
-        'has_variants': True}
-    form = ProductTypeForm(data, instance=product_type)
-    assert form.is_valid()
+    response = admin_client.post(url, data)
 
+    assert response.status_code == 302
+    assert get_redirect_location(response) == url
+    product_type.refresh_from_db()
+    assert not product_type.has_variants
+    assert product.variants.count() == 1
+
+
+def test_view_product_type_edit_to_no_variants_invalid(admin_client, product):
+    product_type = ProductType.objects.create(
+        name='New product type', has_variants=True)
+    product.product_type = product_type
+    product.save()
+
+    product.variants.create(sku='12345')
+
+    url = reverse(
+        'dashboard:product-type-update', kwargs={'pk': product_type.pk})
     # Test has_variants validator which prevents turning off when product
     # has multiple variants
-    ProductVariant.objects.create(product=product, sku='12345')
-    assert product.variants.all().count() == 2
     data = {
         'name': product_type.name,
-        'product_attributes': product_type.product_attributes.all(),
-        'variant_attributes': product_type.variant_attributes.all(),
+        'product_attributes': product_type.product_attributes.values_list(
+            'pk', flat=True),
+        'variant_attributes': product_type.variant_attributes.values_list(
+            'pk', flat=True),
         'has_variants': False}
-    form = ProductTypeForm(data, instance=product_type)
-    assert not form.is_valid()
-    assert 'has_variants' in form.errors.keys()
 
+    response = admin_client.post(url, data)
 
-def test_change_attributes_in_product_form(db, product, color_attribute):
-    product_type = product.product_type
-    text_attribute = ProductAttribute.objects.create(
-        slug='author', name='Author')
-    product_type.product_attributes.add(text_attribute)
-    color_value = color_attribute.values.first()
-    new_author = 'Main Tester'
-    data = {
-        'name': product.name,
-        'price': product.price.amount,
-        'category': product.category.pk,
-        'description': 'description',
-        'attribute-author': new_author,
-        'attribute-color': color_value.pk}
-    form = ProductForm(data, instance=product)
-    assert form.is_valid()
-    product = form.save()
-    assert product.attributes[str(color_attribute.pk)] == str(color_value.pk)
+    assert response.status_code == 200
+    product_type.refresh_from_db()
+    assert product_type.has_variants
+    assert product.variants.count() == 2
 
-    # Check that new attribute was created for author
-    author_value = AttributeChoiceValue.objects.get(name=new_author)
-    assert product.attributes[str(text_attribute.pk)] == str(author_value.pk)
 
+def test_view_product_type_delete(db, admin_client, product):
+    product_type = product.product_type
+    url = reverse(
+        'dashboard:product-type-delete', kwargs={'pk': product_type.pk})
 
-def test_attribute_list(db, product, color_attribute, admin_client):
-    assert len(ProductAttribute.objects.all()) == 2
-    response = admin_client.get(reverse('dashboard:product-attributes'))
-    assert response.status_code == 200
+    response = admin_client.post(url)
+
+    assert response.status_code == 302
+    assert not ProductType.objects.filter(pk=product_type.pk)
 
 
-def test_attribute_details(color_attribute, admin_client):
+def test_view_product_type_not_deleted_before_confirmation(
+        admin_client, product):
+    product_type = product.product_type
     url = reverse(
-        'dashboard:product-attribute-details',
-        kwargs={'pk': color_attribute.pk})
+        'dashboard:product-type-delete', kwargs={'pk': product_type.pk})
+
     response = admin_client.get(url)
+
     assert response.status_code == 200
+    assert ProductType.objects.filter(pk=product_type.pk)
 
 
-def test_attribute_add(color_attribute, admin_client):
-    assert len(ProductAttribute.objects.all()) == 1
-    url = reverse('dashboard:product-attribute-add')
-    data = {'name': 'test', 'slug': 'test'}
-    response = admin_client.post(url, data, follow=True)
-    assert response.status_code == 200
-    assert len(ProductAttribute.objects.all()) == 2
+def test_view_product_variant_details(admin_client, product):
+    product_type = product.product_type
+    product_type.has_variants = True
+    product_type.save()
+    variant = product.variants.get()
+    url = reverse(
+        'dashboard:variant-details',
+        kwargs={'product_pk': product.pk, 'variant_pk': variant.pk})
 
+    response = admin_client.get(url)
 
-def test_attribute_add_not_valid(color_attribute, admin_client):
-    assert len(ProductAttribute.objects.all()) == 1
-    url = reverse('dashboard:product-attribute-add')
-    data = {}
-    response = admin_client.post(url, data, follow=True)
     assert response.status_code == 200
-    assert len(ProductAttribute.objects.all()) == 1
+    context = response.context
+    assert context['product'] == product
+    assert context['variant'] == variant
+    assert context['images'].count() == 0
+    assert context['margin'] == 90
+    assert context['discounted_price'] == variant.base_price
 
 
-def test_attribute_edit(color_attribute, admin_client):
-    assert len(ProductAttribute.objects.all()) == 1
+def test_view_product_variant_details_redirect_to_product(
+        admin_client, product):
+    variant = product.variants.get()
     url = reverse(
-        'dashboard:product-attribute-update',
-        kwargs={'pk': color_attribute.pk})
-    data = {'name': 'new_name', 'slug': 'new_slug'}
-    response = admin_client.post(url, data, follow=True)
-    assert response.status_code == 200
-    assert len(ProductAttribute.objects.all()) == 1
-    color_attribute.refresh_from_db()
-    assert color_attribute.name == 'new_name'
-    assert color_attribute.slug == 'new_slug'
+        'dashboard:variant-details',
+        kwargs={'product_pk': product.pk, 'variant_pk': variant.pk})
 
+    response = admin_client.get(url)
 
-def test_attribute_delete(color_attribute, admin_client):
-    assert len(ProductAttribute.objects.all()) == 1
-    url = reverse(
-        'dashboard:product-attribute-delete',
-        kwargs={'pk': color_attribute.pk})
-    response = admin_client.post(url, follow=True)
-    assert response.status_code == 200
-    assert len(ProductAttribute.objects.all()) == 0
+    assert response.status_code == 302
+    assert get_redirect_location(response) == reverse(
+        'dashboard:product-details', kwargs={'pk': product.pk})
 
 
-def test_attribute_choice_value_add(color_attribute, admin_client):
-    values = AttributeChoiceValue.objects.filter(attribute=color_attribute.pk)
-    assert len(values) == 2
-    url = reverse(
-        'dashboard:product-attribute-value-add',
-        kwargs={'attribute_pk': color_attribute.pk})
-    data = {'name': 'Pink', 'color': '#FFF', 'attribute': color_attribute.pk}
-    response = admin_client.post(url, data, follow=True)
-    assert response.status_code == 200
-    values = AttributeChoiceValue.objects.filter(attribute=color_attribute.pk)
-    assert len(values) == 3
+def test_view_product_variant_create(admin_client, product):
+    product_type = product.product_type
+    product_type.has_variants = True
+    product_type.save()
+    url = reverse('dashboard:variant-add', kwargs={'product_pk': product.pk})
+    data = {
+        'sku': 'ABC', 'price_override': '', 'quantity': 10, 'cost_price': ''}
+
+    response = admin_client.post(url, data)
 
+    assert response.status_code == 302
+    variant = product.variants.last()
+    assert get_redirect_location(response) == reverse(
+        'dashboard:variant-details',
+        kwargs={'product_pk': product.pk, 'variant_pk': variant.pk})
+    assert product.variants.count() == 2
+    assert variant.sku == 'ABC'
 
-def test_attribute_choice_value_add_not_valid(color_attribute, admin_client):
-    values = AttributeChoiceValue.objects.filter(attribute=color_attribute.pk)
-    assert len(values) == 2
+
+def test_view_product_variant_edit(admin_client, product):
+    variant = product.variants.get()
     url = reverse(
-        'dashboard:product-attribute-value-add',
-        kwargs={'attribute_pk': color_attribute.pk})
-    data = {}
-    response = admin_client.post(url, data, follow=True)
-    assert response.status_code == 200
-    values = AttributeChoiceValue.objects.filter(attribute=color_attribute.pk)
-    assert len(values) == 2
+        'dashboard:variant-update',
+        kwargs={'product_pk': product.pk, 'variant_pk': variant.pk})
+    data = {
+        'sku': 'ABC', 'price_override': '', 'quantity': 10, 'cost_price': ''}
 
+    response = admin_client.post(url, data)
 
-def test_attribute_choice_value_edit(color_attribute, admin_client):
-    values = AttributeChoiceValue.objects.filter(attribute=color_attribute.pk)
-    assert len(values) == 2
+    assert response.status_code == 302
+    variant = product.variants.last()
+    assert get_redirect_location(response) == reverse(
+        'dashboard:variant-details',
+        kwargs={'product_pk': product.pk, 'variant_pk': variant.pk})
+    assert variant.sku == 'ABC'
+
+
+def test_view_product_variant_delete(admin_client, product):
+    variant = product.variants.get()
     url = reverse(
-        'dashboard:product-attribute-value-update',
-        kwargs={'attribute_pk': color_attribute.pk, 'value_pk': values[0].pk})
-    data = {'name': 'Pink', 'color': '#FFF', 'attribute': color_attribute.pk}
-    response = admin_client.post(url, data, follow=True)
-    assert response.status_code == 200
-    values = AttributeChoiceValue.objects.filter(
-        attribute=color_attribute.pk, name='Pink')
-    assert len(values) == 1
-    assert values[0].name == 'Pink'
+        'dashboard:variant-delete',
+        kwargs={'product_pk': product.pk, 'variant_pk': variant.pk})
 
+    response = admin_client.post(url)
+    assert response.status_code == 302
 
-def test_attribute_choice_value_delete(color_attribute, admin_client):
-    values = AttributeChoiceValue.objects.filter(attribute=color_attribute.pk)
-    assert len(values) == 2
-    deleted_value = values[0]
+    assert not ProductVariant.objects.filter(pk=variant.pk).exists()
+
+
+def test_view_product_variant_not_deleted_before_confirmation(
+        admin_client, product):
+    variant = product.variants.get()
     url = reverse(
-        'dashboard:product-attribute-value-delete',
-        kwargs={
-            'attribute_pk': color_attribute.pk, 'value_pk': deleted_value.pk})
-    response = admin_client.post(url, follow=True)
+        'dashboard:variant-delete',
+        kwargs={'product_pk': product.pk, 'variant_pk': variant.pk})
+
+    response = admin_client.get(url)
+
     assert response.status_code == 200
-    values = AttributeChoiceValue.objects.filter(attribute=color_attribute.pk)
-    assert len(values) == 1
-    assert deleted_value not in values
+    assert ProductVariant.objects.filter(pk=variant.pk).exists()
 
 
-def test_get_formfield_name_with_unicode_characters(db):
-    text_attribute = ProductAttribute.objects.create(
-        slug='ąęαβδηθλμπ', name='ąęαβδηθλμπ')
-    assert text_attribute.get_formfield_name() == 'attribute-ąęαβδηθλμπ'
+def test_view_variant_images(admin_client, product_with_image):
+    variant = product_with_image.variants.get()
+    product_image = product_with_image.images.get()
+    url = reverse(
+        'dashboard:variant-images',
+        kwargs={'product_pk': product_with_image.pk, 'variant_pk': variant.pk})
+    data = {'images': [product_image.pk]}
 
+    response = admin_client.post(url, data)
 
-def test_view_product_toggle_publish(db, admin_client, product):
-    url = reverse('dashboard:product-publish', kwargs={'pk': product.pk})
-    response = admin_client.post(url)
-    assert response.status_code == HTTP_STATUS_OK
-    data = {'success': True, 'is_published': False}
-    assert json.loads(response.content.decode('utf8')) == data
-    admin_client.post(url)
-    product.refresh_from_db()
-    assert product.is_published
+    assert response.status_code == 302
+    assert get_redirect_location(response) == reverse(
+        'dashboard:variant-details',
+        kwargs={'product_pk': product_with_image.pk, 'variant_pk': variant.pk})
+    assert variant.variant_images.filter(image=product_image).exists()
 
 
-def test_view_product_not_deleted_before_confirmation(
-        db, admin_client, product):
-    url = reverse('dashboard:product-delete', kwargs={'pk': product.pk})
-    response = admin_client.get(url)
-    assert response.status_code == HTTP_STATUS_OK
-    product.refresh_from_db()
+def test_view_ajax_available_variants_list(
+        admin_client, product, default_category):
+    unavailable_product = Product.objects.create(
+        name='Test product', price=10, product_type=product.product_type,
+        category=default_category, is_published=False)
+    unavailable_product.variants.create()
+    url = reverse('dashboard:ajax-available-variants')
 
+    response = admin_client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
 
-def test_view_product_delete(db, admin_client, product):
-    url = reverse('dashboard:product-delete', kwargs={'pk': product.pk})
-    response = admin_client.post(url)
-    assert response.status_code == HTTP_REDIRECTION
-    assert not Product.objects.filter(pk=product.pk)
+    assert response.status_code == 200
+    resp_decoded = json.loads(response.content.decode('utf-8'))
+    variant = product.variants.get()
+    assert resp_decoded.get('results') == [
+        {'id': variant.id, 'text': variant.get_ajax_label()}]
 
 
-def test_view_product_type_not_deleted_before_confirmation(
-        admin_client, product):
-    product_type = product.product_type
+def test_view_product_images(admin_client, product_with_image):
+    product_image = product_with_image.images.get()
     url = reverse(
-        'dashboard:product-type-delete', kwargs={'pk': product_type.pk})
+        'dashboard:product-image-list',
+        kwargs={'product_pk': product_with_image.pk})
+
     response = admin_client.get(url)
-    assert response.status_code == HTTP_STATUS_OK
-    assert ProductType.objects.filter(pk=product_type.pk)
 
+    assert response.status_code == 200
+    assert response.context['product'] == product_with_image
+    assert not response.context['is_empty']
+    images = response.context['images']
+    assert len(images) == 1
+    assert product_image in images
 
-def test_view_product_type_delete(db, admin_client, product):
-    product_type = product.product_type
+
+def test_view_product_image_create(
+        monkeypatch, admin_client, product_with_image):
+    mock_create_thumbnails = Mock(return_value=None)
+    monkeypatch.setattr(
+        'saleor.dashboard.product.forms.create_product_thumbnails.delay',
+        mock_create_thumbnails)
     url = reverse(
-        'dashboard:product-type-delete', kwargs={'pk': product_type.pk})
-    response = admin_client.post(url)
-    assert response.status_code == HTTP_REDIRECTION
-    assert not ProductType.objects.filter(pk=product_type.pk)
+        'dashboard:product-image-add',
+        kwargs={'product_pk': product_with_image.pk})
+
+    response = admin_client.get(url)
 
+    assert response.status_code == 200
 
-def test_view_product_variant_not_deleted_before_confirmation(
-        admin_client, product):
-    product_variant_pk = product.variants.first().pk
+    image, image_name = create_image()
+    data = {'image_0': image, 'alt': ['description']}
+
+    response = admin_client.post(url, data, follow=True)
+
+    assert response.status_code == 200
+    assert ProductImage.objects.count() == 2
+    product_with_image.refresh_from_db()
+    images = product_with_image.images.all()
+    assert len(images) == 2
+    assert image_name in images[1].image.name
+    assert images[1].alt == 'description'
+    mock_create_thumbnails.assert_called_once_with(images[1].pk)
+
+
+def test_view_product_image_edit_same_image_add_description(
+        monkeypatch, admin_client, product_with_image):
+    mock_create_thumbnails = Mock(return_value=None)
+    monkeypatch.setattr(
+        'saleor.dashboard.product.forms.create_product_thumbnails.delay',
+        mock_create_thumbnails)
+    product_image = product_with_image.images.all()[0]
     url = reverse(
-        'dashboard:variant-delete',
+        'dashboard:product-image-update',
         kwargs={
-            'product_pk': product.pk,
-            'variant_pk': product_variant_pk})
+            'img_pk': product_image.pk,
+            'product_pk': product_with_image.pk})
+    data = {'image_1': ['0.49x0.59'], 'alt': ['description']}
+
     response = admin_client.get(url)
-    assert response.status_code == HTTP_STATUS_OK
-    assert ProductVariant.objects.filter(pk=product_variant_pk)
 
+    assert response.status_code == 200
 
-def test_view_product_variant_delete(admin_client, product):
-    product_variant_pk = product.variants.first().pk
-    url = reverse(
-        'dashboard:variant-delete',
-        kwargs={
-            'product_pk': product.pk,
-            'variant_pk': product_variant_pk})
-    response = admin_client.post(url)
-    assert response.status_code == HTTP_REDIRECTION
-    assert not ProductVariant.objects.filter(pk=product_variant_pk)
+    response = admin_client.post(url, data, follow=True)
 
+    assert response.status_code == 200
+    assert product_with_image.images.count() == 1
+    product_image.refresh_from_db()
+    assert product_image.alt == 'description'
+    mock_create_thumbnails.assert_called_once_with(product_image.pk)
 
-def test_view_attribute_not_deleted_before_confirmation(
-        admin_client, color_attribute):
+
+def test_view_product_image_edit_new_image(
+        monkeypatch, admin_client, product_with_image):
+    mock_create_thumbnails = Mock(return_value=None)
+    monkeypatch.setattr(
+        'saleor.dashboard.product.forms.create_product_thumbnails.delay',
+        mock_create_thumbnails)
+    product_image = product_with_image.images.all()[0]
     url = reverse(
-        'dashboard:product-attribute-delete',
-        kwargs={'pk': color_attribute.pk})
+        'dashboard:product-image-update',
+        kwargs={
+            'img_pk': product_image.pk,
+            'product_pk': product_with_image.pk})
+
     response = admin_client.get(url)
-    assert response.status_code == HTTP_STATUS_OK
-    assert ProductAttribute.objects.filter(pk=color_attribute.pk)
 
+    assert response.status_code == 200
 
-def test_view_attribute_delete(admin_client, color_attribute):
-    url = reverse(
-        'dashboard:product-attribute-delete',
-        kwargs={'pk': color_attribute.pk})
-    response = admin_client.post(url)
-    assert response.status_code == HTTP_REDIRECTION
-    assert not ProductAttribute.objects.filter(pk=color_attribute.pk)
+    image, image_name = create_image()
+    data = {'image_0': image, 'alt': ['description']}
 
+    response = admin_client.post(url, data, follow=True)
 
-def test_view_product_image_not_deleted_before_confirmation(
-        admin_client, product_with_image):
+    assert response.status_code == 200
+    assert product_with_image.images.count() == 1
+    product_image.refresh_from_db()
+    assert image_name in product_image.image.name
+    assert product_image.alt == 'description'
+    mock_create_thumbnails.assert_called_once_with(product_image.pk)
+
+
+def test_view_product_image_delete(admin_client, product_with_image):
     product_image = product_with_image.images.all()[0]
     url = reverse(
         'dashboard:product-image-delete',
         kwargs={
             'img_pk': product_image.pk,
             'product_pk': product_with_image.pk})
-    response = admin_client.get(url)
-    assert response.status_code == HTTP_STATUS_OK
-    assert ProductImage.objects.filter(pk=product_image.pk).count()
 
+    response = admin_client.post(url)
 
-def test_view_product_image_delete(admin_client, product_with_image):
+    assert response.status_code == 302
+    assert not ProductImage.objects.filter(pk=product_image.pk)
+
+
+def test_view_product_image_not_deleted_before_confirmation(
+        admin_client, product_with_image):
     product_image = product_with_image.images.all()[0]
     url = reverse(
         'dashboard:product-image-delete',
         kwargs={
             'img_pk': product_image.pk,
             'product_pk': product_with_image.pk})
-    response = admin_client.post(url)
-    assert response.status_code == HTTP_REDIRECTION
-    assert not ProductImage.objects.filter(pk=product_image.pk)
+
+    response = admin_client.get(url)
+
+    assert response.status_code == 200
+    assert ProductImage.objects.filter(pk=product_image.pk).count()
 
 
-def test_view_reorder_product_images(admin_client, product_with_images):
+def test_view_ajax_reorder_product_images(admin_client, product_with_images):
     order_before = [img.pk for img in product_with_images.images.all()]
     ordered_images = list(reversed(order_before))
     url = reverse(
         'dashboard:product-images-reorder',
         kwargs={'product_pk': product_with_images.pk})
     data = {'ordered_images': ordered_images}
+
     response = admin_client.post(
         url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-    order_after = [img.pk for img in product_with_images.images.all()]
+
     assert response.status_code == 200
+    order_after = [img.pk for img in product_with_images.images.all()]
     assert order_after == ordered_images
 
 
-def test_view_invalid_reorder_product_images(
+def test_view_ajax_reorder_product_images_invalid(
         admin_client, product_with_images):
     order_before = [img.pk for img in product_with_images.images.all()]
     ordered_images = list(reversed(order_before)).append(3)
@@ -411,214 +742,217 @@ def test_view_invalid_reorder_product_images(
         'dashboard:product-images-reorder',
         kwargs={'product_pk': product_with_images.pk})
     data = {'ordered_images': ordered_images}
+
     response = admin_client.post(
         url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+
     assert response.status_code == 400
     resp_decoded = json.loads(response.content.decode('utf-8'))
     assert 'error' in resp_decoded
     assert 'ordered_images' in resp_decoded['error']
 
 
-def test_view_product_image_add(
-        monkeypatch, admin_client, product_with_image):
+def test_view_ajax_upload_image(monkeypatch, admin_client, product_with_image):
     mock_create_thumbnails = Mock(return_value=None)
     monkeypatch.setattr(
         'saleor.dashboard.product.forms.create_product_thumbnails.delay',
         mock_create_thumbnails)
-    assert len(ProductImage.objects.all()) == 1
-    assert len(product_with_image.images.all()) == 1
+    product = product_with_image
     url = reverse(
-        'dashboard:product-image-add',
-        kwargs={'product_pk': product_with_image.pk})
-    response = admin_client.get(url)
-    assert response.status_code == 200
+        'dashboard:product-images-upload', kwargs={'product_pk': product.pk})
     image, image_name = create_image()
     data = {'image_0': image, 'alt': ['description']}
-    response = admin_client.post(url, data, follow=True)
+
+    response = admin_client.post(
+        url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+
     assert response.status_code == 200
-    assert len(ProductImage.objects.all()) == 2
+    assert ProductImage.objects.count() == 2
     product_with_image.refresh_from_db()
     images = product_with_image.images.all()
     assert len(images) == 2
     assert image_name in images[1].image.name
-    assert images[1].alt == 'description'
     mock_create_thumbnails.assert_called_once_with(images[1].pk)
 
 
-def test_view_product_image_edit_same_image_add_description(
-        monkeypatch, admin_client, product_with_image):
-    mock_create_thumbnails = Mock(return_value=None)
-    monkeypatch.setattr(
-        'saleor.dashboard.product.forms.create_product_thumbnails.delay',
-        mock_create_thumbnails)
-    assert len(product_with_image.images.all()) == 1
-    product_image = product_with_image.images.all()[0]
-    url = reverse(
-        'dashboard:product-image-update',
-        kwargs={
-            'img_pk': product_image.pk,
-            'product_pk': product_with_image.pk})
+def test_view_attribute_list(db, admin_client, color_attribute):
+    url = reverse('dashboard:product-attributes')
+
     response = admin_client.get(url)
+
     assert response.status_code == 200
-    data = {'image_1': ['0.49x0.59'], 'alt': ['description']}
-    response = admin_client.post(url, data, follow=True)
+    result = response.context['attributes'].object_list
+    assert len(result) == 1
+    assert result[0][0] == color_attribute.pk
+    assert result[0][1] == color_attribute.name
+    assert len(result[0][2]) == 2
+    assert not response.context['is_empty']
+
+
+def test_view_attribute_details(admin_client, color_attribute):
+    url = reverse(
+        'dashboard:product-attribute-details',
+        kwargs={'pk': color_attribute.pk})
+
+    response = admin_client.get(url)
+
     assert response.status_code == 200
-    assert len(product_with_image.images.all()) == 1
-    product_image.refresh_from_db()
-    assert product_image.alt == 'description'
-    mock_create_thumbnails.assert_called_once_with(product_image.pk)
+    assert response.context['attribute'] == color_attribute
 
 
-def test_view_product_image_edit_new_image(
-        monkeypatch, admin_client, product_with_image):
-    mock_create_thumbnails = Mock(return_value=None)
-    monkeypatch.setattr(
-        'saleor.dashboard.product.forms.create_product_thumbnails.delay',
-        mock_create_thumbnails)
-    assert len(product_with_image.images.all()) == 1
-    product_image = product_with_image.images.all()[0]
+def test_view_attribute_details_no_choices(admin_client):
+    attribute = ProductAttribute.objects.create(slug='size', name='Size')
     url = reverse(
-        'dashboard:product-image-update',
-        kwargs={
-            'img_pk': product_image.pk,
-            'product_pk': product_with_image.pk})
+        'dashboard:product-attribute-details', kwargs={'pk': attribute.pk})
+
     response = admin_client.get(url)
+
     assert response.status_code == 200
-    image, image_name = create_image()
-    data = {'image_0': image, 'alt': ['description']}
+    assert response.context['attribute'] == attribute
+
+
+def test_view_attribute_create(admin_client, color_attribute):
+    url = reverse('dashboard:product-attribute-add')
+    data = {'name': 'test', 'slug': 'test'}
+
     response = admin_client.post(url, data, follow=True)
+
     assert response.status_code == 200
-    assert len(product_with_image.images.all()) == 1
-    product_image.refresh_from_db()
-    assert image_name in product_image.image.name
-    assert product_image.alt == 'description'
-    mock_create_thumbnails.assert_called_once_with(product_image.pk)
+    assert ProductAttribute.objects.count() == 2
 
 
-def perform_bulk_action(product_list, action):
-    """Perform given bulk action on given product list."""
-    data = {'action': action, 'products': [p.pk for p in product_list]}
-    form = ProductBulkUpdate(data)
-    assert form.is_valid()
-    form.save()
+def test_view_attribute_create_not_valid(admin_client, color_attribute):
+    url = reverse('dashboard:product-attribute-add')
+    data = {}
 
+    response = admin_client.post(url, data, follow=True)
 
-def test_product_bulk_update_form_can_publish_products(product_list):
-    perform_bulk_action(product_list, ProductBulkAction.PUBLISH)
-    for p in product_list:
-        p.refresh_from_db()
-        assert p.is_published
+    assert response.status_code == 200
+    assert ProductAttribute.objects.count() == 1
 
 
-def test_product_bulk_update_form_can_unpublish_products(product_list):
-    perform_bulk_action(product_list, ProductBulkAction.UNPUBLISH)
-    for p in product_list:
-        p.refresh_from_db()
-        assert not p.is_published
+def test_view_attribute_edit(color_attribute, admin_client):
+    url = reverse(
+        'dashboard:product-attribute-update',
+        kwargs={'pk': color_attribute.pk})
+    data = {'name': 'new_name', 'slug': 'new_slug'}
 
+    response = admin_client.post(url, data, follow=True)
 
-def test_product_list_filters(admin_client, product_list):
-    data = {'price_1': [''], 'price_0': [''], 'is_featured': [''],
-            'name': ['Test'], 'sort_by': [''], 'is_published': ['']}
-    url = reverse('dashboard:product-list')
-    response = admin_client.get(url, data)
     assert response.status_code == 200
-    assert list(response.context['filter_set'].qs) == product_list
+    assert ProductAttribute.objects.count() == 1
+    color_attribute.refresh_from_db()
+    assert color_attribute.name == 'new_name'
+    assert color_attribute.slug == 'new_slug'
 
 
-def test_product_list_filters_sort_by(admin_client, product_list):
-    data = {'price_1': [''], 'price_0': [''], 'is_featured': [''],
-            'name': ['Test'], 'sort_by': ['name'], 'is_published': ['']}
-    url = reverse('dashboard:product-list')
-    response = admin_client.get(url, data)
-    assert response.status_code == 200
-    assert list(response.context['filter_set'].qs) == product_list
+def test_view_attribute_delete(admin_client, color_attribute):
+    url = reverse(
+        'dashboard:product-attribute-delete',
+        kwargs={'pk': color_attribute.pk})
 
-    data = {'price_1': [''], 'price_0': [''], 'is_featured': [''],
-            'name': ['Test'], 'sort_by': ['-name'], 'is_published': ['']}
-    url = reverse('dashboard:product-list')
-    response = admin_client.get(url, data)
-    assert response.status_code == 200
-    assert list(response.context['filter_set'].qs) == product_list[::-1]
+    response = admin_client.post(url)
 
+    assert response.status_code == 302
+    assert not ProductAttribute.objects.filter(pk=color_attribute.pk).exists()
 
-def test_product_list_filters_is_published(
-        admin_client, product_list, default_category):
-    data = {'price_1': [''], 'price_0': [''], 'is_featured': [''],
-            'name': ['Test'], 'sort_by': ['name'],
-            'category': default_category.pk, 'is_published': ['1']}
-    url = reverse('dashboard:product-list')
-    response = admin_client.get(url, data)
-    assert response.status_code == 200
-    result = list(response.context['filter_set'].qs)
-    assert result == [product_list[0], product_list[2]]
 
+def test_view_attribute_not_deleted_before_confirmation(
+        admin_client, color_attribute):
+    url = reverse(
+        'dashboard:product-attribute-delete',
+        kwargs={'pk': color_attribute.pk})
+
+    response = admin_client.get(url)
 
-def test_product_list_filters_no_results(admin_client, product_list):
-    data = {'price_1': [''], 'price_0': [''], 'is_featured': [''],
-            'name': ['BADTest'], 'sort_by': [''],
-            'is_published': ['']}
-    url = reverse('dashboard:product-list')
-    response = admin_client.get(url, data)
     assert response.status_code == 200
-    assert list(response.context['filter_set'].qs) == []
+    assert ProductAttribute.objects.filter(pk=color_attribute.pk)
 
 
-def test_product_list_pagination(admin_client, product_list):
-    settings.DASHBOARD_PAGINATE_BY = 1
-    data = {'page': '1'}
-    url = reverse('dashboard:product-list')
-    response = admin_client.get(url, data)
-    assert response.status_code == 200
-    assert not response.context['filter_set'].is_bound_unsorted
+def test_view_attribute_choice_value_create(color_attribute, admin_client):
+    values = AttributeChoiceValue.objects.filter(attribute=color_attribute.pk)
+    assert values.count() == 2
+    url = reverse(
+        'dashboard:product-attribute-value-add',
+        kwargs={'attribute_pk': color_attribute.pk})
+    data = {'name': 'Pink', 'color': '#FFF', 'attribute': color_attribute.pk}
+
+    response = admin_client.post(url, data, follow=True)
 
-    data = {'page': '2'}
-    url = reverse('dashboard:product-list')
-    response = admin_client.get(url, data)
     assert response.status_code == 200
-    assert not response.context['filter_set'].is_bound_unsorted
+    values = AttributeChoiceValue.objects.filter(attribute=color_attribute.pk)
+    assert values.count() == 3
 
 
-def test_product_list_pagination_with_filters(admin_client, product_list):
-    settings.DASHBOARD_PAGINATE_BY = 1
-    data = {'page': '1', 'price_1': [''], 'price_0': [''], 'is_featured': [''],
-            'name': ['Test'], 'sort_by': ['name'], 'is_published': ['']}
-    url = reverse('dashboard:product-list')
-    response = admin_client.get(url, data)
-    assert response.status_code == 200
-    assert list(response.context['products'])[0] == product_list[0]
+def test_view_attribute_choice_value_create_invalid(
+        color_attribute, admin_client):
+    values = AttributeChoiceValue.objects.filter(attribute=color_attribute.pk)
+    assert values.count() == 2
+    url = reverse(
+        'dashboard:product-attribute-value-add',
+        kwargs={'attribute_pk': color_attribute.pk})
+    data = {}
+
+    response = admin_client.post(url, data, follow=True)
 
-    data = {'page': '2', 'price_1': [''], 'price_0': [''], 'is_featured': [''],
-            'name': ['Test'], 'sort_by': ['name'], 'is_published': ['']}
-    url = reverse('dashboard:product-list')
-    response = admin_client.get(url, data)
     assert response.status_code == 200
-    assert list(response.context['products'])[0] == product_list[1]
+    values = AttributeChoiceValue.objects.filter(attribute=color_attribute.pk)
+    assert values.count() == 2
 
 
-def test_product_select_types(admin_client, product_type):
-    url = reverse('dashboard:product-add-select-type')
-    response = admin_client.get(url)
-    assert response.status_code == HTTP_STATUS_OK
+def test_view_attribute_choice_value_edit(color_attribute, admin_client):
+    values = AttributeChoiceValue.objects.filter(attribute=color_attribute.pk)
+    assert values.count() == 2
+    url = reverse(
+        'dashboard:product-attribute-value-update',
+        kwargs={'attribute_pk': color_attribute.pk, 'value_pk': values[0].pk})
+    data = {'name': 'Pink', 'color': '#FFF', 'attribute': color_attribute.pk}
 
-    data = {'product_type': product_type.pk}
-    response = admin_client.post(url, data)
-    assert response.get('location') == reverse(
-        'dashboard:product-add', kwargs={'type_pk': product_type.pk})
-    assert response.status_code == HTTP_REDIRECTION
+    response = admin_client.post(url, data, follow=True)
 
+    assert response.status_code == 200
+    values = AttributeChoiceValue.objects.filter(
+        attribute=color_attribute.pk, name='Pink')
+    assert len(values) == 1
+    assert values[0].name == 'Pink'
 
-def test_product_select_types_by_ajax(admin_client, product_type):
-    url = reverse('dashboard:product-add-select-type')
-    data = {'product_type': product_type.pk}
 
-    response = admin_client.post(
-        url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+def test_view_attribute_choice_value_delete(color_attribute, admin_client):
+    values = AttributeChoiceValue.objects.filter(attribute=color_attribute.pk)
+    assert values.count() == 2
+    deleted_value = values[0]
+    url = reverse(
+        'dashboard:product-attribute-value-delete',
+        kwargs={
+            'attribute_pk': color_attribute.pk, 'value_pk': deleted_value.pk})
+
+    response = admin_client.post(url, follow=True)
 
-    resp_decoded = json.loads(response.content.decode('utf-8'))
     assert response.status_code == 200
-    assert resp_decoded.get('redirectUrl') == reverse(
-        'dashboard:product-add', kwargs={'type_pk': product_type.pk})
+    values = AttributeChoiceValue.objects.filter(attribute=color_attribute.pk)
+    assert len(values) == 1
+    assert deleted_value not in values
+
+
+def test_get_formfield_name_with_unicode_characters(db):
+    text_attribute = ProductAttribute.objects.create(
+        slug='ąęαβδηθλμπ', name='ąęαβδηθλμπ')
+    assert text_attribute.get_formfield_name() == 'attribute-ąęαβδηθλμπ'
+
+
+def test_product_variant_form(product):
+    variant = product.variants.first()
+    variant.name = ''
+    variant.save()
+    example_size = 'Small Size'
+    data = {'attribute-size': example_size, 'sku': '1111', 'quantity': 2}
+
+    form = ProductVariantForm(data, instance=variant)
+    assert form.is_valid()
+
+    form.save()
+    variant.refresh_from_db()
+    assert variant.name == example_size
 
 
 def test_hide_field_in_variant_choice_field_form():
@@ -626,12 +960,40 @@ def test_hide_field_in_variant_choice_field_form():
     variants, cart = MagicMock(), MagicMock()
     variants.count.return_value = 1
     variants.all()[0].pk = 'test'
+
     form.update_field_data(variants, discounts=None, taxes=None)
+
     assert isinstance(form.widget, HiddenInput)
     assert form.widget.attrs.get('value') == 'test'
 
 
-def test_assign_collection_to_product(product):
+def test_product_form_change_attributes(db, product, color_attribute):
+    product_type = product.product_type
+    text_attribute = ProductAttribute.objects.create(
+        slug='author', name='Author')
+    product_type.product_attributes.add(text_attribute)
+    color_value = color_attribute.values.first()
+    new_author = 'Main Tester'
+    data = {
+        'name': product.name,
+        'price': product.price.amount,
+        'category': product.category.pk,
+        'description': 'description',
+        'attribute-author': new_author,
+        'attribute-color': color_value.pk}
+
+    form = ProductForm(data, instance=product)
+    assert form.is_valid()
+
+    product = form.save()
+    assert product.attributes[str(color_attribute.pk)] == str(color_value.pk)
+
+    # Check that new attribute was created for author
+    author_value = AttributeChoiceValue.objects.get(name=new_author)
+    assert product.attributes[str(text_attribute.pk)] == str(author_value.pk)
+
+
+def test_product_form_assign_collection_to_product(product):
     collection = Collection.objects.create(name='test_collections')
     data = {
         'name': product.name,
@@ -639,14 +1001,17 @@ def test_assign_collection_to_product(product):
         'category': product.category.pk,
         'description': 'description',
         'collections': [collection.pk]}
+
     form = ProductForm(data, instance=product)
     assert form.is_valid()
+
     form.save()
     assert product.collections.first().name == 'test_collections'
     assert collection.products.first().name == product.name
 
 
-def test_sanitize_product_description(product_type, default_category):
+def test_product_form_sanitize_product_description(
+        product_type, default_category):
     product = Product.objects.create(
         name='Test Product', price=10, description='', pk=10,
         product_type=product_type, category=default_category)
@@ -657,20 +1022,21 @@ def test_sanitize_product_description(product_type, default_category):
         '<p><a href="www.mirumee.com">link</a></p>'
         '<p>an <script>evil()</script>example</p>')
     data['price'] = 20
+
     form = ProductForm(data, instance=product)
     assert form.is_valid()
+
     form.save()
     assert product.description == (
         '<b>bold</b><p><i>italic</i></p><h2>Header</h2><h3>subheader</h3>'
         '<blockquote>quote</blockquote>'
         '<p><a href="www.mirumee.com">link</a></p>'
         '<p>an &lt;script&gt;evil()&lt;/script&gt;example</p>')
-
     assert product.seo_description == (
         'bolditalicHeadersubheaderquotelinkan evil()example')
 
 
-def test_set_product_seo_description(unavailable_product):
+def test_product_form_seo_description(unavailable_product):
     seo_description = (
         'This is a dummy product. '
         'HTML <b>shouldn\'t be removed</b> since it\'s a simple text field.')
@@ -680,13 +1046,13 @@ def test_set_product_seo_description(unavailable_product):
     data['seo_description'] = seo_description
 
     form = ProductForm(data, instance=unavailable_product)
-
     assert form.is_valid()
+
     form.save()
     assert unavailable_product.seo_description == seo_description
 
 
-def test_set_product_description_too_long_for_seo(unavailable_product):
+def test_product_form_seo_description_too_long(unavailable_product):
     description = (
         'Saying it fourth made saw light bring beginning kind over herb '
         'won\'t creepeth multiply dry rule divided fish herb cattle greater '
@@ -705,15 +1071,13 @@ def test_set_product_description_too_long_for_seo(unavailable_product):
     data['description'] = description
 
     form = ProductForm(data, instance=unavailable_product)
-
     assert form.is_valid()
-    form.save()
 
+    form.save()
     assert len(unavailable_product.seo_description) <= 300
     assert unavailable_product.seo_description == (
         'Saying it fourth made saw light bring beginning kind over herb '
         'won\'t creepeth multiply dry rule divided fish herb cattle greater '
         'fly divided midst, gathering can\'t moveth seed greater subdue. '
         'Lesser meat living fowl called. Dry don\'t wherein. Doesn\'t above '
-        'form sixth. Image moving earth without f...'
-    )
+        'form sixth. Image moving earth without f...')
diff --git a/tests/dashboard/test_shipping.py b/tests/dashboard/test_shipping.py
--- a/tests/dashboard/test_shipping.py
+++ b/tests/dashboard/test_shipping.py
@@ -11,36 +11,36 @@ def test_shipping_method_list(admin_client, shipping_method):
 
 
 def test_shipping_method_add(admin_client):
-    assert len(ShippingMethod.objects.all()) == 0
+    assert ShippingMethod.objects.count() == 0
     url = reverse('dashboard:shipping-method-add')
     data = {'name': 'Zium', 'description': 'Fastest zium'}
     response = admin_client.post(url, data, follow=True)
     assert response.status_code == 200
-    assert len(ShippingMethod.objects.all()) == 1
+    assert ShippingMethod.objects.count() == 1
 
 
 def test_shipping_method_add_not_valid(admin_client):
-    assert len(ShippingMethod.objects.all()) == 0
+    assert ShippingMethod.objects.count() == 0
     url = reverse('dashboard:shipping-method-add')
     data = {}
     response = admin_client.post(url, data, follow=True)
     assert response.status_code == 200
-    assert len(ShippingMethod.objects.all()) == 0
+    assert ShippingMethod.objects.count() == 0
 
 
 def test_shipping_method_edit(admin_client, shipping_method):
-    assert len(ShippingMethod.objects.all()) == 1
+    assert ShippingMethod.objects.count() == 1
     url = reverse('dashboard:shipping-method-update',
                   kwargs={'pk': shipping_method.pk})
     data = {'name': 'Flash', 'description': 'In a flash!'}
     response = admin_client.post(url, data, follow=True)
     assert response.status_code == 200
-    assert len(ShippingMethod.objects.all()) == 1
+    assert ShippingMethod.objects.count() == 1
     assert ShippingMethod.objects.all()[0].name == 'Flash'
 
 
 def test_shipping_method_details(admin_client, shipping_method):
-    assert len(ShippingMethod.objects.all()) == 1
+    assert ShippingMethod.objects.count() == 1
     url = reverse('dashboard:shipping-method-details',
                   kwargs={'pk': shipping_method.pk})
     response = admin_client.post(url, follow=True)
@@ -48,37 +48,37 @@ def test_shipping_method_details(admin_client, shipping_method):
 
 
 def test_shipping_method_delete(admin_client, shipping_method):
-    assert len(ShippingMethod.objects.all()) == 1
+    assert ShippingMethod.objects.count() == 1
     url = reverse('dashboard:shipping-method-delete',
                   kwargs={'pk': shipping_method.pk})
     response = admin_client.post(url, follow=True)
     assert response.status_code == 200
-    assert len(ShippingMethod.objects.all()) == 0
+    assert ShippingMethod.objects.count() == 0
 
 
 def test_shipping_method_country_add(admin_client, shipping_method):
-    assert len(ShippingMethodCountry.objects.all()) == 1
+    assert ShippingMethodCountry.objects.count() == 1
     url = reverse('dashboard:shipping-method-country-add',
                   kwargs={'shipping_method_pk': shipping_method.pk})
     data = {'country_code': 'FR', 'price': '50',
             'shipping_method': shipping_method.pk}
     response = admin_client.post(url, data, follow=True)
     assert response.status_code == 200
-    assert len(ShippingMethodCountry.objects.all()) == 2
+    assert ShippingMethodCountry.objects.count() == 2
 
 
 def test_shipping_method_country_add_not_valid(admin_client, shipping_method):
-    assert len(ShippingMethodCountry.objects.all()) == 1
+    assert ShippingMethodCountry.objects.count() == 1
     url = reverse('dashboard:shipping-method-country-add',
                   kwargs={'shipping_method_pk': shipping_method.pk})
     data = {}
     response = admin_client.post(url, data, follow=True)
     assert response.status_code == 200
-    assert len(ShippingMethodCountry.objects.all()) == 1
+    assert ShippingMethodCountry.objects.count() == 1
 
 
 def test_shipping_method_country_edit(admin_client, shipping_method):
-    assert len(ShippingMethodCountry.objects.all()) == 1
+    assert ShippingMethodCountry.objects.count() == 1
     country = shipping_method.price_per_country.all()[0]
     assert country.price == Money(10, 'USD')
     url = reverse('dashboard:shipping-method-country-edit',
@@ -88,18 +88,18 @@ def test_shipping_method_country_edit(admin_client, shipping_method):
             'shipping_method': shipping_method.pk}
     response = admin_client.post(url, data, follow=True)
     assert response.status_code == 200
-    assert len(ShippingMethodCountry.objects.all()) == 1
+    assert ShippingMethodCountry.objects.count() == 1
 
     shipping_price = shipping_method.price_per_country.all()[0].price
     assert shipping_price == Money(50, 'USD')
 
 
 def test_shipping_method_country_delete(admin_client, shipping_method):
-    assert len(ShippingMethodCountry.objects.all()) == 1
+    assert ShippingMethodCountry.objects.count() == 1
     country = shipping_method.price_per_country.all()[0]
     url = reverse('dashboard:shipping-method-country-delete',
                   kwargs={'shipping_method_pk': shipping_method.pk,
                           'country_pk': country.pk})
     response = admin_client.post(url, follow=True)
     assert response.status_code == 200
-    assert len(ShippingMethodCountry.objects.all()) == 0
+    assert ShippingMethodCountry.objects.count() == 0
diff --git a/tests/dashboard/test_site_settings.py b/tests/dashboard/test_site_settings.py
--- a/tests/dashboard/test_site_settings.py
+++ b/tests/dashboard/test_site_settings.py
@@ -98,7 +98,7 @@ def test_authorization_key_form_add(admin_client, site_settings):
                   kwargs={'site_settings_pk': site_settings.pk})
     response = admin_client.post(url, data, follow=True)
     assert response.status_code == 200
-    assert len(AuthorizationKey.objects.all()) == 1
+    assert AuthorizationKey.objects.count() == 1
     assert site_settings.available_backends().count() == 1
     assert 'google-oauth2' in site_settings.available_backends()
 
@@ -111,7 +111,7 @@ def test_authorization_key_form_add_not_valid(admin_client, site_settings):
                   kwargs={'site_settings_pk': site_settings.pk})
     response = admin_client.post(url, data, follow=True)
     assert response.status_code == 200
-    assert len(AuthorizationKey.objects.all()) == 0
+    assert AuthorizationKey.objects.count() == 0
     assert site_settings.available_backends().count() == 0
 
 
@@ -125,7 +125,7 @@ def test_authorization_key_form_edit(
                           'key_pk': authorization_key.pk})
     response = admin_client.post(url, data, follow=True)
     assert response.status_code == 200
-    assert len(AuthorizationKey.objects.all()) == 1
+    assert AuthorizationKey.objects.count() == 1
     assert site_settings.available_backends().count() == 1
     assert 'google-oauth2' in site_settings.available_backends()
 
@@ -138,7 +138,7 @@ def test_authorization_key_form_delete(
                           'key_pk': authorization_key.pk})
     response = admin_client.post(url, follow=True)
     assert response.status_code == 200
-    assert len(AuthorizationKey.objects.all()) == 0
+    assert AuthorizationKey.objects.count() == 0
     assert site_settings.available_backends().count() == 0
 
 
diff --git a/tests/test_discounts.py b/tests/test_discounts.py
--- a/tests/test_discounts.py
+++ b/tests/test_discounts.py
@@ -86,27 +86,27 @@ def test_checkout_discount_form_not_applicable_voucher(monkeypatch, voucher):
 
 
 def test_checkout_discount_form_active_queryset_voucher_not_active(voucher):
-    assert len(Voucher.objects.all()) == 1
+    assert Voucher.objects.count() == 1
     checkout = Mock(cart=Mock())
     voucher.start_date = date.today() + timedelta(days=1)
     voucher.save()
     form = CheckoutDiscountForm({'voucher': voucher.code}, checkout=checkout)
     qs = form.fields['voucher'].queryset
-    assert len(qs) == 0
+    assert qs.count() == 0
 
 
 def test_checkout_discount_form_active_queryset_voucher_active(voucher):
-    assert len(Voucher.objects.all()) == 1
+    assert Voucher.objects.count() == 1
     checkout = Mock(cart=Mock())
     voucher.start_date = date.today()
     voucher.save()
     form = CheckoutDiscountForm({'voucher': voucher.code}, checkout=checkout)
     qs = form.fields['voucher'].queryset
-    assert len(qs) == 1
+    assert qs.count() == 1
 
 
 def test_checkout_discount_form_active_queryset_after_some_time(voucher):
-    assert len(Voucher.objects.all()) == 1
+    assert Voucher.objects.count() == 1
     checkout = Mock(cart=Mock())
     voucher.start_date = date(year=2016, month=6, day=1)
     voucher.end_date = date(year=2016, month=6, day=2)
@@ -115,17 +115,17 @@ def test_checkout_discount_form_active_queryset_after_some_time(voucher):
     with freeze_time('2016-05-31'):
         form = CheckoutDiscountForm(
             {'voucher': voucher.code}, checkout=checkout)
-        assert len(form.fields['voucher'].queryset) == 0
+        assert form.fields['voucher'].queryset.count() == 0
 
     with freeze_time('2016-06-01'):
         form = CheckoutDiscountForm(
             {'voucher': voucher.code}, checkout=checkout)
-        assert len(form.fields['voucher'].queryset) == 1
+        assert form.fields['voucher'].queryset.count() == 1
 
     with freeze_time('2016-06-03'):
         form = CheckoutDiscountForm(
             {'voucher': voucher.code}, checkout=checkout)
-        assert len(form.fields['voucher'].queryset) == 0
+        assert form.fields['voucher'].queryset.count() == 0
 
 
 @pytest.mark.parametrize(