Skip to content

Commit b7601e5

Browse files
authored
Added user deletion manager that removes user together with all data (#9686)
1 parent eed6960 commit b7601e5

File tree

3 files changed

+150
-0
lines changed

3 files changed

+150
-0
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
### Added
2+
3+
- Django command to remove user with all resources `python manage.py deleteuser <user_id>`
4+
(<https://github.com/cvat-ai/cvat/pull/9686>)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Copyright (C) CVAT.ai Corporation
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
from django.core.management.base import BaseCommand, CommandError
6+
from rest_framework.exceptions import ValidationError
7+
8+
from cvat.apps.engine.models import User
9+
from cvat.apps.engine.user_deletion import delete_user_with_cleanup
10+
11+
12+
class Command(BaseCommand):
13+
help = "Delete a user and all associated resources by user ID."
14+
15+
def add_arguments(self, parser):
16+
parser.add_argument("user_id", type=int, help="ID of the user to delete")
17+
parser.add_argument("--dry-run", action="store_true")
18+
19+
def handle(self, *args, **options):
20+
user_id = options["user_id"]
21+
dry_run = options["dry_run"]
22+
23+
try:
24+
deleted_resources = delete_user_with_cleanup(user_id, dry_run=dry_run)
25+
for resource_type in deleted_resources:
26+
for resource_id in deleted_resources[resource_type]:
27+
self.stdout.write(f"Deleted {resource_type}: #{resource_id}")
28+
except ValidationError as e:
29+
raise CommandError(e.detail)
30+
except User.DoesNotExist:
31+
raise CommandError(f"User with ID {user_id} does not exist.")

cvat/apps/engine/user_deletion.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Copyright (C) CVAT.ai Corporation
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
from abc import ABC, ABCMeta, abstractmethod
6+
from typing import TypedDict
7+
8+
from django.db import transaction
9+
from django.db.models import Count, Q, QuerySet
10+
from rest_framework.exceptions import ValidationError
11+
12+
from cvat.apps.engine.model_utils import _ModelT
13+
from cvat.apps.engine.models import CloudStorage, Project, Task, User
14+
from cvat.apps.organizations.models import Organization
15+
16+
_USER_DELETION_VALIDATORS = []
17+
18+
19+
class AutoRegisterValidatorMeta(ABCMeta):
20+
def __new__(mcs, name, bases, namespace):
21+
cls = super().__new__(mcs, name, bases, namespace)
22+
if not getattr(cls, "__abstractmethods__", None):
23+
_USER_DELETION_VALIDATORS.append(cls)
24+
return cls
25+
26+
27+
class UserDeletionValidator(ABC, metaclass=AutoRegisterValidatorMeta):
28+
"""
29+
Base class for user deletion validators.
30+
To add a new validator, inherit from this class and implement the `validate` method.
31+
The implemented class will be automatically registered and used during user deletion.
32+
"""
33+
34+
@abstractmethod
35+
def validate(self, user: User) -> None:
36+
"""
37+
Perform validation before user deletion.
38+
39+
If the user cannot be deleted, this method must raise
40+
rest_framework.exceptions.ValidationError with a description of the reason.
41+
"""
42+
43+
44+
class NonEmptyOrgsValidator(UserDeletionValidator):
45+
def validate(self, user: User) -> None:
46+
orgs = (
47+
Organization.objects.filter(owner=user)
48+
.annotate(members_count=Count("members"))
49+
.values_list("slug", "members_count")
50+
)
51+
52+
for org_slug, members_count in orgs:
53+
if members_count > 1:
54+
raise ValidationError(
55+
"Cannot delete the user, who is the owner of the organization "
56+
+ f"'{org_slug}' with {members_count} members."
57+
)
58+
59+
60+
class _DeletedResources(TypedDict):
61+
organization: list[int]
62+
project: list[int]
63+
task: list[int]
64+
cloud_storage: list[int]
65+
66+
67+
@transaction.atomic
68+
def delete_user_with_cleanup(user_id: int, dry_run: bool = True) -> _DeletedResources:
69+
"""
70+
The function removes user and associated resources.
71+
72+
It does not remove resources created in external organizations
73+
These resources are considered like "owned" by the organization
74+
as user has given control over them during creation.
75+
"""
76+
77+
deleted_resources: _DeletedResources = {
78+
"organization": [],
79+
"project": [],
80+
"task": [],
81+
"cloud_storage": [],
82+
}
83+
84+
user = User.objects.select_for_update().get(pk=user_id)
85+
for ValidatorClass in _USER_DELETION_VALIDATORS:
86+
ValidatorClass().validate(user)
87+
88+
db_orgs = Organization.objects.filter(owner=user).select_for_update()
89+
90+
def filter_by_owner_and_org(queryset: QuerySet[_ModelT]) -> QuerySet[_ModelT]:
91+
return queryset.filter(owner=user).filter(
92+
Q(organization=None) | Q(organization__in=db_orgs)
93+
)
94+
95+
db_projects = list(filter_by_owner_and_org(Project.objects).select_for_update())
96+
db_tasks = list(filter_by_owner_and_org(Task.objects.filter(project=None)).select_for_update())
97+
db_cloud_storages = list(filter_by_owner_and_org(CloudStorage.objects).select_for_update())
98+
99+
for resource_type, db_resources in (
100+
("organization", db_orgs),
101+
("project", db_projects),
102+
("task", db_tasks),
103+
("cloud_storage", db_cloud_storages),
104+
):
105+
for db_resource in db_resources:
106+
deleted_resources[resource_type].append(db_resource.id)
107+
108+
if not dry_run:
109+
# call delete on each instance instead of qs.delete()
110+
# to perform custom .delete() method (e.g. query optimizations, audit logs) if any
111+
for db_resource in list(db_orgs) + db_cloud_storages + db_projects + db_tasks:
112+
db_resource.delete()
113+
user.delete()
114+
115+
return deleted_resources

0 commit comments

Comments
 (0)