Skip to content

Commit adc2475

Browse files
authored
feat: OPTIC-116: User soft-deletion API (#4876)
* feat: OPTIC-116: User soft-deletion API * Add new HasOwnerPermission to check owner for deletion, add soft_delete function * Update tests * Add linting changes * Add permission_required to view * Linting fix * Updates to fix soft delete and add typing * Add newlines for test legibility * Update status code, update typing error * Add new url to all_urls.json --------- Co-authored-by: dredivaris <dredivaris@users.noreply.github.com>
1 parent d592c79 commit adc2475

File tree

7 files changed

+198
-6
lines changed

7 files changed

+198
-6
lines changed

label_studio/core/all_urls.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,13 @@
491491
"name": "current-user-whoami",
492492
"decorators": ""
493493
},
494+
{
495+
"url": "/users/<int:pk>/soft-delete/",
496+
"module": "users.views.UserSoftDeleteView",
497+
"name": "user-soft-delete",
498+
"decorators": ""
499+
},
500+
494501
{
495502
"url": "/api/tasks/",
496503
"module": "tasks.api.TaskListAPI",

label_studio/core/api_permissions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,10 @@
44
class HasObjectPermission(BasePermission):
55
def has_object_permission(self, request, view, obj):
66
return obj.has_permission(request.user)
7+
8+
9+
class HasOwnerPermission(BasePermission):
10+
def has_object_permission(self, request, view, obj):
11+
if not request.user.own_organization or obj.active_organization != request.user.active_organization:
12+
return False
13+
return obj.has_permission(request.user)

label_studio/core/settings/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,7 @@
528528
ANNOTATION_MIXIN = 'tasks.mixins.AnnotationMixin'
529529
ORGANIZATION_MIXIN = 'organizations.mixins.OrganizationMixin'
530530
USER_MIXIN = 'users.mixins.UserMixin'
531+
USER_PERM = 'core.api_permissions.HasOwnerPermission'
531532
RECALCULATE_ALL_STATS = None
532533
GET_STORAGE_LIST = 'io_storages.functions.get_storage_list'
533534
STORAGE_ANNOTATION_SERIALIZER = 'io_storages.serializers.StorageAnnotationSerializer'

label_studio/tests/users.tavern.yml

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@ marks:
77
stages:
88
- id: signup
99
type: ref
10+
1011
- id: get_my_user
1112
type: ref
13+
1214
- name: stage
1315
request:
1416
method: GET
1517
url: '{django_live_url}/api/users'
1618
response:
1719
status_code: 200
20+
1821
- name: stage
1922
request:
2023
method: GET
@@ -24,6 +27,7 @@ stages:
2427
save:
2528
json:
2629
org_pk: active_organization
30+
2731
- name: stage
2832
request:
2933
json:
@@ -37,12 +41,14 @@ stages:
3741
json:
3842
new_user_pk: id
3943
status_code: 201
44+
4045
- name: stage
4146
request:
4247
method: GET
4348
url: '{django_live_url}/api/users/{new_user_pk}'
4449
response:
4550
status_code: 200
51+
4652
- name: attempt_update_email_raises_405
4753
request:
4854
url: "{django_live_url}/api/users/{new_user_pk}"
@@ -72,6 +78,7 @@ stages:
7278
status_code: 302
7379
headers:
7480
location: "/projects/" # redirect to projects page rather than malicious.com
81+
7582
- name: malicious_login
7683
request:
7784
url: "{django_live_url}/user/login?next=http://malicious.com"
@@ -83,3 +90,124 @@ stages:
8390
status_code: 302
8491
headers:
8592
location: "/projects/" # redirect to projects page rather than malicious.com
93+
94+
---
95+
test_name: test_users_soft_delete
96+
strict: false
97+
marks:
98+
- usefixtures:
99+
- django_live_url
100+
stages:
101+
- id: signup
102+
type: ref
103+
104+
- id: get_my_user
105+
type: ref
106+
107+
- name: get_active_organization
108+
request:
109+
method: GET
110+
url: '{django_live_url}/api/users/{user_pk}'
111+
response:
112+
status_code: 200
113+
save:
114+
json:
115+
org_pk: active_organization
116+
117+
- type: ref
118+
id: get_invite_url
119+
120+
- type: ref
121+
id: logout
122+
123+
# signup 2 new users
124+
- name: signup_new_user_under_active_organization
125+
request:
126+
url: "{django_live_url}{invite_url}"
127+
data:
128+
email: test_user@heartextest.com
129+
password: 12345678
130+
method: POST
131+
response:
132+
status_code: 302
133+
134+
- type: ref
135+
id: logout
136+
137+
- name: signup_second_new_user_under_active_organization
138+
request:
139+
url: "{django_live_url}{invite_url}"
140+
data:
141+
email: test_second_user@heartextest.com
142+
password: 12345678
143+
method: POST
144+
response:
145+
status_code: 302
146+
147+
- id: get_my_user # get user_pk to use in soft-delete
148+
type: ref
149+
150+
- id: get_user_token
151+
type: ref
152+
153+
- id: logout
154+
type: ref
155+
156+
- id: get_my_user_with_token
157+
name: Get my user with token
158+
request:
159+
headers:
160+
authorization: "Token {user_token}"
161+
url: "{django_live_url}/api/current-user/whoami"
162+
method: GET
163+
response:
164+
status_code: 200
165+
166+
- name: login_as_first_new_user
167+
request:
168+
url: "{django_live_url}/user/login"
169+
data:
170+
email: test_user@heartextest.com
171+
password: 12345678
172+
method: POST
173+
response:
174+
status_code: 302
175+
176+
- name: soft_delete_user_fails_without_owner_logged_in
177+
request:
178+
url: "{django_live_url}/users/{user_pk}/soft-delete"
179+
method: DELETE
180+
response:
181+
status_code: 403
182+
183+
- id: logout
184+
type: ref
185+
186+
- id: login # as owner
187+
type: ref
188+
189+
- name: soft_delete_user_succeeds_as_owner
190+
request:
191+
url: "{django_live_url}/users/{user_pk}/soft-delete"
192+
method: DELETE
193+
response:
194+
status_code: 204
195+
196+
- name: soft_delete_user_fails_second_deletion_attempt
197+
request:
198+
url: "{django_live_url}/users/{user_pk}/soft-delete"
199+
method: DELETE
200+
response:
201+
status_code: 404
202+
203+
- id: logout
204+
type: ref
205+
206+
- name: get_my_user_with_token_fails_as_user_is_now_deleted
207+
request:
208+
headers:
209+
authorization: "Token {user_token}"
210+
url: "{django_live_url}/api/current-user/whoami"
211+
method: GET
212+
response:
213+
status_code: 401

label_studio/users/models.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""This file and its contents are licensed under the Apache License 2.0. Please see the included NOTICE for copyright information and LICENSE for a copy of the license.
22
"""
33
import datetime
4+
from typing import Optional
45

56
from core.feature_flags import flag_set
67
from core.utils.common import load_func
8+
from core.utils.db import fast_first
79
from django.conf import settings
810
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
911
from django.contrib.auth.models import PermissionsMixin
@@ -164,8 +166,8 @@ def active_organization_contributed_project_number(self):
164166
return annotations.values_list('project').distinct().count()
165167

166168
@property
167-
def own_organization(self):
168-
return Organization.objects.get(created_by=self)
169+
def own_organization(self) -> Optional[Organization]:
170+
return fast_first(Organization.objects.filter(created_by=self))
169171

170172
@property
171173
def has_organization(self):
@@ -193,12 +195,15 @@ def get_short_name(self):
193195
"""Return the short name for the user."""
194196
return self.first_name
195197

196-
def reset_token(self):
197-
token = Token.objects.filter(user=self)
198-
if token.exists():
199-
token.delete()
198+
def reset_token(self) -> Token:
199+
Token.objects.filter(user=self).delete()
200200
return Token.objects.create(user=self)
201201

202+
def soft_delete(self) -> None:
203+
self.is_deleted = True
204+
Token.objects.filter(user=self).delete()
205+
self.save(update_fields=['is_deleted'])
206+
202207
def get_initials(self):
203208
initials = '?'
204209

label_studio/users/urls.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django.views.static import serve
99
from rest_framework import routers
1010
from users import api, views
11+
from users.views import UserSoftDeleteView
1112

1213
router = routers.DefaultRouter()
1314
router.register(r'users', api.UserAPI, basename='user')
@@ -23,6 +24,8 @@
2324
path('api/current-user/reset-token/', api.UserResetTokenAPI.as_view(), name='current-user-reset-token'),
2425
path('api/current-user/token', api.UserGetTokenAPI.as_view(), name='current-user-token'),
2526
path('api/current-user/whoami', api.UserWhoAmIAPI.as_view(), name='current-user-whoami'),
27+
# additional user actions
28+
path('users/<int:pk>/soft-delete/', UserSoftDeleteView.as_view(), name='user-soft-delete'),
2629
]
2730

2831
# When CLOUD_FILE_STORAGE_ENABLED is set, avatars are uploaded to cloud storage with a different URL pattern.

label_studio/users/views.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
11
"""This file and its contents are licensed under the Apache License 2.0. Please see the included NOTICE for copyright information and LICENSE for a copy of the license.
22
"""
33
import logging
4+
from typing import Any
45

56
from core.feature_flags import flag_set
67
from core.middleware import enforce_csrf_checks
8+
from core.permissions import ViewClassPermission, all_permissions
79
from core.utils.common import load_func
810
from django.conf import settings
911
from django.contrib import auth
1012
from django.contrib.auth.decorators import login_required
1113
from django.core.exceptions import PermissionDenied
14+
from django.http import Http404
1215
from django.shortcuts import redirect, render, reverse
1316
from django.utils.http import is_safe_url
1417
from organizations.forms import OrganizationSignupForm
1518
from organizations.models import Organization
19+
from rest_framework import generics, status
1620
from rest_framework.authtoken.models import Token
21+
from rest_framework.exceptions import MethodNotAllowed
22+
from rest_framework.permissions import IsAuthenticated
23+
from rest_framework.request import Request
24+
from rest_framework.response import Response
1725
from users import forms
1826
from users.functions import login, proceed_registration
27+
from users.models import User
28+
from users.serializers import UserSerializer
29+
30+
HasObjectPermission = load_func(settings.USER_PERM)
1931

2032
logger = logging.getLogger()
2133

@@ -149,3 +161,32 @@ def user_account(request):
149161
'users/user_account.html',
150162
{'settings': settings, 'user': user, 'user_profile_form': form, 'token': token},
151163
)
164+
165+
166+
class UserSoftDeleteView(generics.RetrieveDestroyAPIView):
167+
permission_required = ViewClassPermission(
168+
DELETE=all_permissions.organizations_change,
169+
)
170+
queryset = User.objects.all()
171+
serializer_class = UserSerializer
172+
permission_classes = (IsAuthenticated, HasObjectPermission)
173+
174+
def get_object(self) -> User:
175+
pk = self.kwargs[self.lookup_field]
176+
# only fetch & delete user if they are in the same organization as the calling user
177+
try:
178+
user = self.queryset.filter(active_organization=self.request.user.active_organization).get(pk=pk)
179+
except User.DoesNotExist:
180+
raise Http404('User could not be found in organization')
181+
182+
return user
183+
184+
def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response:
185+
user = self.get_object()
186+
187+
self.check_object_permissions(self.request, user)
188+
if self.kwargs[self.lookup_field] == self.request.user.pk:
189+
raise MethodNotAllowed('User cannot delete self')
190+
191+
user.soft_delete()
192+
return Response(status=status.HTTP_204_NO_CONTENT)

0 commit comments

Comments
 (0)