Repository: saleor/saleor
Pull request: [GDPR] Ability to remove a customer
close #2235 

### TODO
- [x] Ability to delete user from the dashboard
- [x] Ability to delete user from the storefront
- [x] Documentation
- [x] Tests
- [x] Confirmation email

Problem statement:
[GDPR] Data removal tool
A logged in user should have the ability to remove their own data (and effectively close their account).

We should likely require a confirmation link to be clicked to prove that the person has access to the email address of the logged in account, otherwise, data removal could become a DoS attack vector.

Anonymous users should have a way to request their data being removed by providing an identifying piece of information like the email address or a phone number. Similar confirmation methods could be used to ensure this feature is not abused with malicious intent.

As the store is required to fulfill any outstanding contracts, orders and financial records should be preserved until they are no longer needed.

Hints from issue discussion:
To be clear, the focus here is on being able to remove registered accounts. It's unlikely that we'll be able to build a tool that automatically removes anonymous user's info without staff assisting.
When deleting User's account, should we remove `user_email`  from `Order` and `phone number` from order's addresses as well? 
I don't find them necessary to keep 
Should we keep a track of who deleted which user?  

We should not delete any address data as we may still need to contact the user regarding their orders. We have a contractual requirement here but also a legal obligation (the store still needs to be able to accept returns, replacements, refunds and so on).

As for keeping track of who deletes whom:
* if the user deletes their own account we don't need to keep track of that as long as we make sure random people can't delete data that belongs to others
* if an admin fulfills a right to be forgotten in response to a legitimate request, the company is required to keep this information in an audit log (it's company-level, like accounting, not specific to Saleor, and we don't need to provide such functionality as part of this issue)

Commit message: Rebase with latest master

Patch to review:
diff --git a/docs/architecture.rst b/docs/architecture.rst
index bc35e2e1a60..0db35e3f4cc 100644
--- a/docs/architecture.rst
+++ b/docs/architecture.rst
@@ -14,3 +14,4 @@ Architecture
    architecture/payments
    architecture/settings
    architecture/page
+   architecture/gdpr
diff --git a/docs/architecture/gdpr.rst b/docs/architecture/gdpr.rst
new file mode 100644
index 00000000000..93471a67175
--- /dev/null
+++ b/docs/architecture/gdpr.rst
@@ -0,0 +1,34 @@
+GDPR Compilance
+===============
+
+Saleor handles few aspects of the GDPR regulation by default
+
+Deleting users
+--------------
+
+A user account can be deleted from the dashboard, by a staff user.
+This action takes place immediately.
+
+From the storefront, a user can request his account deletion
+from within his profile settings, in such case, a confirmation email will be
+sent to the email address associated with the account.
+
+Deleting a user will delete his account instance, but will leave all the data
+used for the checkout process untouched, for the financial record.
+This behavior in accordance with the GDPR regulations.
+
+Cookies
+-------
+
+All cookies used by Saleor are functional, therefore there's no need to notify
+the users about them.
+
+Manual actions required
+=======================
+
+Privacy Policy and Terms of Service
+-----------------------------------
+
+Make sure your Terms of Service or Privacy Policy properly communicate to your
+users who you are and how you are using their data.
+We recommend you ensure your policies are up to date and clear to your readers.
diff --git a/saleor/account/emails.py b/saleor/account/emails.py
index ea64d8e5ceb..e5646793f88 100644
--- a/saleor/account/emails.py
+++ b/saleor/account/emails.py
@@ -1,5 +1,6 @@
 from celery import shared_task
 from django.conf import settings
+from django.contrib.sites.models import Site
 from django.urls import reverse
 from templated_email import send_templated_mail
 
@@ -18,3 +19,16 @@ def send_password_reset_email(context, recipient):
         from_email=settings.DEFAULT_FROM_EMAIL,
         recipient_list=[recipient],
         context=context)
