Repository: saleor/saleor

Issue:
Missing is_active and is_staff in Staff mutations
There should be a possibility to set is_staff and is_active in StaffUser mutations


Discussion:

Resolving patch:
diff --git a/saleor/dashboard/staff/utils.py b/saleor/dashboard/staff/utils.py
--- a/saleor/dashboard/staff/utils.py
+++ b/saleor/dashboard/staff/utils.py
@@ -5,6 +5,7 @@ def remove_staff_member(staff):
     """
     if staff.orders.exists():
         staff.is_staff = False
+        staff.user_permissions.clear()
         staff.save()
     else:
         staff.delete()
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
@@ -5,9 +5,11 @@
 from django.utils.encoding import force_bytes
 from django.utils.http import urlsafe_base64_encode
 from graphql_jwt.decorators import permission_required
+from graphql_jwt.exceptions import PermissionDenied
 
 from ...account import emails, models
 from ...core.permissions import MODELS_PERMISSIONS, get_permissions
+from ...dashboard.staff.utils import remove_staff_member
 from ..account.types import AddressInput, User
 from ..core.mutations import BaseMutation, ModelDeleteMutation, ModelMutation
 from ..core.types.common import Error
@@ -51,6 +53,8 @@ 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.')
@@ -140,6 +144,56 @@ class Meta:
         model = models.User
 
 
+class StaffDelete(ModelMutation):
+    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 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)
+
+    @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.')
+        return errors
+
+
 class StaffCreate(ModelMutation):
     class Arguments:
         input = StaffCreateInput(
@@ -198,6 +252,29 @@ class Meta:
         exclude = ['password']
         model = models.User
 
+    @classmethod
+    def clean_is_active(cls, is_active, instance, user, errors):
+        if is_active is None:
+            return errors
+
+        if not is_active:
+            if user == instance:
+                cls.add_error(
+                    errors, 'is_active', 'Cannot deactivate your own account.')
+            elif instance.is_superuser:
+                cls.add_error(
+                    errors, 'is_active',
+                    'Cannot deactivate superuser\'s account.')
+        return errors
+
+    @classmethod
+    def clean_input(cls, info, instance, input, errors):
+        cleaned_input = super().clean_input(info, instance, input, errors)
+        cls.clean_is_active(
+            cleaned_input.get('is_active'), instance, info.context.user,
+            errors)
+        return cleaned_input
+
 
 class SetPasswordInput(graphene.InputObjectType):
     token = graphene.String(
diff --git a/saleor/graphql/api.py b/saleor/graphql/api.py
--- a/saleor/graphql/api.py
+++ b/saleor/graphql/api.py
@@ -5,7 +5,7 @@
 
 from .account.mutations import (
     CustomerCreate, CustomerUpdate, PasswordReset, SetPassword, StaffCreate,
-    StaffUpdate, AddressCreate, AddressUpdate, AddressDelete)
+    StaffDelete, StaffUpdate, AddressCreate, AddressUpdate, AddressDelete)
 from .account.types import AddressValidationData, AddressValidationInput, User
 from .account.resolvers import (
     resolve_address_validator, resolve_customers, resolve_staff_users)
@@ -290,6 +290,7 @@ class Mutations(graphene.ObjectType):
 
     staff_create = StaffCreate.Field()
     staff_update = StaffUpdate.Field()
+    staff_delete = StaffDelete.Field()
 
     address_create = AddressCreate.Field()
     address_update = AddressUpdate.Field()


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
@@ -1,17 +1,18 @@
 import json
 import re
-from unittest.mock import patch, Mock
+from unittest.mock import Mock, patch
 
-import graphene
 import pytest
+
+import graphene
 from django.contrib.auth import get_user_model
 from django.contrib.auth.tokens import default_token_generator
 from django.shortcuts import reverse
+from saleor.account.models import Address, User
+from saleor.graphql.account.mutations import (
+    SetPassword, StaffDelete, StaffUpdate)
 from tests.utils import get_graphql_content
 
-from saleor.account.models import Address
-from saleor.graphql.account.mutations import SetPassword
-
 from .utils import assert_no_permission, convert_dict_keys_to_camel_case
 
 
@@ -449,8 +450,11 @@ def test_staff_create(
 
 def test_staff_update(admin_api_client, staff_user, user_api_client):
     query = """
