Skip to content

Commit bb45582

Browse files
authored
User: delete organizations where the user is the last owner (#9164)
This also shows a warning with a list of projects/organizations to be deleted.
1 parent 80b2fa1 commit bb45582

File tree

10 files changed

+226
-107
lines changed

10 files changed

+226
-107
lines changed

readthedocs/core/signals.py

+15-16
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,18 @@
11
"""Signal handling for core app."""
22

33
import structlog
4-
54
from corsheaders import signals
65
from django.conf import settings
7-
from django.db.models import Count
86
from django.db.models.signals import pre_delete
97
from django.dispatch import Signal, receiver
10-
118
from rest_framework.permissions import SAFE_METHODS
129
from simple_history.models import HistoricalRecords
1310
from simple_history.signals import pre_create_historical_record
1411

1512
from readthedocs.analytics.utils import get_client_ip
1613
from readthedocs.builds.models import Version
1714
from readthedocs.core.unresolver import unresolve
15+
from readthedocs.organizations.models import Organization
1816
from readthedocs.projects.models import Project
1917

2018
log = structlog.get_logger(__name__)
@@ -114,19 +112,20 @@ def decide_if_cors(sender, request, **kwargs): # pylint: disable=unused-argumen
114112

115113

116114
@receiver(pre_delete, sender=settings.AUTH_USER_MODEL)
117-
def delete_projects(sender, instance, *args, **kwargs):
118-
# Here we count the owner list from the projects that the user own
119-
# Then exclude the projects where there are more than one owner
120-
# Add annotate before filter
121-
# https://github.com/rtfd/readthedocs.org/pull/4577
122-
# https://docs.djangoproject.com/en/2.1/topics/db/aggregation/#order-of-annotate-and-filter-clauses # noqa
123-
projects = (
124-
Project.objects.annotate(num_users=Count('users')
125-
).filter(users=instance.id
126-
).exclude(num_users__gt=1)
127-
)
128-
129-
projects.delete()
115+
def delete_projects_and_organizations(sender, instance, *args, **kwargs):
116+
"""
117+
Delete projects and organizations where the user is the only owner.
118+
119+
We delete projects that don't belong to an organization first,
120+
then full organizations.
121+
"""
122+
user = instance
123+
124+
for project in Project.objects.single_owner(user):
125+
project.delete()
126+
127+
for organization in Organization.objects.single_owner(user):
128+
organization.delete()
130129

131130

132131
@receiver(pre_create_historical_record)
+73-41
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,82 @@
1-
# -*- coding: utf-8 -*-
2-
import django_dynamic_fixture
31
import pytest
4-
52
from django.contrib.auth.models import User
3+
from django_dynamic_fixture import get
64

5+
from readthedocs.builds.models import Version
6+
from readthedocs.organizations.models import Organization, Team, TeamInvite, TeamMember
77
from readthedocs.projects.models import Project
88

99

1010
@pytest.mark.django_db
1111
class TestProjectOrganizationSignal:
1212

13-
def test_project_get_deleted_upon_user_delete(self):
14-
"""If the user has Project where he is the only
15-
user, upon deleting his account, the Project
16-
should also get deleted."""
17-
18-
project = django_dynamic_fixture.get(Project)
19-
user1 = django_dynamic_fixture.get(User)
20-
project.users.add(user1)
21-
22-
project.refresh_from_db()
23-
assert project.users.all().count() == 1
24-
25-
# Delete the user
26-
user1.delete()
27-
# The object should not exist
28-
project = Project.objects.all().filter(id=project.id)
29-
assert not project.exists()
30-
31-
def test_multiple_users_project_not_delete(self):
32-
"""Check Project which have multiple users do not
33-
get deleted when any of the user delete his account."""
34-
35-
project = django_dynamic_fixture.get(Project)
36-
user1 = django_dynamic_fixture.get(User)
37-
user2 = django_dynamic_fixture.get(User)
38-
project.users.add(user1, user2)
39-
40-
project.refresh_from_db()
41-
assert project.users.all().count() > 1
42-
# Delete 1 user of the project
43-
user1.delete()
44-
45-
# The project should still exist and it should have 1 user
46-
project.refresh_from_db()
47-
assert project.id
48-
obj_users = project.users.all()
49-
assert len(obj_users) == 1
50-
assert user2 in obj_users
13+
def test_delete_user_deletes_projects(self):
14+
"""When deleting a user, delete projects where it's the only owner."""
15+
user = get(User)
16+
another_user = get(User)
17+
project_one = get(Project, slug="one", users=[user])
18+
project_two = get(Project, slug="two", users=[user])
19+
project_three = get(Project, slug="three", users=[another_user])
20+
project_four = get(Project, slug="four", users=[user, another_user])
21+
22+
project_five = get(
23+
Project,
24+
slug="five",
25+
users=[],
26+
)
27+
assert Project.objects.all().count() == 5
28+
assert Version.objects.all().count() == 5
29+
30+
user.delete()
31+
assert {project_three, project_four, project_five} == set(Project.objects.all())
32+
assert Version.objects.all().count() == 3
33+
34+
def test_delete_user_deletes_organizations(self):
35+
"""When deleting a user, delete organizations where it's the only owner."""
36+
user = get(User)
37+
member = get(User)
38+
another_user = get(User)
39+
project_one = get(Project, slug="one")
40+
project_two = get(Project, slug="two")
41+
project_three = get(Project, slug="three")
42+
org_one = get(Organization, slug="one", owners=[user], projects=[project_one])
43+
org_two = get(
44+
Organization,
45+
slug="two",
46+
owners=[user, another_user],
47+
projects=[project_two],
48+
)
49+
org_three = get(
50+
Organization, slug="three", owners=[another_user], projects=[project_three]
51+
)
52+
53+
team_one = get(
54+
Team, organization=org_one, members=[member, user], projects=[project_one]
55+
)
56+
team_two = get(
57+
Team,
58+
organization=org_three,
59+
members=[another_user],
60+
projects=[project_three],
61+
)
62+
63+
get(TeamInvite, team=team_one, organization=org_one)
64+
65+
66+
assert Organization.objects.all().count() == 3
67+
assert Project.objects.all().count() == 3
68+
assert Version.objects.all().count() == 3
69+
assert Team.objects.all().count() == 2
70+
assert TeamInvite.objects.all().count() == 1
71+
assert TeamMember.objects.all().count() == 3
72+
assert User.objects.all().count() == 3
73+
74+
user.delete()
75+
76+
assert {org_two, org_three} == set(Organization.objects.all())
77+
assert {project_two, project_three} == set(Project.objects.all())
78+
assert Version.objects.all().count() == 2
79+
assert {team_two} == set(Team.objects.all())
80+
assert TeamInvite.objects.all().count() == 0
81+
assert TeamMember.objects.all().count() == 1
82+
assert User.objects.all().count() == 2

readthedocs/organizations/querysets.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from django.conf import settings
66
from django.db import models
7-
from django.db.models import Q
7+
from django.db.models import Count, Q
88
from django.utils import timezone
99

1010
from readthedocs.core.utils.extend import SettingsOverrideObject
@@ -214,6 +214,13 @@ def clean_artifacts(self):
214214
)
215215
return orgs.distinct()
216216