+
+
+@shared_task
+def send_account_delete_confirmation_email(token, recipient_email):
+    site = Site.objects.get_current()
+    delete_url = build_absolute_uri(
+        reverse('account:delete-confirm', kwargs={'token': token}))
+    ctx = {'site_name': site.name, 'domain': site.domain, 'url': delete_url}
+    send_templated_mail(
+        template_name='account/account_delete',
+        from_email=settings.DEFAULT_FROM_EMAIL,
+        recipient_list=[recipient_email],
+        context=ctx)
diff --git a/saleor/account/migrations/0020_user_token.py b/saleor/account/migrations/0020_user_token.py
new file mode 100644
index 00000000000..6d512a7c6d6
--- /dev/null
+++ b/saleor/account/migrations/0020_user_token.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.0.3 on 2018-05-30 12:26
+
+from django.db import migrations, models
+import saleor.account.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('account', '0019_auto_20180528_1205'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='user',
+            name='token',
+            field=models.UUIDField(default=saleor.account.models.get_token, editable=False),
+        ),
+    ]
diff --git a/saleor/account/models.py b/saleor/account/models.py
index 228392b2a48..8f158b038c7 100644
--- a/saleor/account/models.py
+++ b/saleor/account/models.py
@@ -1,3 +1,5 @@
+import uuid
+
 from django.conf import settings
 from django.contrib.auth.models import (
     AbstractBaseUser, BaseUserManager, PermissionsMixin)
@@ -77,6 +79,7 @@ def create_user(
         email = UserManager.normalize_email(email)
         # Google OAuth2 backend send unnecessary username field
         extra_fields.pop('username', None)
+
         user = self.model(
             email=email, is_active=is_active, is_staff=is_staff,
             **extra_fields)
@@ -90,11 +93,16 @@ def create_superuser(self, email, password=None, **extra_fields):
             email, password, is_staff=True, is_superuser=True, **extra_fields)
 
 
+def get_token():
+    return str(uuid.uuid4())
+
+
 class User(PermissionsMixin, AbstractBaseUser):
     email = models.EmailField(unique=True)
     addresses = models.ManyToManyField(
         Address, blank=True, related_name='user_addresses')
     is_staff = models.BooleanField(default=False)
+    token = models.UUIDField(default=get_token, editable=False)
     is_active = models.BooleanField(default=True)
     note = models.TextField(null=True, blank=True)
     date_joined = models.DateTimeField(default=timezone.now, editable=False)
diff --git a/saleor/account/urls.py b/saleor/account/urls.py
index b58779a2c58..663aaf490cc 100644
--- a/saleor/account/urls.py
+++ b/saleor/account/urls.py
@@ -24,4 +24,7 @@
         name='address-edit'),
     url(r'^address/(?P<pk>\d+)/delete/$',
         views.address_delete, name='address-delete'),
-]
+    url(r'^(?P<pk>\d+)/delete/$', views.account_delete,
+        name='delete'),
+    url(r'^(?P<token>[0-9A-Za-z_\-]+)/delete-confirm/',
+        views.account_delete_confirm, name='delete-confirm')]
diff --git a/saleor/account/views.py b/saleor/account/views.py
index 1bbdb346ba6..f68855299ab 100755
--- a/saleor/account/views.py
+++ b/saleor/account/views.py
@@ -2,17 +2,20 @@
 from django.contrib import auth, messages
 from django.contrib.auth import views as django_views
 from django.contrib.auth.decorators import login_required
-from django.http import HttpResponseRedirect
+from django.http import Http404, HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect
 from django.template.response import TemplateResponse
 from django.urls import reverse, reverse_lazy
 from django.utils.translation import pgettext, ugettext_lazy as _
+from django.views.decorators.http import require_POST
 
 from ..checkout.utils import find_and_assign_anonymous_cart
 from ..core.utils import get_paginator_items