-    mutation UpdateStaff($id: ID!, $permissions: [String]) {
-        staffUpdate(id: $id, input: {permissions: $permissions}) {
+    mutation UpdateStaff(
+            $id: ID!, $permissions: [String], $is_active: Boolean) {
+        staffUpdate(
+                id: $id,
+                input: {permissions: $permissions, isActive: $is_active}) {
             errors {
                 field
                 message
@@ -459,12 +463,13 @@ def test_staff_update(admin_api_client, staff_user, user_api_client):
                 permissions {
                     code
                 }
+                isActive
             }
         }
     }
     """
     id = graphene.Node.to_global_id('User', staff_user.id)
-    variables = json.dumps({'id': id, 'permissions': []})
+    variables = json.dumps({'id': id, 'permissions': [], 'is_active': False})
 
     # check unauthorized access
     response = user_api_client.post(
@@ -478,6 +483,72 @@ def test_staff_update(admin_api_client, staff_user, user_api_client):
     data = content['data']['staffUpdate']
     assert data['errors'] == []
     assert data['user']['permissions'] == []
+    assert data['user']['isActive'] == False
+
+
+def test_staff_delete(admin_api_client, staff_user, user_api_client):
+    query = """
+        mutation DeleteStaff($id: ID!) {
+            staffDelete(id: $id) {
+                errors {
+                    field
+                    message
+                }
+                user {
+                    id
+                }
+            }
+        }
+    """
+    user_id = graphene.Node.to_global_id('User', staff_user.id)
+    variables = json.dumps({'id': user_id})
+
+    # check unauthorized access
+    response = user_api_client.post(
+        reverse('api'), {'query': query, 'variables': variables})
+    assert_no_permission(response)
+
+    response = admin_api_client.post(
+        reverse('api'), {'query': query, 'variables': variables})
+    content = get_graphql_content(response)
+    assert 'errors' not in content
+    data = content['data']['staffDelete']
+    assert data['errors'] == []
+    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, [])
+    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.')
+
+    errors = StaffDelete.clean_user(admin_user, staff_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, [])
+    assert not errors
+
+
+def test_staff_update_errors(staff_user, customer_user, admin_user):
+    errors = StaffUpdate.clean_is_active(None, customer_user, staff_user, [])
+    assert not errors
+
+    errors = StaffUpdate.clean_is_active(False, staff_user, staff_user, [])
+    assert errors[0].field == 'isActive'
+    assert errors[0].message == 'Cannot deactivate your own account.'
+
+    errors = StaffUpdate.clean_is_active(False, admin_user, staff_user, [])
+    assert errors[0].field == 'isActive'
+    assert errors[0].message == 'Cannot deactivate superuser\'s account.'
+
+    errors = StaffUpdate.clean_is_active(False, customer_user, staff_user, [])
+    assert not errors
 
 
 def test_set_password(user_api_client, customer_user):
diff --git a/tests/dashboard/test_staff.py b/tests/dashboard/test_staff.py
--- a/tests/dashboard/test_staff.py
+++ b/tests/dashboard/test_staff.py
@@ -9,9 +9,27 @@
 from saleor.account.models import User
 from saleor.core.utils import build_absolute_uri
 from saleor.dashboard.staff.forms import StaffForm
+from saleor.dashboard.staff.utils import remove_staff_member
 from saleor.settings import DEFAULT_FROM_EMAIL
 
 
+def test_remove_staff_member_with_orders(
+        staff_user, permission_manage_products, order):
+    order.user = staff_user
+    order.save()
+    staff_user.user_permissions.add(permission_manage_products)
+
+    remove_staff_member(staff_user)
+    staff_user = User.objects.get(pk=staff_user.pk)
+    assert not staff_user.is_staff
+    assert not staff_user.user_permissions.exists()
+
+
+def test_remove_staff_member(staff_user):
+    remove_staff_member(staff_user)
+    assert not User.objects.filter(pk=staff_user.pk).exists()
+
+
 def test_staff_form_not_valid(db):
     data = {'user_permissions': 1}
     form = StaffForm(data=data)