Skip to content

Commit bc74cd2

Browse files
authored
Delete account (#2978)
* Add active projects to default response * Account deletion notification email * Delete account view * Style remove account section
1 parent f8d132c commit bc74cd2

File tree

7 files changed

+366
-4
lines changed

7 files changed

+366
-4
lines changed

tests/unit/manage/test_views.py

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
from warehouse.manage import views
2424
from warehouse.accounts.interfaces import IUserService
25-
from warehouse.packaging.models import JournalEntry, Project, Role
25+
from warehouse.packaging.models import JournalEntry, Project, Role, User
2626

2727
from ...common.db.accounts import EmailFactory
2828
from ...common.db.packaging import (
@@ -53,10 +53,15 @@ def test_default_response(self, monkeypatch):
5353

5454
view = views.ManageProfileViews(request)
5555

56+
monkeypatch.setattr(
57+
views.ManageProfileViews, 'active_projects', pretend.stub()
58+
)
59+
5660
assert view.default_response == {
5761
'save_profile_form': save_profile_obj,
5862
'add_email_form': add_email_obj,
5963
'change_password_form': change_pass_obj,
64+
'active_projects': view.active_projects,
6065
}
6166
assert view.request == request
6267
assert view.user_service == user_service
@@ -70,6 +75,44 @@ def test_default_response(self, monkeypatch):
7075
pretend.call(user_service=user_service),
7176
]
7277

78+
def test_active_projects(self, db_request):
79+
user = UserFactory.create()
80+
another_user = UserFactory.create()
81+
82+
db_request.user = user
83+
db_request.find_service = lambda *a, **kw: pretend.stub()
84+
85+
# A project with a sole owner that is the user
86+
with_sole_owner = ProjectFactory.create()
87+
RoleFactory.create(
88+
user=user, project=with_sole_owner, role_name='Owner'
89+
)
90+
RoleFactory.create(
91+
user=another_user, project=with_sole_owner, role_name='Maintainer'
92+
)
93+
94+
# A project with multiple owners, including the user
95+
with_multiple_owners = ProjectFactory.create()
96+
RoleFactory.create(
97+
user=user, project=with_multiple_owners, role_name='Owner'
98+
)
99+
RoleFactory.create(
100+
user=another_user, project=with_multiple_owners, role_name='Owner'
101+
)
102+
103+
# A project with a sole owner that is not the user
104+
not_an_owner = ProjectFactory.create()
105+
RoleFactory.create(
106+
user=user, project=not_an_owner, role_name='Maintatiner'
107+
)
108+
RoleFactory.create(
109+
user=another_user, project=not_an_owner, role_name='Owner'
110+
)
111+
112+
view = views.ManageProfileViews(db_request)
113+
114+
assert view.active_projects == [with_sole_owner]
115+
73116
def test_manage_profile(self, monkeypatch):
74117
user_service = pretend.stub()
75118
name = pretend.stub()
@@ -560,6 +603,105 @@ def test_change_password_validation_fails(self, monkeypatch):
560603
assert send_email.calls == []
561604
assert user_service.update_user.calls == []
562605

606+
def test_delete_account(self, monkeypatch, db_request):
607+
user = UserFactory.create()
608+
deleted_user = UserFactory.create(username='deleted-user')
609+
journal = JournalEntryFactory(submitted_by=user)
610+
611+
db_request.user = user
612+
db_request.params = {'confirm_username': user.username}
613+
db_request.find_service = lambda *a, **kw: pretend.stub()
614+
615+
monkeypatch.setattr(
616+
views.ManageProfileViews, 'default_response', pretend.stub()
617+
)
618+
monkeypatch.setattr(views.ManageProfileViews, 'active_projects', [])
619+
send_email = pretend.call_recorder(lambda *a: None)
620+
monkeypatch.setattr(views, 'send_account_deletion_email', send_email)
621+
logout_response = pretend.stub()
622+
logout = pretend.call_recorder(lambda *a: logout_response)
623+
monkeypatch.setattr(views, 'logout', logout)
624+
625+
view = views.ManageProfileViews(db_request)
626+
627+
assert view.delete_account() == logout_response
628+
assert journal.submitted_by == deleted_user
629+
assert db_request.db.query(User).all() == [deleted_user]
630+
assert send_email.calls == [pretend.call(db_request, user)]
631+
assert logout.calls == [pretend.call(db_request)]
632+
633+
def test_delete_account_no_confirm(self, monkeypatch):
634+
request = pretend.stub(
635+
params={'confirm_username': ''},
636+
session=pretend.stub(
637+
flash=pretend.call_recorder(lambda *a, **kw: None),
638+
),
639+
find_service=lambda *a, **kw: pretend.stub(),
640+
)
641+
642+
monkeypatch.setattr(
643+
views.ManageProfileViews, 'default_response', pretend.stub()
644+
)
645+
646+
view = views.ManageProfileViews(request)
647+
648+
assert view.delete_account() == view.default_response
649+
assert request.session.flash.calls == [
650+
pretend.call('Must confirm the request.', queue='error')
651+
]
652+
653+
def test_delete_account_wrong_confirm(self, monkeypatch):
654+
request = pretend.stub(
655+
params={'confirm_username': 'invalid'},
656+
user=pretend.stub(username='username'),
657+
session=pretend.stub(
658+
flash=pretend.call_recorder(lambda *a, **kw: None),
659+
),
660+
find_service=lambda *a, **kw: pretend.stub(),
661+
)
662+
663+
monkeypatch.setattr(
664+
views.ManageProfileViews, 'default_response', pretend.stub()
665+
)
666+
667+
view = views.ManageProfileViews(request)
668+
669+
assert view.delete_account() == view.default_response
670+
assert request.session.flash.calls == [
671+
pretend.call(
672+
"Could not delete account - 'invalid' is not the same as "
673+
"'username'",
674+
queue='error',
675+
)
676+
]
677+
678+
def test_delete_account_has_active_projects(self, monkeypatch):
679+
request = pretend.stub(
680+
params={'confirm_username': 'username'},
681+
user=pretend.stub(username='username'),
682+
session=pretend.stub(
683+
flash=pretend.call_recorder(lambda *a, **kw: None),
684+
),
685+
find_service=lambda *a, **kw: pretend.stub(),
686+
)
687+
688+
monkeypatch.setattr(
689+
views.ManageProfileViews, 'default_response', pretend.stub()
690+
)
691+
monkeypatch.setattr(
692+
views.ManageProfileViews, 'active_projects', [pretend.stub()]
693+
)
694+
695+
view = views.ManageProfileViews(request)
696+
697+
assert view.delete_account() == view.default_response
698+
assert request.session.flash.calls == [
699+
pretend.call(
700+
"Cannot delete account with active project ownerships.",
701+
queue='error',
702+
)
703+
]
704+
563705

564706
class TestManageProjects:
565707

tests/unit/test_email.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,47 @@ def test_password_change_email(
261261
assert send_email.delay.calls == [
262262
pretend.call('Email Body', [stub_user.email], 'Email Subject'),
263263
]
264+
265+
266+
class TestAccountDeletionEmail:
267+
268+
def test_account_deletion_email(
269+
self, pyramid_request, pyramid_config, monkeypatch):
270+
271+
stub_user = pretend.stub(
272+
email='email',
273+
username='username',
274+
)
275+
subject_renderer = pyramid_config.testing_add_renderer(
276+
'email/account-deleted.subject.txt'
277+
)
278+
subject_renderer.string_response = 'Email Subject'
279+
body_renderer = pyramid_config.testing_add_renderer(
280+
'email/account-deleted.body.txt'
281+
)
282+
body_renderer.string_response = 'Email Body'
283+
284+
send_email = pretend.stub(
285+
delay=pretend.call_recorder(lambda *args, **kwargs: None)
286+
)
287+
pyramid_request.task = pretend.call_recorder(
288+
lambda *args, **kwargs: send_email
289+
)
290+
monkeypatch.setattr(email, 'send_email', send_email)
291+
292+
result = email.send_account_deletion_email(
293+
pyramid_request,
294+
user=stub_user,
295+
)
296+
297+
assert result == {
298+
'username': stub_user.username,
299+
}
300+
subject_renderer.assert_()
301+
body_renderer.assert_(username=stub_user.username)
302+
assert pyramid_request.task.calls == [
303+
pretend.call(send_email),
304+
]
305+
assert send_email.delay.calls == [
306+
pretend.call('Email Body', [stub_user.email], 'Email Subject'),
307+
]

warehouse/email.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,21 @@ def send_password_change_email(request, user):
108108
request.task(send_email).delay(body, [user.email], subject)
109109

110110
return fields
111+
112+
113+
def send_account_deletion_email(request, user):
114+
fields = {
115+
'username': user.username,
116+
}
117+
118+
subject = render(
119+
'email/account-deleted.subject.txt', fields, request=request
120+
)
121+
122+
body = render(
123+
'email/account-deleted.body.txt', fields, request=request
124+
)
125+
126+
request.task(send_email).delay(body, [user.email], subject)
127+
128+
return fields

warehouse/manage/views.py

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
# Licensed under the Apache License, Version 2.0 (the "License");
2-
32
# you may not use this file except in compliance with the License.
43
# You may obtain a copy of the License at
54
#
@@ -16,18 +15,21 @@
1615
from pyramid.httpexceptions import HTTPSeeOther
1716
from pyramid.security import Authenticated
1817
from pyramid.view import view_config, view_defaults
18+
from sqlalchemy import func
1919
from sqlalchemy.orm.exc import NoResultFound
2020

2121
from warehouse.accounts.interfaces import IUserService
2222
from warehouse.accounts.models import User, Email
23+
from warehouse.accounts.views import logout
2324
from warehouse.email import (
24-
send_email_verification_email, send_password_change_email,
25+
send_account_deletion_email, send_email_verification_email,
26+
send_password_change_email,
2527
)
2628
from warehouse.manage.forms import (
2729
AddEmailForm, ChangePasswordForm, CreateRoleForm, ChangeRoleForm,
2830
SaveProfileForm,
2931
)
30-
from warehouse.packaging.models import JournalEntry, Role, File
32+
from warehouse.packaging.models import File, JournalEntry, Project, Role
3133
from warehouse.utils.project import confirm_project, remove_project
3234

3335

@@ -44,6 +46,32 @@ def __init__(self, request):
4446
self.request = request
4547
self.user_service = request.find_service(IUserService, context=None)
4648

49+
@property
50+
def active_projects(self):
51+
''' Return all the projects for with the user is a sole owner '''
52+
projects_owned = (
53+
self.request.db.query(Project)
54+
.join(Role.project)
55+
.filter(Role.role_name == 'Owner', Role.user == self.request.user)
56+
.subquery()
57+
)
58+
59+
with_sole_owner = (
60+
self.request.db.query(Role.package_name)
61+
.join(projects_owned)
62+
.filter(Role.role_name == 'Owner')
63+
.group_by(Role.package_name)
64+
.having(func.count(Role.package_name) == 1)
65+
.subquery()
66+
)
67+
68+
return (
69+
self.request.db.query(Project)
70+
.join(with_sole_owner)
71+
.order_by(Project.name)
72+
.all()
73+
)
74+
4775
@property
4876
def default_response(self):
4977
return {
@@ -52,6 +80,7 @@ def default_response(self):
5280
'change_password_form': ChangePasswordForm(
5381
user_service=self.user_service
5482
),
83+
'active_projects': self.active_projects,
5584
}
5685

5786
@view_config(request_method="GET")
@@ -218,6 +247,58 @@ def change_password(self):
218247
'change_password_form': form,
219248
}
220249

250+
@view_config(
251+
request_method='POST',
252+
request_param=['confirm_username']
253+
)
254+
def delete_account(self):
255+
username = self.request.params.get('confirm_username')
256+
257+
if not username:
258+
self.request.session.flash(
259+
"Must confirm the request.", queue='error'
260+
)
261+
return self.default_response
262+
263+
if username != self.request.user.username:
264+
self.request.session.flash(
265+
f"Could not delete account - {username!r} is not the same as "
266+
f"{self.request.user.username!r}",
267+
queue='error'
268+
)
269+
return self.default_response
270+
271+
if self.active_projects:
272+
self.request.session.flash(
273+
"Cannot delete account with active project ownerships.",
274+
queue='error',
275+
)
276+
return self.default_response
277+
278+
# Update all journals to point to `deleted-user` instead
279+
deleted_user = (
280+
self.request.db.query(User)
281+
.filter(User.username == 'deleted-user')
282+
.one()
283+
)
284+
285+
journals = (
286+
self.request.db.query(JournalEntry)
287+
.filter(JournalEntry.submitted_by == self.request.user)
288+
.all()
289+
)
290+
291+
for journal in journals:
292+
journal.submitted_by = deleted_user
293+
294+
# Send a notification email
295+
send_account_deletion_email(self.request, self.request.user)
296+
297+
# Actually delete the user
298+
self.request.db.delete(self.request.user)
299+
300+
return logout(self.request)
301+
221302

222303
@view_config(
223304
route_name="manage.projects",
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Your PyPI account '{{ username }}' has successfully been deleted.
2+
3+
If you did not make this change, you can reply to this email directly to
4+
communicate with the PyPI administrators.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
PyPI Account Deletion Notification

0 commit comments

Comments
 (0)