Repository: saleor/saleor

Issue:
API is unable to remove customer
### What I'm trying to achieve
To remove customers account

### Describe a proposed solution
Add `customerRemove(id: ID!)` mutation.


Discussion:
Blocks #2598 

Resolving patch:
diff --git a/saleor/graphql/account/mutations.py b/saleor/graphql/account/mutations.py
--- a/saleor/graphql/account/mutations.py
+++ b/saleor/graphql/account/mutations.py
@@ -56,6 +56,8 @@ def save(cls, info, user, cleaned_input):
 class UserInput(graphene.InputObjectType):
     email = graphene.String(
         description='The unique email address of the user.')
+    is_active = graphene.Boolean(
+        required=False, description='User account is active.')
     note = graphene.String(description='A note about the user.')
 
 
@@ -72,8 +74,6 @@ class UserCreateInput(CustomerInput):
 
 
 class StaffInput(UserInput):
-    is_active = graphene.Boolean(
-        required=False, description='User account is activated.')
     permissions = graphene.List(
         graphene.String,
         description='List of permission code names to assign to this user.')
@@ -162,53 +162,40 @@ class Meta:
         model = models.User
 
 
-class StaffDelete(ModelMutation):
+class UserDelete(ModelDeleteMutation):
     class Meta:
-        description = 'Deletes a staff user.'
+        abstract = True
+
+    @classmethod
+    def clean_instance(cls, info, instance, errors):
+        user = info.context.user
+        if instance == user:
+            cls.add_error(
+                errors, 'id', 'You cannot delete your own account.')
+        elif instance.is_superuser:
+            cls.add_error(
+                errors, 'id', 'Only superuser can delete his own account.')
+        return errors
+
+
+class CustomerDelete(UserDelete):
+    class Meta:
+        description = 'Deletes a customer.'
         model = models.User
 
     class Arguments:
         id = graphene.ID(
-            required=True, description='ID of a staff user to delete.')
+            required=True, description='ID of a customer to delete.')
 
     @classmethod
     def user_is_allowed(cls, user, input):
-        return user.has_perm('account.manage_staff')
-
-    @classmethod
-    def mutate(cls, root, info, **data):
-        if not cls.user_is_allowed(info.context.user, data):
-            raise PermissionDenied()
-
-        errors = []
-        user_id = data.get('id')
-        instance = cls.get_node_or_error(info, user_id, errors, 'id', User)
-        cls.clean_user(instance, info.context.user, errors)
-        if errors:
-            return cls(errors=errors)
-
-        db_id = instance.id
-        remove_staff_member(instance)
-        # After the instance is deleted, set its ID to the original database's
-        # ID so that the success response contains ID of the deleted object.
-        instance.id = db_id
-        return cls.success_response(instance)
+        return user.has_perm('account.manage_users')
 
     @classmethod
-    def clean_user(cls, instance, user, errors):
-        if not instance:
-            return
-        if not instance.is_staff:
-            cls.add_error(
-                errors, 'id',
-                'Only staff users can be deleted with this mutation.')
-        elif instance == user:
-            cls.add_error(
-                errors, 'id',
-                'You cannot delete your own account via dashboard.')
-        elif instance.is_superuser:
-            cls.add_error(
-                errors, 'id', 'Only superuser can delete his own account.')
+    def clean_instance(cls, info, instance, errors):
+        super().clean_instance(info, instance, errors)
+        if instance.is_staff:
+            cls.add_error(errors, 'id', 'Cannot delete a staff account.')
         return errors
 
 
@@ -294,6 +281,48 @@ def clean_input(cls, info, instance, input, errors):
         return cleaned_input
 
 
