Repository: saleor/saleor

Issue:
Common template for delete views in dashboard
Currently each delete view in dashboard has its own template. Instead we should have one generic template that will be reused in each delete view. Deleting is a two-step action since it's destructive and user has to confirm it. For this reason we use modals as templates for confirmation.

Our new base template should be simple modal with the following [blocks](https://docs.djangoproject.com/en/1.11/ref/templates/builtins/#std:templatetag-block):
- action_url
- title
- content

The modal will have two actions at the bottom:
- Cancel - closes the modal
- Delete - submits the form
They'll be common to each subclassing template so we don't have to wrap them in blocks.

For now we could only customize `category_delete`. Deleting a category results in removing all products which is dangerous and requires a warning.

Definition of done:
- [x] Add generic "modal_confirm_delete.html" template.
- [x] Update existing delete templates to inherit the generic one.
- [x] Add tests for delete views.



Discussion:
I would be 👎 on string templates like "Delete {{ object }}" and "Are you sure you want to delete {{ object }}". Those are a nightmare for i18n and don't indicate what object you're removing.
What do you propose instead?
I propose that the backend always passes in a full string so I can translate "Delete user {{ user }}" differently than "Delete product {{ product }}".
Do you want to pass both title and body? We could only pass the title and in the body keep generic content as "Are you sure you want to delete this item?".
How would you translate this generic message into Polish? Consider that the "item" may very well be a user or an order.
OK so you want to pass both title and content from backend? I hope that this will not tempt people to write HTML tags in backend when they want add a new line in a body etc.
Maybe the best solution would be to have one base template for deleting with good blocks for title and content and extend it each time you want to add a new delete view? This would be a compromise between what we have now and what I initially proposed.
Sounds like a plan. I just don't want us to abstract so much away that we eventually paint ourselves into a corner.
In case of both title and content being injected from outside, I see little if not none use in delete-specific abstraction for modal. Why wouldn't it be generic modal parametric template?
@salwator I've updated the issue description. I think we'll benefit from introducing a common base template. Adding a new view will require only to add three simple template blocks.

Resolving patch:
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
@@ -32,7 +32,9 @@ def product_class_list(request):
         for pc in classes.object_list]
     ctx = {'form': form, 'product_classes': classes}
     return TemplateResponse(
-        request, 'dashboard/product/product_class/list.html', ctx)
+        request,
+        'dashboard/product/product_class/list.html',
+        ctx)
 
 
 @superuser_required
@@ -48,7 +50,9 @@ def product_class_create(request):
         return redirect('dashboard:product-class-list')
     ctx = {'form': form, 'product_class': product_class}
     return TemplateResponse(
-        request, 'dashboard/product/product_class/form.html', ctx)
+        request,
+        'dashboard/product/product_class/form.html',
+        ctx)
 
 
 @superuser_required
@@ -65,13 +69,14 @@ def product_class_edit(request, pk):
         return redirect('dashboard:product-class-update', pk=pk)
     ctx = {'form': form, 'product_class': product_class}
     return TemplateResponse(
-        request, 'dashboard/product/product_class/form.html', ctx)
+        request,
+        'dashboard/product/product_class/form.html',
+        ctx)
 
 
 @superuser_required
 def product_class_delete(request, pk):
     product_class = get_object_or_404(ProductClass, pk=pk)
-    products = [str(p) for p in product_class.products.all()]
     if request.method == 'POST':
         product_class.delete()
         messages.success(
@@ -80,10 +85,12 @@ def product_class_delete(request, pk):
                 'Dashboard message',
                 'Deleted product type %s') % product_class)
         return redirect('dashboard:product-class-list')
+    ctx = {'product_class': product_class,
+           'products': product_class.products.all()}
     return TemplateResponse(
         request,
         'dashboard/product/product_class/modal_confirm_delete.html',
-        {'product_class': product_class, 'products': products})
+        ctx)
 
 
 @staff_member_required
@@ -157,7 +164,6 @@ def product_detail(request, pk):
         stock = only_variant.stock.all()
     else:
         stock = Stock.objects.none()