+from .emails import send_account_delete_confirmation_email
 from .forms import (
     ChangePasswordForm, LoginForm, PasswordResetForm, SignupForm,
     get_address_form, logout_on_password_change)
+from .models import User
 
 
 @find_and_assign_anonymous_cart()
@@ -122,3 +125,24 @@ def address_delete(request, pk):
         return HttpResponseRedirect(reverse('account:details') + '#addresses')
     return TemplateResponse(
         request, 'account/address_delete.html', {'address': address})
+
+
+@login_required
+@require_POST
+def account_delete(request, pk):
+    user = get_object_or_404(User, pk=pk)
+    if request.user.pk != user.pk:
+        raise Http404('No such page!')
+    send_account_delete_confirmation_email.delay(str(user.token), user.email)
+    messages.success(
+        request, pgettext(
+            'Storefront message, when user requested his account removed',
+            'Please check your inbox for a confirmation e-mail.'))
+    return HttpResponseRedirect(reverse('account:details') + '#settings')
+
+
+def account_delete_confirm(request, token):
+    user = get_object_or_404(User, token=token)
+    user.delete()
+    return TemplateResponse(
+        request, 'account/account_delete.html')
diff --git a/saleor/core/utils/__init__.py b/saleor/core/utils/__init__.py
index 41b63d6887e..14e202a259a 100644
--- a/saleor/core/utils/__init__.py
+++ b/saleor/core/utils/__init__.py
@@ -14,15 +14,13 @@
 from django_countries import countries
 from django_countries.fields import Country
 from django_prices_openexchangerates import exchange_currency
-from django_prices_vatlayer.utils import (
-    get_tax_rates_for_country, get_tax_for_rate)
+
 from geolite2 import geolite2
 from prices import MoneyRange
 from versatileimagefield.image_warmer import VersatileImageFieldWarmer
 
 from ...account.models import User
 from ...core.i18n import COUNTRY_CODE_CHOICES
-from ...core.utils.taxes import DEFAULT_TAX_RATE_NAME
 
 georeader = geolite2.reader()
 logger = logging.getLogger(__name__)
diff --git a/saleor/dashboard/customer/forms.py b/saleor/dashboard/customer/forms.py
index 12da3dc78b5..bf139cf654a 100644
--- a/saleor/dashboard/customer/forms.py
+++ b/saleor/dashboard/customer/forms.py
@@ -4,6 +4,35 @@
 from ...account.models import CustomerNote, User
 
 
+class CustomerDeleteForm(forms.Form):
+
+    def __init__(self, *args, **kwargs):
+        self.instance = kwargs.pop('instance')
+        self.user = kwargs.pop('user')
+        super().__init__(*args, **kwargs)
+
+    def clean(self):
+        data = super().clean()
+        if not self.instance.is_staff:
+            return data
+
+        if self.instance == self.user:
+            raise forms.ValidationError(pgettext_lazy(
+                'Edit customer details in order form error',
+                'You can\'t delete your own account via dashboard, '
+                'please try from the storefront.'))
+        if self.instance.is_superuser:
+            raise forms.ValidationError(pgettext_lazy(
+                'Edit customer details in order form error',
+                'Only superuser can delete his own account.'))
+        can_edit_staff_users = self.user.has_perm('account.edit_staff')
+        if not can_edit_staff_users:
+            raise forms.ValidationError(pgettext_lazy(
+                'Edit customer details in order form error',
+                'You have insufficient permissions, to edit staff users.'))
+        return data
+
+
 class CustomerForm(forms.ModelForm):
     def __init__(self, *args, **kwargs):
         self.user = kwargs.pop('user', None)
diff --git a/saleor/dashboard/customer/urls.py b/saleor/dashboard/customer/urls.py
index aab82487972..715034b98d3 100644
--- a/saleor/dashboard/customer/urls.py
+++ b/saleor/dashboard/customer/urls.py
@@ -8,6 +8,8 @@
     url(r'^(?P<pk>[0-9]+)/$', views.customer_details, name='customer-details'),
     url(r'^(?P<pk>[0-9]+)/update/$', views.customer_edit,
         name='customer-update'),