217+
def single_owner(self, user):
218+
"""Returns organizations where `user` is the only owner."""
219+
return self.annotate(count_owners=Count("owners")).filter(
220+
owners=user,
221+
count_owners=1,
222+
)
223+
217224

218225
class OrganizationQuerySet(SettingsOverrideObject):
219226

readthedocs/organizations/signals.py

+11-30
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,20 @@
11
"""Organization signals."""
22

33
import structlog
4-
54
from allauth.account.signals import user_signed_up
65
from django.db.models import Count
76
from django.db.models.signals import pre_delete
87
from django.dispatch import receiver
98

109
from readthedocs.builds.constants import BUILD_STATE_FINISHED
11-
from readthedocs.builds.models import Build, Version
10+
from readthedocs.builds.models import Build
1211
from readthedocs.builds.signals import build_complete
13-
from readthedocs.organizations.models import (
14-
Organization,
15-
Team,
16-
TeamInvite,
17-
TeamMember,
18-
)
12+
from readthedocs.organizations.models import Organization, Team, TeamMember
1913
from readthedocs.projects.models import Project
2014

21-
from .tasks import mark_organization_assets_not_cleaned as mark_organization_assets_not_cleaned_task
15+
from .tasks import (
16+
mark_organization_assets_not_cleaned as mark_organization_assets_not_cleaned_task,
17+
)
2218

2319
log = structlog.get_logger(__name__)
2420

@@ -56,28 +52,13 @@ def remove_organization_completely(sender, instance, using, **kwargs):
5652
# to be sure that the projects we are deleting here belongs only to the
5753
# organization deleted
5854
projects = Project.objects.annotate(
59-
Count('organizations'),
60-
).filter(
61-
organizations__in=[organization],
62-
organizations__count=1,
63-
)
64-
65-
versions = Version.objects.filter(project__in=projects)
66-
teams = Team.objects.filter(organization=organization)
67-
team_invites = TeamInvite.objects.filter(organization=organization)
68-
team_memberships = TeamMember.objects.filter(team__in=teams)
69-
70-
# Bulk delete
71-
team_memberships.delete()
72-
team_invites.delete()
73-
teams.delete()
74-
75-
# Granular delete that trigger other complex tasks
76-
for version in versions:
77-
# Triggers a task to remove artifacts from storage
78-
version.delete()
55+
count_organizations=Count("organizations")
56+
).filter(organizations__in=[organization], count_organizations=1)
7957