-
     ctx = {
         'product': product, 'sale_price': sale_price, 'variants': variants,
         'gross_price_range': gross_price_range, 'images': images,
@@ -202,10 +208,9 @@ def product_edit(request, pk):
             'Dashboard message', 'Updated product %s') % product
         messages.success(request, msg)
         return redirect('dashboard:product-detail', pk=product.pk)
-    ctx = {
-        'product': product, 'product_form': form, 'variant_form': variant_form}
-    return TemplateResponse(
-        request, 'dashboard/product/form.html', ctx)
+    ctx = {'product': product, 'product_form': form,
+           'variant_form': variant_form}
+    return TemplateResponse(request, 'dashboard/product/form.html', ctx)
 
 
 @staff_member_required
@@ -219,7 +224,8 @@ def product_delete(request, pk):
             pgettext_lazy('Dashboard message', 'Deleted product %s') % product)
         return redirect('dashboard:product-list')
     return TemplateResponse(
-        request, 'dashboard/product/modal_confirm_delete.html',
+        request,
+        'dashboard/product/modal_confirm_delete.html',
         {'product': product})
 
 
@@ -231,7 +237,9 @@ def stock_details(request, product_pk, variant_pk, stock_pk):
     stock = get_object_or_404(variant.stock, pk=stock_pk)
     ctx = {'stock': stock, 'product': product, 'variant': variant}
     return TemplateResponse(
-        request, 'dashboard/product/stock/detail.html', ctx)
+        request,
+        'dashboard/product/stock/detail.html',
+        ctx)
 
 
 @staff_member_required
@@ -252,9 +260,12 @@ def stock_edit(request, product_pk, variant_pk, stock_pk=None):
         return redirect(
             'dashboard:variant-details', product_pk=product.pk,
             variant_pk=variant.pk)
-    ctx = {
-        'form': form, 'product': product, 'variant': variant, 'stock': stock}
-    return TemplateResponse(request, 'dashboard/product/stock/form.html', ctx)
+    ctx = {'form': form, 'product': product,
+           'variant': variant, 'stock': stock}
+    return TemplateResponse(
+        request,
+        'dashboard/product/stock/form.html',
+        ctx)
 
 
 @staff_member_required
@@ -272,7 +283,9 @@ def stock_delete(request, product_pk, variant_pk, stock_pk):
             variant_pk=variant.pk)
     ctx = {'product': product, 'stock': stock, 'variant': variant}
     return TemplateResponse(
-        request, 'dashboard/product/stock/modal_confirm_delete.html', ctx)
+        request,
+        'dashboard/product/stock/modal_confirm_delete.html',
+        ctx)
 
 
 @staff_member_required
@@ -281,9 +294,10 @@ def product_images(request, product_pk):
     product = get_object_or_404(
         Product.objects.prefetch_related('images'), pk=product_pk)
     images = product.images.all()
-    ctx = {'product': product, 'images': images}
     return TemplateResponse(
-        request, 'dashboard/product/product_image/list.html', ctx)
+        request,
+        'dashboard/product/product_image/list.html',
+        {'product': product, 'images': images})
 
 
 @staff_member_required
@@ -310,7 +324,9 @@ def product_image_edit(request, product_pk, img_pk=None):
         return redirect('dashboard:product-image-list', product_pk=product.pk)
     ctx = {'form': form, 'product': product, 'product_image': product_image}
     return TemplateResponse(
-        request, 'dashboard/product/product_image/form.html', ctx)
+        request,
+        'dashboard/product/product_image/form.html',
+        ctx)
 
 
 @staff_member_required
@@ -326,10 +342,10 @@ def product_image_delete(request, product_pk, img_pk):
                 'Dashboard message',
                 'Deleted image %s') % image.image.name)
         return redirect('dashboard:product-image-list', product_pk=product.pk)
-    ctx = {'product': product, 'image': image}
     return TemplateResponse(
         request,
-        'dashboard/product/product_image/modal_confirm_delete.html', ctx)
+        'dashboard/product/product_image/modal_confirm_delete.html',
+        {'product': product, 'image': image})
 
 
 @staff_member_required
@@ -353,11 +369,12 @@ def variant_edit(request, product_pk, variant_pk=None):
         return redirect(
             'dashboard:variant-details', product_pk=product.pk,
             variant_pk=variant.pk)