+    url(r'^(?P<pk>[0-9]+)/delete/$', views.customer_delete,
+        name='customer-delete'),
     url(r'^ajax/users/$', views.ajax_users_list, name='ajax-users-list'),
     url(r'^(?P<customer_pk>[0-9]+)/add-note/$',
         views.customer_add_note, name='customer-add-note')]
diff --git a/saleor/dashboard/customer/views.py b/saleor/dashboard/customer/views.py
index 32e792434fa..a76b984f8ea 100644
--- a/saleor/dashboard/customer/views.py
+++ b/saleor/dashboard/customer/views.py
@@ -13,7 +13,7 @@
 from ..emails import send_set_password_email
 from ..views import staff_member_required
 from .filters import UserFilter
-from .forms import CustomerForm, CustomerNoteForm
+from .forms import CustomerDeleteForm, CustomerForm, CustomerNoteForm
 
 
 @staff_member_required
@@ -117,3 +117,26 @@ def customer_add_note(request, customer_pk):
     ctx.update(csrf(request))
     template = 'dashboard/customer/modal/add_note.html'
     return TemplateResponse(request, template, ctx, status=status)
+
+
+@staff_member_required
+@permission_required('account.edit_user')
+def customer_delete(request, pk):
+    customer = get_object_or_404(User, pk=pk)
+    form = CustomerDeleteForm(
+        request.POST or None, instance=customer, user=request.user)
+    status = 200
+    if request.method == 'POST' and form.is_valid():
+        customer.delete()
+        msg = pgettext_lazy(
+            'Dashboard message',
+            '%(customer_name)s successfully removed') % {
+                'customer_name': customer}
+        messages.success(request, msg)
+        return redirect('dashboard:customers')
+    elif request.method == 'POST' and not form.is_valid():
+        status = 400
+    ctx = {'customer': customer, 'form': form}
+    return TemplateResponse(
+        request, 'dashboard/customer/modal/confirm_delete.html', ctx,
+        status=status)
diff --git a/saleor/order/views.py b/saleor/order/views.py
index 8dd54b0af00..8eaa32c7f49 100644
--- a/saleor/order/views.py
+++ b/saleor/order/views.py
@@ -12,7 +12,7 @@
 from django.views.decorators.csrf import csrf_exempt
 from payments import PaymentStatus, RedirectNeeded
 
-from . import FulfillmentStatus, OrderStatus
+from . import FulfillmentStatus
 from ..account.forms import LoginForm
 from ..account.models import User
 from ..core.utils import get_client_ip
diff --git a/saleor/static/scss/layouts/_account.scss b/saleor/static/scss/layouts/_account.scss
index 8b28286f7ed..e3674251538 100644
--- a/saleor/static/scss/layouts/_account.scss
+++ b/saleor/static/scss/layouts/_account.scss
@@ -44,4 +44,16 @@
   h3 {
     font-size: 1rem;
   }
+
+  &__deleted {
+    text-align: center;
+    margin-top: $global-margin * 6;
+    @include media-breakpoint-down(sm) {
+      margin-top: $global-margin * 3;
+    }
+    h1 {
+      font-weight: 300;
+      margin-top: -$global-margin * 0.5;
+    }
+  }
 }