+class StaffDelete(UserDelete):
+    class Meta:
+        description = 'Deletes a staff user.'
+        model = models.User
+
+    class Arguments:
+        id = graphene.ID(
+            required=True, description='ID of a staff user to delete.')
+
+    @classmethod
+    def user_is_allowed(cls, user, input):
+        return user.has_perm('account.manage_staff')
+
+    @classmethod
+    def clean_instance(cls, info, instance, errors):
+        super().clean_instance(info, instance, errors)
+        if not instance.is_staff:
+            cls.add_error(
+                errors, 'id', 'Cannot delete a non-staff user.')
+        return errors
+
+    @classmethod
+    def mutate(cls, root, info, **data):
+        if not cls.user_is_allowed(info.context.user, data):
+            raise PermissionDenied()
+
+        errors = []
+        user_id = data.get('id')
+        instance = cls.get_node_or_error(info, user_id, errors, 'id', User)
+        if instance:
+            cls.clean_instance(info, instance, errors)
+        if errors:
+            return cls(errors=errors)
+
+        db_id = instance.id
+        remove_staff_member(instance)
+        # After the instance is deleted, set its ID to the original database's
+        # ID so that the success response contains ID of the deleted object.
+        instance.id = db_id
+        return cls.success_response(instance)
+
+
 class SetPasswordInput(graphene.InputObjectType):
     token = graphene.String(
         description='A one-time token required to set the password.',
diff --git a/saleor/graphql/api.py b/saleor/graphql/api.py
--- a/saleor/graphql/api.py
+++ b/saleor/graphql/api.py
@@ -5,9 +5,9 @@
 from graphql_jwt.decorators import login_required, permission_required
 
 from .account.mutations import (
-    CustomerCreate, CustomerUpdate, CustomerPasswordReset, CustomerRegister,
-    PasswordReset, SetPassword, StaffCreate, StaffDelete, StaffUpdate,
-    AddressCreate, AddressUpdate, AddressDelete)
+    CustomerCreate, CustomerDelete, CustomerUpdate, CustomerPasswordReset,
+    CustomerRegister, PasswordReset, SetPassword, StaffCreate, StaffDelete,
+    StaffUpdate, AddressCreate, AddressUpdate, AddressDelete)
 from .account.types import AddressValidationData, AddressValidationInput, User
 from .account.resolvers import (
     resolve_address_validator, resolve_customers, resolve_staff_users)
@@ -220,6 +220,7 @@ class Mutations(ProductMutations):
 
     customer_create = CustomerCreate.Field()
     customer_update = CustomerUpdate.Field()
+    customer_delete = CustomerDelete.Field()
     customer_password_reset = CustomerPasswordReset.Field()
     customer_register = CustomerRegister.Field()
 
diff --git a/saleor/graphql/core/mutations.py b/saleor/graphql/core/mutations.py
--- a/saleor/graphql/core/mutations.py
+++ b/saleor/graphql/core/mutations.py
@@ -297,6 +297,15 @@ class ModelDeleteMutation(ModelMutation):
     class Meta:
         abstract = True
 
+    @classmethod
+    def clean_instance(cls, info, instance, errors):
+        """Perform additional logic before deleting the model instance.
+
+        Override this method to raise custom validation error and abort
+        the deletion process.
+        """
+        pass
+
     @classmethod
     def mutate(cls, root, info, **data):
         """Perform a mutation that deletes a model instance."""
@@ -309,6 +318,9 @@ def mutate(cls, root, info, **data):
         instance = cls.get_node_or_error(
             info, node_id, errors, 'id', model_type)
 
+        if instance:
+            cls.clean_instance(info, instance, errors)
+
         if errors:
             return cls(errors=errors)
 


Test patch:
diff --git a/tests/api/test_account.py b/tests/api/test_account.py
--- a/tests/api/test_account.py
+++ b/tests/api/test_account.py
@@ -10,7 +10,7 @@
 from django.shortcuts import reverse
 from saleor.account.models import Address, User
 from saleor.graphql.account.mutations import (
-    SetPassword, StaffDelete, StaffUpdate)
+    CustomerDelete, SetPassword, StaffDelete, StaffUpdate, UserDelete)
 from tests.api.utils import get_graphql_content
 
 from .utils import assert_no_permission, convert_dict_keys_to_camel_case