-    ctx = {
-        'attribute_form': attribute_form, 'form': form, 'product': product,
-        'variant': variant}
+    ctx = {'attribute_form': attribute_form, 'form': form, 'product': product,
+           'variant': variant}
     return TemplateResponse(
-        request, 'dashboard/product/product_variant/form.html', ctx)
+        request,
+        'dashboard/product/product_variant/form.html',
+        ctx)
 
 
 @staff_member_required
@@ -377,11 +394,12 @@ def variant_details(request, product_pk, variant_pk):
 
     stock = variant.stock.all()
     images = variant.images.all()
-    ctx = {
-        'images': images, 'product': product, 'stock': stock,
-        'variant': variant}
+    ctx = {'images': images, 'product': product, 'stock': stock,
+           'variant': variant}
     return TemplateResponse(
-        request, 'dashboard/product/product_variant/detail.html', ctx)
+        request,
+        'dashboard/product/product_variant/detail.html',
+        ctx)
 
 
 @staff_member_required
@@ -398,7 +416,8 @@ def variant_images(request, product_pk, variant_pk):
             variant_pk=variant.pk)
     ctx = {'form': form, 'product': product, 'variant': variant}
     return TemplateResponse(
-        request, 'dashboard/product/product_variant/modal_select_images.html',
+        request,
+        'dashboard/product/product_variant/modal_select_images.html',
         ctx)
 
 
@@ -407,7 +426,6 @@ def variant_images(request, product_pk, variant_pk):
 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)
-    is_only_variant = product.variants.count() == 1
     if request.method == 'POST':
         variant.delete()
         messages.success(
@@ -415,12 +433,14 @@ def variant_delete(request, product_pk, variant_pk):
             pgettext_lazy(
                 'Dashboard message', 'Deleted variant %s') % variant.name)
         return redirect('dashboard:product-detail', pk=product.pk)
-    ctx = {
-        'is_only_variant': is_only_variant, 'product': product,
-        'variant': variant}
+
+    ctx = {'is_only_variant': product.variants.count() == 1,
+           'product': product,
+           'variant': variant}
     return TemplateResponse(
         request,
-        'dashboard/product/product_variant/modal_confirm_delete.html', ctx)
+        'dashboard/product/product_variant/modal_confirm_delete.html',
+        ctx)
 
 
 @superuser_required
@@ -428,18 +448,20 @@ def attribute_list(request):
     attributes = [
         (attribute.pk, attribute.name, attribute.values.all())
         for attribute in ProductAttribute.objects.prefetch_related('values')]
-    ctx = {'attributes': attributes}
     return TemplateResponse(
-        request, 'dashboard/product/product_attribute/list.html', ctx)
+        request,
+        'dashboard/product/product_attribute/list.html',
+        {'attributes': attributes})
 
 
 @superuser_required
 def attribute_detail(request, pk):
     attributes = ProductAttribute.objects.prefetch_related('values').all()
     attribute = get_object_or_404(attributes, pk=pk)
-    ctx = {'attribute': attribute}
     return TemplateResponse(
-        request, 'dashboard/product/product_attribute/detail.html', ctx)
+        request,
+        'dashboard/product/product_attribute/detail.html',
+        {'attribute': attribute})
 
 
 @superuser_required
@@ -461,7 +483,9 @@ def attribute_edit(request, pk=None):
         return redirect('dashboard:product-attribute-detail', pk=attribute.pk)
     ctx = {'attribute': attribute, 'form': form, 'formset': formset}
     return TemplateResponse(
-        request, 'dashboard/product/product_attribute/form.html', ctx)
+        request,
+        'dashboard/product/product_attribute/form.html',
+        ctx)
 
 
 @superuser_required
@@ -475,10 +499,10 @@ def attribute_delete(request, pk):
                 'Dashboard message',
                 'Deleted attribute %s') % (attribute.name,))
         return redirect('dashboard:product-attributes')
-    ctx = {'attribute': attribute}
     return TemplateResponse(
         request,
-        'dashboard/product/product_attribute/modal_confirm_delete.html', ctx)
+        'dashboard/product/product_attribute/modal_confirm_delete.html',
+        {'attribute': attribute})
 
 
 @staff_member_required