diff --git a/templates/account/account_delete.html b/templates/account/account_delete.html
new file mode 100644
index 00000000000..bbe15483538
--- /dev/null
+++ b/templates/account/account_delete.html
@@ -0,0 +1,24 @@
+{% extends 'base.html' %}
+
+{% load bootstrap_form from bootstrap4 %}
+{% load staticfiles %}
+{% load i18n %}
+{% load placeholder %}
+
+{% block title %}{% trans "Account deleted" context "Account deleted page title" %} — {{ block.super }}{% endblock %}
+
+{% block content %}
+<div class="row account__deleted">
+  <div class="col-xl-10 m-auto">
+    <img class="lazyload lazypreload"
+        data-src="{% static 'images/login-bg.png' %}"
+        data-srcset="{% static 'images/login-bg.png' %} 1x, {% static 'images/login-bg2x.png' %} 2x">
+    <h1>{% trans "Sorry to see you go!" context "Message displayed to the user after his account was deleted" %}</h1>
+    <p>{% blocktrans trimmed context "Message displayed to the user after his account was deleted" %}
+        Your account was deleted successfully.
+        If any problems or questions contact us.
+    {% endblocktrans %}
+    </p>
+  </div>
+</div>
+{% endblock %}
diff --git a/templates/account/details.html b/templates/account/details.html
index c1fc166060f..f8597751806 100644
--- a/templates/account/details.html
+++ b/templates/account/details.html
@@ -42,6 +42,11 @@ <h3>{% trans "Change password" context "User profile tab" %}</h3>
             <h3>{% trans "Addresses book" context "User profile tab" %}</h3>
           </a>
         </li>
+        <li class="nav-item">
+          <a class="nav-link" data-toggle="tab" href="#settings" role="tab">
+            <h3>{% trans "Settings" context "User profile tab" %}</h3>
+          </a>
+        </li>
       </ul>
     </div>
     <div class="tab-content col-lg-10 m-auto">
@@ -146,7 +151,23 @@ <h3 class="text-sm-center">
           {% endif %}
         </div>
       </div>
-    </div>
+      <div class="tab-pane fade" id="settings" role="tabpanel">
+        <div class="row ">
+          <div class="d-block mx-auto text-center">
+            <form action="{% url 'account:delete' pk=request.user.pk %}" method="post">
+              {% csrf_token %}
+              <p>
+                {% blocktrans context "Message informing about the process of account removal" %}
+                  If you'd like to remove your account, please click on the button below and a confirmation email will be sent to you.
+                {% endblocktrans %}
+              </p>
+              <button type="submit" class="btn danger narrow text-center">
+                {% trans "Remove Account" context "Remove account form action" %}
+              </button>
+            </form>
+          </div>
+        </div>
+      </div>
   </div>
 {% endblock content %}
 
diff --git a/templates/dashboard/customer/detail.html b/templates/dashboard/customer/detail.html
index c5f1f8eb4ab..b10eabc5fd2 100644
--- a/templates/dashboard/customer/detail.html
+++ b/templates/dashboard/customer/detail.html
@@ -74,6 +74,9 @@ <h4>
             <a href="{% url 'dashboard:customer-update' customer.pk %}" class="btn-flat waves-effect">
               {% trans "Edit customer" context "Customer action" %}
             </a>
+            <a href="#base-modal" data-href="{% url 'dashboard:customer-delete' pk=customer.pk %}" class="modal-trigger-custom btn-flat waves-effect">
+              {% trans "Remove customer" context "Customer action" %}
+            </a>
           {% endif %}
           {% if perms.account.impersonate_user %}
             <a href="{% url 'impersonate-start' uid=customer.pk %}" class="btn-flat waves-effect">