@@ -343,11 +343,11 @@ def test_customer_create(
 
 
 def test_customer_update(
-        staff_api_client, customer_user, user_api_client, address,
-        permission_manage_users):
+        staff_api_client, customer_user, address, permission_manage_users):
     query = """
-    mutation UpdateCustomer($id: ID!, $note: String, $billing: AddressInput, $shipping: AddressInput) {
+    mutation UpdateCustomer($id: ID!, $isActive: Boolean, $note: String, $billing: AddressInput, $shipping: AddressInput) {
         customerUpdate(id: $id, input: {
+            isActive: $isActive,
             note: $note,
             defaultBillingAddress: $billing
             defaultShippingAddress: $shipping
@@ -358,13 +358,14 @@ def test_customer_update(
             }
             user {
                 id
-                note
                 defaultBillingAddress {
                     id
                 }
                 defaultShippingAddress {
                     id
                 }
+                isActive
+                note
             }
         }
     }
@@ -385,9 +386,7 @@ def test_customer_update(
     address_data['streetAddress1'] = new_street_address
 
     variables = {
-        'id': id,
-        'note': note,
-        'billing': address_data,
+        'id': id, 'isActive': False, 'note': note, 'billing': address_data,
         'shipping': address_data}
     response = staff_api_client.post_graphql(
         query, variables, permissions=[permission_manage_users])
@@ -406,6 +405,41 @@ def test_customer_update(
     data = content['data']['customerUpdate']
     assert data['errors'] == []
     assert data['user']['note'] == note
+    assert not data['user']['isActive']
+
+
+def test_customer_delete(staff_api_client, customer_user, permission_manage_users):
+    query = """
+    mutation CustomerDelete($id: ID!) {
+        customerDelete(id: $id){
+            errors {
+                field
+                message
+            }
+            user {
+                id
+            }
+        }
+    }
+    """
+    customer_id = graphene.Node.to_global_id('User', customer_user.pk)
+    variables = {'id': customer_id}
+    response = staff_api_client.post_graphql(
+        query, variables, permissions=[permission_manage_users])
+    content = get_graphql_content(response)
+    data = content['data']['customerDelete']
+    assert data['errors'] == []
+    assert data['user']['id'] == customer_id
+
+
+def test_customer_delete_errors(customer_user, admin_user, staff_user):
+    info = Mock(context=Mock(user=admin_user))
+    errors = CustomerDelete.clean_instance(info, staff_user, [])
+    assert errors[0].field == 'id'
+    assert errors[0].message == 'Cannot delete a staff account.'
+
+    errors = CustomerDelete.clean_instance(info, customer_user, [])
+    assert errors == []
 
 
 @patch('saleor.account.emails.send_password_reset_email.delay')
@@ -522,20 +556,26 @@ def test_staff_delete(staff_api_client, permission_manage_staff):
     assert not User.objects.filter(pk=staff_user.id).exists()
 
 
-def test_staff_delete_errors(staff_user, customer_user, admin_user):
-    errors = StaffDelete.clean_user(customer_user, staff_user, [])
+def test_user_delete_errors(staff_user, customer_user, admin_user):
+    info = Mock(context=Mock(user=staff_user))
+    errors = UserDelete.clean_instance(info, staff_user, [])
     assert errors[0].field == 'id'
-    assert errors[0].message == (
-        'Only staff users can be deleted with this mutation.')
-    errors = StaffDelete.clean_user(staff_user, staff_user, [])
-    assert errors[0].field == 'id'
-    assert errors[0].message == (
-        'You cannot delete your own account via dashboard.')
+    assert errors[0].message == 'You cannot delete your own account.'
 
-    errors = StaffDelete.clean_user(admin_user, staff_user, [])
+    info = Mock(context=Mock(user=staff_user))
+    errors = UserDelete.clean_instance(info, admin_user, [])
     assert errors[0].field == 'id'
     assert errors[0].message == 'Only superuser can delete his own account.'
-    errors = StaffDelete.clean_user(staff_user, admin_user, [])
+
+
+def test_staff_delete_errors(staff_user, customer_user, admin_user):
+    info = Mock(context=Mock(user=staff_user))
+    errors = StaffDelete.clean_instance(info, customer_user, [])
+    assert errors[0].field == 'id'
+    assert errors[0].message == 'Cannot delete a non-staff user.'
+
+    info = Mock(context=Mock(user=admin_user))
+    errors = StaffDelete.clean_instance(info, staff_user, [])
     assert not errors