@@ -487,7 +511,9 @@ def stock_location_list(request):
     stock_locations = StockLocation.objects.all()
     ctx = {'locations': stock_locations}
     return TemplateResponse(
-        request, 'dashboard/product/stock_location/list.html', ctx)
+        request,
+        'dashboard/product/stock_location/list.html',
+        ctx)
 
 
 @staff_member_required
@@ -506,9 +532,10 @@ def stock_location_edit(request, location_pk=None):
                 'Dashboard message for stock location', 'Added location')
         messages.success(request, msg)
         return redirect('dashboard:product-stock-location-list')
-    ctx = {'form': form, 'location': location}
     return TemplateResponse(
-        request, 'dashboard/product/stock_location/form.html', ctx)
+        request,
+        'dashboard/product/stock_location/form.html',
+        {'form': form, 'location': location})
 
 
 @staff_member_required
@@ -525,5 +552,6 @@ def stock_location_delete(request, location_pk):
         return redirect('dashboard:product-stock-location-list')
     ctx = {'location': location, 'stock_count': stock_count}
     return TemplateResponse(
-        request, 'dashboard/product/stock_location/modal_confirm_delete.html',
+        request,
+        'dashboard/product/stock_location/modal_confirm_delete.html',
         ctx)


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -3,11 +3,16 @@
 
 from decimal import Decimal
 import pytest
+
+from io import BytesIO
+from PIL import Image
+
+from django.contrib.auth.models import AnonymousUser
+from django.utils.encoding import smart_text
+from django.core.files.uploadedfile import SimpleUploadedFile
 from django.contrib.auth.models import AnonymousUser, Group, Permission
 from django.utils.encoding import smart_text
 
-from mock import Mock
-
 from saleor.cart import utils
 from saleor.cart.models import Cart
 from saleor.checkout.core import Checkout
@@ -15,7 +20,8 @@
 from saleor.order.models import Order, OrderedItem, DeliveryGroup
 from saleor.product.models import (AttributeChoiceValue, Category, Product,
                                    ProductAttribute, ProductClass,
-                                   ProductVariant, Stock, StockLocation)
+                                   ProductVariant, ProductImage, Stock,
+                                   StockLocation)
 from saleor.shipping.models import ShippingMethod
 from saleor.site.models import SiteSettings, AuthorizationKey
 from saleor.userprofile.models import Address, User
@@ -247,6 +253,27 @@ def product_in_stock(product_class, default_category):
     return product
 
 
+@pytest.fixture
+def stock_location():
+    warehouse_1 = StockLocation.objects.create(name='Warehouse 1')
+    return warehouse_1
+
+
+@pytest.fixture
+def product_image():
+    img_data = BytesIO()
+    image = Image.new('RGB', size=(1, 1))
+    image.save(img_data, format='JPEG')
+    return SimpleUploadedFile('product.jpg', img_data.getvalue())
+
+
+@pytest.fixture
+def product_with_image(product_in_stock, product_image):
+    product = product_in_stock
+    ProductImage.objects.create(product=product, image=product_image)
+    return product
+
+
 @pytest.fixture
 def unavailable_product(product_class, default_category):
     product = Product.objects.create(
@@ -259,7 +286,7 @@ def unavailable_product(product_class, default_category):
 
 @pytest.fixture
 def anonymous_checkout():
-    return Checkout(Mock(), AnonymousUser(), 'tracking_code')
+    return Checkout((), AnonymousUser(), 'tracking_code')
 
 
 @pytest.fixture
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
@@ -12,7 +12,12 @@
                                             ProductClassSelectorForm,
                                             ProductForm)
 from saleor.product.models import (Product, ProductAttribute, ProductClass,
-                                   ProductVariant)
+                                   ProductVariant, ProductImage, Stock,
+                                   StockLocation)
+
+
+HTTP_STATUS_OK = 200
+HTTP_REDIRECTION = 302
 
 
 @pytest.mark.integration
@@ -138,9 +143,138 @@ def test_view_product_toggle_publish(db, admin_client, product_in_stock):
     product = product_in_stock
     url = reverse('dashboard:product-publish', kwargs={'pk': product.pk})
     response = admin_client.post(url)