diff --git a/templates/dashboard/customer/modal/confirm_delete.html b/templates/dashboard/customer/modal/confirm_delete.html
new file mode 100644
index 00000000000..f940448e1ad
--- /dev/null
+++ b/templates/dashboard/customer/modal/confirm_delete.html
@@ -0,0 +1,29 @@
+{% extends "dashboard/base_modal.html" %}
+
+{% load i18n %}
+
+{% block form_class %}form-async{% endblock %}
+
+{% block action_url %}
+{% url 'dashboard:customer-delete' pk=customer.pk %}
+{% endblock %}
+
+{% block title %}
+{% blocktrans trimmed context "Header of modal used for removing customer's account " %}
+  Remove customer {{ customer }}
+{% endblocktrans %}
+{% endblock %}
+
+{% block content %}
+  <div class="col s12">
+    {% blocktrans trimmed context "Modal remove collection text" %}
+      Are you sure you want to remove customer <strong>{{ customer }}</strong>?
+      Please note that this procedure will remove customer's account,
+      but customer's orders and addresses associated with them will persist for the financial records.
+    {% endblocktrans %}
+  </div>
+{% endblock content %}
+
+{% block primary_action %}
+{% trans "Remove" context "Dashboard delete action" %}
+{% endblock %}
diff --git a/templates/templated_email/account/account_delete.email b/templates/templated_email/account/account_delete.email
new file mode 100644
index 00000000000..aa640ac19c5
--- /dev/null
+++ b/templates/templated_email/account/account_delete.email
@@ -0,0 +1,29 @@
+{% load i18n %}
+
+{% block subject %}
+  {% trans "Delete your account" context "Account delete reset e-mail subject" %}
+{% endblock %}
+
+{% block plain %}
+{% blocktrans context "Account delete reset e-mail text" %}
+Hi!
+
+You're receiving this e-mail because you or someone else has requested a deletion of your user account at {{ domain }}.
+Click the link below to delete your account.
+
+Please note that this action is permanent and cannot be reversed.
+{% endblocktrans %}
+
+{{ url }}
+
+This is an automatically generated email, please do not reply.
+
+{% blocktrans context "Account delete reset e-mail text" %}
+Sincerely,
+{{ site_name }}
+{% endblocktrans %}
+{% endblock %}
+
+{% block html %}
+{% include 'templated_email/compiled/account_delete.html' %}
+{% endblock %}
diff --git a/templates/templated_email/source/account_delete.mjml b/templates/templated_email/source/account_delete.mjml
new file mode 100644
index 00000000000..9c22799deea
--- /dev/null
+++ b/templates/templated_email/source/account_delete.mjml
@@ -0,0 +1,30 @@
+<mjml>
+  <mj-head>
+    <mj-include path="./shared/styles.mjml" />
+  </mj-head>
+  <mj-body>
+    <mj-raw>
+      {% load i18n %}
+    </mj-raw>
+    <mj-include path="./shared/header.mjml" />
+    <mj-section>
+      <mj-column>
+        <mj-text font-size="16px">
+          {% trans "Hi!" context "Account delete e-mail text" %}
+        </mj-text>
+        <mj-text>
+          {% blocktrans trimmed context "Account delete e-mail text" %}
+            You're receiving this e-mail because you or someone else has requested a deletion of your user account at {{ domain }}.<br/>
+            Click the link below to delete your account.
+
+            Please note that this action is permanent and cannot be reversed.
+          {% endblocktrans %}
+        </mj-text>
+        <mj-text>
+          <a href="{{ url }}">{{ url }}</a>
+          </mj-text>
+      </mj-column>
+    </mj-section>
+    <mj-include path="./shared/footer.mjml" />
+  </mj-body>
+</mjml>
diff --git a/tests/dashboard/test_customer.py b/tests/dashboard/test_customer.py
index e6755b86d59..3d9e4316ecf 100644
--- a/tests/dashboard/test_customer.py
+++ b/tests/dashboard/test_customer.py
@@ -1,7 +1,8 @@
 from django.urls import reverse
 
-from saleor.account.models import CustomerNote
-from saleor.dashboard.customer.forms import CustomerNoteForm
+from saleor.account.models import CustomerNote, User
+from saleor.dashboard.customer.forms import (
+    CustomerDeleteForm, CustomerNoteForm)
 
 
 def test_add_note_to_customer(admin_user, customer_user):
@@ -22,3 +23,45 @@ def test_add_note_to_customer_from_url(admin_client, customer_user):
     response = admin_client.post(url, data)
 
     assert response.status_code == 200