80-
projects.delete()
58+
# Granular delete that trigger other complex tasks.
59+
for project in projects:
60+
# Triggers a task to remove artifacts from storage.
61+
project.delete()
8162

8263

8364
@receiver(build_complete, sender=Build)

readthedocs/organizations/tests/test_orgs.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -195,10 +195,7 @@ def test_organization_delete(self):
195195
with mock.patch('readthedocs.projects.tasks.utils.clean_project_resources') as clean_project_resources:
196196
# Triggers a pre_delete signal that removes all leaf overs
197197
self.organization.delete()
198-
clean_project_resources.assert_has_calls([
199-
mock.call(self.project, mock.ANY), # latest
200-
mock.call(self.project, mock.ANY), # version
201-
])
198+
clean_project_resources.assert_called_once()
202199

203200
self.assertNotIn(self.organization, Organization.objects.all())
204201
self.assertNotIn(team, Team.objects.all())
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from django.contrib.auth.models import User
2+
from django.test import TestCase
3+
from django_dynamic_fixture import get
4+
5+
from readthedocs.organizations.models import Organization
6+
7+
8+
class TestOrganizationQuerysets(TestCase):
9+
def test_only_owner(self):
10+
user = get(User)
11+
another_user = get(User)
12+
13+
org_one = get(Organization, slug="one", owners=[user])
14+
org_two = get(Organization, slug="two", owners=[user])
15+
org_three = get(Organization, slug="three", owners=[another_user])
16+
get(Organization, slug="four", owners=[user, another_user])
17+
get(Organization, slug="five", owners=[])
18+
19+
self.assertEqual(
20+
{org_one, org_two}, set(Organization.objects.single_owner(user))
21+
)
22+
self.assertEqual(
23+
{org_three}, set(Organization.objects.single_owner(another_user))
24+
)

readthedocs/profiles/views.py

+11-13
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,17 @@
1212
from django.utils import timezone
1313
from django.utils.translation import gettext_lazy as _
1414
from rest_framework.authtoken.models import Token
15-
from vanilla import (
16-
CreateView,
17-
DeleteView,
18-
DetailView,
19-
FormView,
20-
ListView,
21-
UpdateView,
22-
)
15+
from vanilla import CreateView, DeleteView, DetailView, FormView, ListView, UpdateView
2316

2417
from readthedocs.audit.filters import UserSecurityLogFilter
2518
from readthedocs.audit.models import AuditLog
26-
from readthedocs.core.forms import (
27-
UserAdvertisingForm,
28-
UserDeleteForm,
29-
UserProfileForm,
30-
)
19+
from readthedocs.core.forms import UserAdvertisingForm, UserDeleteForm, UserProfileForm
3120
from readthedocs.core.history import set_change_reason
3221
from readthedocs.core.mixins import PrivateViewMixin
3322
from readthedocs.core.models import UserProfile
3423
from readthedocs.core.utils.extend import SettingsOverrideObject
24+
from readthedocs.organizations.models import Organization
25+
from readthedocs.projects.models import Project
3526
from readthedocs.projects.utils import get_csv_file
3627

3728

@@ -95,6 +86,13 @@ def get_form(self, data=None, files=None, **kwargs):
9586
kwargs['initial'] = {'username': ''}
9687
return super().get_form(data, files, **kwargs)
9788

89+
def get_context_data(self, **kwargs):
90+
context = super().get_context_data(**kwargs)
91+
user = self.request.user
92+
context["projects_to_be_deleted"] = Project.objects.single_owner(user)
93+
context["organizations_to_be_deleted"] = Organization.objects.single_owner(user)
94+
return context
95+
9896
def get_success_url(self):
9997
return reverse('homepage')
10098

readthedocs/projects/querysets.py

+13-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
"""Project model QuerySet classes."""
2-
32
from django.conf import settings
43
from django.db import models
5-
from django.db.models import OuterRef, Prefetch, Q, Subquery
4+
from django.db.models import Count, OuterRef, Prefetch, Q, Subquery
65

76
from readthedocs.core.permissions import AdminPermission
87
from readthedocs.core.utils.extend import SettingsOverrideObject
@@ -148,6 +147,18 @@ def dashboard(self, user):
148147
def api(self, user=None):
149148
return self.public(user)
150149

150+
def single_owner(self, user):
151+
"""
152+
Returns projects where `user` is the only owner.
153+
154+
Projects that belong to organizations aren't included.
155+
"""
156+
return self.annotate(count_users=Count("users")).filter(
157+
users=user,
158+
count_users=1,
159+
organizations__isnull=True,
160+
)
161+
151162

152163
class ProjectQuerySet(SettingsOverrideObject):
153164
_default_class = ProjectQuerySetBase

0 commit comments

Comments
 (0)