-    assert response.status_code == 200
+    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
+
+
+def test_view_product_not_deleted_before_confirmation(db, admin_client, product_in_stock):
+    product = product_in_stock
+    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_product_delete(db, admin_client, product_in_stock):
+    product = product_in_stock
+    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)
+
+
+def test_view_product_class_not_deleted_before_confirmation(admin_client, product_in_stock):
+    product_class = product_in_stock.product_class
+    url = reverse('dashboard:product-class-delete', kwargs={'pk': product_class.pk})
+    response = admin_client.get(url)
+    assert response.status_code == HTTP_STATUS_OK
+    assert ProductClass.objects.filter(pk=product_class.pk)
+
+
+def test_view_product_class_delete(db, admin_client, product_in_stock):
+    product_class = product_in_stock.product_class
+    url = reverse('dashboard:product-class-delete', kwargs={'pk': product_class.pk})
+    response = admin_client.post(url)
+    assert response.status_code == HTTP_REDIRECTION
+    assert not ProductClass.objects.filter(pk=product_class.pk)
+
+
+def test_view_product_variant_not_deleted_before_confirmation(admin_client, product_in_stock):
+    product_variant_pk = product_in_stock.variants.first().pk
+    url = reverse('dashboard:variant-delete',
+                  kwargs={'product_pk':product_in_stock.pk,
+                          'variant_pk': product_variant_pk})
+    response = admin_client.get(url)
+    assert response.status_code == HTTP_STATUS_OK
+    assert ProductVariant.objects.filter(pk=product_variant_pk)
+
+
+def test_view_product_variant_delete(admin_client, product_in_stock):
+    product_variant_pk = product_in_stock.variants.first().pk
+    url = reverse('dashboard:variant-delete',
+                  kwargs={'product_pk':product_in_stock.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)
+
+
+def test_view_stock_not_deleted_before_confirmation(admin_client, product_in_stock):
+    product_variant = product_in_stock.variants.first()
+    stock = Stock.objects.filter(variant=product_variant).first()
+    url = reverse('dashboard:variant-stock-delete',
+                  kwargs={'product_pk':product_in_stock.pk,
+                          'variant_pk': product_variant.pk,
+                          'stock_pk': stock.pk})
+    response = admin_client.get(url)
+    assert response.status_code == HTTP_STATUS_OK
+    assert Stock.objects.filter(pk=stock.pk)
+
+
+def test_view_stock_delete(admin_client, product_in_stock):
+    product_variant = product_in_stock.variants.first()
+    stock = Stock.objects.filter(variant=product_variant).first()
+    url = reverse('dashboard:variant-stock-delete',
+                  kwargs={'product_pk':product_in_stock.pk,
+                          'variant_pk': product_variant.pk,
+                          'stock_pk': stock.pk})
+    response = admin_client.post(url)
+    assert response.status_code == HTTP_REDIRECTION
+    assert not Stock.objects.filter(pk=stock.pk)
+
+
+def test_view_stock_location_not_deleted_before_confirmation(admin_client, stock_location):
+    url = reverse('dashboard:product-stock-location-delete',
+                  kwargs={'location_pk':stock_location.pk})
+    response = admin_client.get(url)
+    assert response.status_code == HTTP_STATUS_OK
+    assert StockLocation.objects.filter(pk=stock_location.pk)
+
+
+def test_view_stock_location_delete(admin_client, stock_location):
+    url = reverse('dashboard:product-stock-location-delete',
+                  kwargs={'location_pk':stock_location.pk})
+    response = admin_client.post(url)
+    assert response.status_code == HTTP_REDIRECTION
+    assert not StockLocation.objects.filter(pk=stock_location.pk)
+
+
+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)
+    assert response.status_code == HTTP_STATUS_OK
+    assert ProductAttribute.objects.filter(pk=color_attribute.pk)
+
+
+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)
+
+
+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.get(url)
+    assert response.status_code == HTTP_STATUS_OK
+    assert ProductImage.objects.filter(pk=product_image.pk).count()
+
+
+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.post(url)
+    assert response.status_code == HTTP_REDIRECTION
+    assert not ProductImage.objects.filter(pk=product_image.pk)
+