+
+
+def test_view_delete_customer(admin_client, admin_user, customer_user):
+    url = reverse('dashboard:customer-delete', args=[admin_user.pk])
+    response = admin_client.post(url, data={'csrf': 'exampledata'})
+    assert response.status_code == 400
+
+    url = reverse('dashboard:customer-delete', args=[customer_user.pk])
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(url, data={'csrf': 'exampledata'})
+    assert not User.objects.filter(pk=customer_user.pk).exists()
+    assert response.status_code == 302
+
+
+def test_form_delete_customer(
+        staff_user, customer_user, admin_user, permission_edit_staff,
+        staff_group):
+    data = {'csrf': 'example-data'}
+    form = CustomerDeleteForm(data, instance=customer_user, user=staff_user)
+    assert form.is_valid()
+
+    # Deleting your own account is not allowed
+    form = CustomerDeleteForm(data, instance=staff_user, user=staff_user)
+    assert not form.is_valid()
+
+    # Deleting a superuser is not allowed
+    form = CustomerDeleteForm(data, instance=admin_user, user=staff_user)
+    assert not form.is_valid()
+
+    # Deleting another staff is not allowed without relevant permission
+    another_staff_user = User.objects.create(is_staff=True, email='exa@mp.le')
+    form = CustomerDeleteForm(
+        data, instance=another_staff_user, user=staff_user)
+    assert not form.is_valid()
+
+    staff_group.permissions.add(permission_edit_staff)
+    staff_user.groups.add(staff_group)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    form = CustomerDeleteForm({}, instance=another_staff_user, user=staff_user)
+    assert form.is_valid()
diff --git a/tests/dashboard/test_permissions.py b/tests/dashboard/test_permissions.py
index f1b8233c6ad..2161e5c2ff7 100644
--- a/tests/dashboard/test_permissions.py
+++ b/tests/dashboard/test_permissions.py
@@ -1273,3 +1273,8 @@ def test_staff_group_member_can_edit_menu_items(
     assert response.status_code == 200
     response = staff_client.get(menu_item_delete_url)
     assert response.status_code == 200
+
+
+def test_staff_group_member_can_remove_user(
+        staff_client, staff_user, staff_group, permission_edit_user):
+    pass
diff --git a/tests/test_account.py b/tests/test_account.py
index c5e7fe064ab..766ed6cb941 100644
--- a/tests/test_account.py
+++ b/tests/test_account.py
@@ -1,20 +1,22 @@
 import json
 import os
+import uuid
 from unittest.mock import patch
 from urllib.parse import urlencode
 
-import i18naddress
 import pytest
+
+import i18naddress
 from captcha import constants as recaptcha_constants
-from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.forms import Form
 from django.http import QueryDict
 from django.template import Context, Template
 from django.urls import reverse
 from django_countries.fields import Country
-
 from saleor.account import forms, i18n
+from saleor.account.forms import FormWithReCaptcha
+from saleor.account.models import User
 from saleor.account.templatetags.i18n_address_tags import format_address
 from saleor.account.validators import validate_possible_number
 
@@ -229,8 +231,6 @@ def test_disabled_recaptcha():
     """
     This test creates a new form that should not contain any recaptcha field.
     """
-    from saleor.account.forms import FormWithReCaptcha
-
     class TestForm(Form, FormWithReCaptcha):
         pass
 
@@ -239,16 +239,13 @@ class TestForm(Form, FormWithReCaptcha):
 
 
 @patch.dict(os.environ, {'RECAPTCHA_TESTING': 'True'})
-@patch.object(
-    settings, 'RECAPTCHA_PUBLIC_KEY', recaptcha_constants.TEST_PUBLIC_KEY)
-@patch.object(
-    settings, 'RECAPTCHA_PRIVATE_KEY', recaptcha_constants.TEST_PRIVATE_KEY)
-def test_requires_recaptcha():
+def test_requires_recaptcha(settings):
     """
     This test creates a new form
     that should contain a (required) recaptcha field.
     """
-    from saleor.account.forms import FormWithReCaptcha
+    settings.RECAPTCHA_PUBLIC_KEY = recaptcha_constants.TEST_PUBLIC_KEY
+    settings.RECAPTCHA_PRIVATE_KEY = recaptcha_constants.TEST_PRIVATE_KEY
 
     class TestForm(Form, FormWithReCaptcha):
         pass
@@ -258,3 +255,43 @@ class TestForm(Form, FormWithReCaptcha):
 
     form = TestForm({'g-recaptcha-response': 'PASSED'})
     assert form.is_valid()
+
+
+def test_view_account_delete_login_required(customer_user, client):
+    url = reverse('account:delete', args=[444])
+    response = client.post(url)
+    assert response.status_code == 302
+
+
+def test_view_account_post_required(customer_user, authorized_client):
+    url = reverse('account:delete', args=[444])
+    response = authorized_client.get(url)
+    assert response.status_code == 405
+
+
+@patch('saleor.account.views.send_account_delete_confirmation_email.delay')
+def test_view_account_delete(
+        send_confirmation_mock, customer_user, authorized_client, staff_user):
+    # User requests account delete for another user
+    url = reverse('account:delete', args=[staff_user.pk])
+    response = authorized_client.post(url)
+    assert response.status_code == 404
+
+    url = reverse('account:delete', args=[customer_user.pk])
+    response = authorized_client.post(url)
+    assert response.status_code == 302
+    send_confirmation_mock.assert_called_once_with(
+        str(customer_user.token), customer_user.email)
+
+
+def test_view_account_delete_confirm(customer_user, authorized_client):
+    # Non existing token
+    url = reverse('account:delete-confirm', args=[str(uuid.uuid4())])
+    response = authorized_client.get(url)
+    assert response.status_code == 404
+
+    url = reverse('account:delete-confirm', args=[customer_user.token])
+    response = authorized_client.get(url)
+    assert response.status_code == 200
+    customer_user = User.objects.filter(pk=customer_user.pk).first()
+    assert customer_user is None


Code review comments:
diff_hunk: @@ -0,0 +1,24 @@
+{% extends 'base.html' %}
+
+{% load bootstrap_form from bootstrap4 %}
+{% load staticfiles %}
+{% load i18n %}
+{% load placeholder %}
+
+{% block title %}{% trans "Account deleted" context "Account deleted page title" %} — {{ block.super }}{% endblock %}
+
+{% block content %}
+<div class="row account__deleted">
+  <div class="col-xl-10 m-auto">
+    <img class="lazyload lazypreload"
+        data-src="{% static 'images/login-bg.png' %}"
+        data-srcset="{% static 'images/login-bg.png' %} 1x, {% static 'images/login-bg2x.png' %} 2x">
+    <h1>{% trans "Sorry to see you go!" context "Message displayed to the user after his account was deleted" %}</h1>
+    <p>{% blocktrans trimmed context "Message displayed to the user after his account was deleted" %}
+        Your account was deleted successfully.
+        If any problems or questions contact us.
path: templates/account/account_delete.html
text: @user1:
Maybe `In case of any problems...`? Sounds more natural to me.

@author:
Right, current version is rather informal, let's change it 

@user2:
Could also say `In case of trouble`

diff_hunk: @@ -0,0 +1,34 @@
+GDPR Compilance
+===============
+
+Saleor handles few aspects of the GDPR regulation by default
+
+Deleting users
+--------------
+
+A user account can be deleted from the dashboard, by a staff user.
+This action takes place immediately.
+
+From the storefront, a user can request his account deletion
+from within his profile settings, in such case, a confirmation email will be
+sent to the email address associated with the account.
+
+Deleting a user will delete his account instance, but will leave all the data
+used for the checkout process untouched, for the financial record.
+This behavior in accordance with the GDPR regulations.
path: docs/architecture/gdpr.rst
text: @user1:
Missed `is` before `in`?