Repository: pypi/warehouse

Issue:
Manage Account: Delete Account
From #423: From the 'manage account' page, a logged-in user should be able to delete their account.

If the user is the sole owner of any projects, this should be disabled and they should be informed that they need to transfer ownership or delete the projects before they can delete their account.

A notification email should be sent to the primary email address when the account is successfully deleted.


Discussion:
@di 
I've started diving into the code and would like to work on this issue.

I'll get back if I get any questions and will put a WIP PR.
Hi @waseem18, I appreciate your enthusiasm! However I've already begun work on this issue. If you're looking for something relevant to work on, I'd recommend searching for issues in upcoming milestones without an assignee. [The project view for this repo might be helpful as well](https://github.com/pypa/warehouse/projects/1).
Thanks for the information @di 

Resolving patch:
diff --git a/warehouse/email.py b/warehouse/email.py
--- a/warehouse/email.py
+++ b/warehouse/email.py
@@ -108,3 +108,21 @@ def send_password_change_email(request, user):
     request.task(send_email).delay(body, [user.email], subject)
 
     return fields
+
+
+def send_account_deletion_email(request, user):
+    fields = {
+        'username': user.username,
+    }
+
+    subject = render(
+        'email/account-deleted.subject.txt', fields, request=request
+    )
+
+    body = render(
+        'email/account-deleted.body.txt', fields, request=request
+    )
+
+    request.task(send_email).delay(body, [user.email], subject)
+
+    return fields
diff --git a/warehouse/manage/views.py b/warehouse/manage/views.py
--- a/warehouse/manage/views.py
+++ b/warehouse/manage/views.py
@@ -1,5 +1,4 @@
 # Licensed under the Apache License, Version 2.0 (the "License");
-
 # you may not use this file except in compliance with the License.
 # You may obtain a copy of the License at
 #
@@ -16,18 +15,21 @@
 from pyramid.httpexceptions import HTTPSeeOther
 from pyramid.security import Authenticated
 from pyramid.view import view_config, view_defaults
+from sqlalchemy import func
 from sqlalchemy.orm.exc import NoResultFound
 
 from warehouse.accounts.interfaces import IUserService
 from warehouse.accounts.models import User, Email
+from warehouse.accounts.views import logout
 from warehouse.email import (
-    send_email_verification_email, send_password_change_email,
+    send_account_deletion_email, send_email_verification_email,
+    send_password_change_email,
 )
 from warehouse.manage.forms import (
     AddEmailForm, ChangePasswordForm, CreateRoleForm, ChangeRoleForm,
     SaveProfileForm,
 )
-from warehouse.packaging.models import JournalEntry, Role, File
+from warehouse.packaging.models import File, JournalEntry, Project, Role
 from warehouse.utils.project import confirm_project, remove_project
 
 
@@ -44,6 +46,32 @@ def __init__(self, request):
         self.request = request
         self.user_service = request.find_service(IUserService, context=None)
 
+    @property
+    def active_projects(self):
+        ''' Return all the projects for with the user is a sole owner '''
+        projects_owned = (
+            self.request.db.query(Project)
+            .join(Role.project)
+            .filter(Role.role_name == 'Owner', Role.user == self.request.user)
+            .subquery()
+        )
+
+        with_sole_owner = (
+            self.request.db.query(Role.package_name)
+            .join(projects_owned)
+            .filter(Role.role_name == 'Owner')
+            .group_by(Role.package_name)
+            .having(func.count(Role.package_name) == 1)
+            .subquery()
+        )
+
+        return (
+            self.request.db.query(Project)
+            .join(with_sole_owner)
+            .order_by(Project.name)
+            .all()
+        )
+
     @property
     def default_response(self):
         return {
@@ -52,6 +80,7 @@ def default_response(self):
             'change_password_form': ChangePasswordForm(
                 user_service=self.user_service
             ),
+            'active_projects': self.active_projects,
         }
 
     @view_config(request_method="GET")
@@ -218,6 +247,58 @@ def change_password(self):
             'change_password_form': form,
         }
 
+    @view_config(
+        request_method='POST',
+        request_param=['confirm_username']
+    )
+    def delete_account(self):
+        username = self.request.params.get('confirm_username')
+
+        if not username:
+            self.request.session.flash(
+                "Must confirm the request.", queue='error'
+            )
+            return self.default_response
+
+        if username != self.request.user.username:
+            self.request.session.flash(
+                f"Could not delete account - {username!r} is not the same as "
+                f"{self.request.user.username!r}",
+                queue='error'
+            )
+            return self.default_response
+
+        if self.active_projects:
+            self.request.session.flash(
+                "Cannot delete account with active project ownerships.",
+                queue='error',
+            )
+            return self.default_response
+
+        # Update all journals to point to `deleted-user` instead
+        deleted_user = (
+            self.request.db.query(User)
+            .filter(User.username == 'deleted-user')
+            .one()
+        )
+
+        journals = (
+            self.request.db.query(JournalEntry)
+            .filter(JournalEntry.submitted_by == self.request.user)
+            .all()
+        )
+
+        for journal in journals:
+            journal.submitted_by = deleted_user
+
+        # Send a notification email
+        send_account_deletion_email(self.request, self.request.user)
+
+        # Actually delete the user
+        self.request.db.delete(self.request.user)
+
+        return logout(self.request)
+
 
 @view_config(
     route_name="manage.projects",


Test patch:
diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py
--- a/tests/unit/manage/test_views.py
+++ b/tests/unit/manage/test_views.py
@@ -22,7 +22,7 @@
 
 from warehouse.manage import views
 from warehouse.accounts.interfaces import IUserService
-from warehouse.packaging.models import JournalEntry, Project, Role
+from warehouse.packaging.models import JournalEntry, Project, Role, User
 
 from ...common.db.accounts import EmailFactory
 from ...common.db.packaging import (
@@ -53,10 +53,15 @@ def test_default_response(self, monkeypatch):
 
         view = views.ManageProfileViews(request)
 
+        monkeypatch.setattr(
+            views.ManageProfileViews, 'active_projects', pretend.stub()
+        )
+
         assert view.default_response == {
             'save_profile_form': save_profile_obj,
             'add_email_form': add_email_obj,
             'change_password_form': change_pass_obj,
+            'active_projects': view.active_projects,
         }
         assert view.request == request
         assert view.user_service == user_service
@@ -70,6 +75,44 @@ def test_default_response(self, monkeypatch):
             pretend.call(user_service=user_service),
         ]
 
+    def test_active_projects(self, db_request):
+        user = UserFactory.create()
+        another_user = UserFactory.create()
+
+        db_request.user = user
+        db_request.find_service = lambda *a, **kw: pretend.stub()
+
+        # A project with a sole owner that is the user
+        with_sole_owner = ProjectFactory.create()
+        RoleFactory.create(
+            user=user, project=with_sole_owner, role_name='Owner'
+        )
+        RoleFactory.create(
+            user=another_user, project=with_sole_owner, role_name='Maintainer'
+        )
+
+        # A project with multiple owners, including the user
+        with_multiple_owners = ProjectFactory.create()
+        RoleFactory.create(
+            user=user, project=with_multiple_owners, role_name='Owner'
+        )
+        RoleFactory.create(
+            user=another_user, project=with_multiple_owners, role_name='Owner'
+        )
+
+        # A project with a sole owner that is not the user
+        not_an_owner = ProjectFactory.create()
+        RoleFactory.create(
+            user=user, project=not_an_owner, role_name='Maintatiner'
+        )
+        RoleFactory.create(
+            user=another_user, project=not_an_owner, role_name='Owner'
+        )
+
+        view = views.ManageProfileViews(db_request)
+
+        assert view.active_projects == [with_sole_owner]
+
     def test_manage_profile(self, monkeypatch):
         user_service = pretend.stub()
         name = pretend.stub()
@@ -560,6 +603,105 @@ def test_change_password_validation_fails(self, monkeypatch):
         assert send_email.calls == []
         assert user_service.update_user.calls == []
 
+    def test_delete_account(self, monkeypatch, db_request):
+        user = UserFactory.create()
+        deleted_user = UserFactory.create(username='deleted-user')
+        journal = JournalEntryFactory(submitted_by=user)
+
+        db_request.user = user
+        db_request.params = {'confirm_username': user.username}
+        db_request.find_service = lambda *a, **kw: pretend.stub()
+
+        monkeypatch.setattr(
+            views.ManageProfileViews, 'default_response', pretend.stub()
+        )
+        monkeypatch.setattr(views.ManageProfileViews, 'active_projects', [])
+        send_email = pretend.call_recorder(lambda *a: None)
+        monkeypatch.setattr(views, 'send_account_deletion_email', send_email)
+        logout_response = pretend.stub()
+        logout = pretend.call_recorder(lambda *a: logout_response)
+        monkeypatch.setattr(views, 'logout', logout)
+
+        view = views.ManageProfileViews(db_request)
+
+        assert view.delete_account() == logout_response
+        assert journal.submitted_by == deleted_user
+        assert db_request.db.query(User).all() == [deleted_user]
+        assert send_email.calls == [pretend.call(db_request, user)]
+        assert logout.calls == [pretend.call(db_request)]
+
+    def test_delete_account_no_confirm(self, monkeypatch):
+        request = pretend.stub(
+            params={'confirm_username': ''},
+            session=pretend.stub(
+                flash=pretend.call_recorder(lambda *a, **kw: None),
+            ),
+            find_service=lambda *a, **kw: pretend.stub(),
+        )
+
+        monkeypatch.setattr(
+            views.ManageProfileViews, 'default_response', pretend.stub()
+        )
+
+        view = views.ManageProfileViews(request)
+
+        assert view.delete_account() == view.default_response
+        assert request.session.flash.calls == [
+            pretend.call('Must confirm the request.', queue='error')
+        ]
+
+    def test_delete_account_wrong_confirm(self, monkeypatch):
+        request = pretend.stub(
+            params={'confirm_username': 'invalid'},
+            user=pretend.stub(username='username'),
+            session=pretend.stub(
+                flash=pretend.call_recorder(lambda *a, **kw: None),
+            ),
+            find_service=lambda *a, **kw: pretend.stub(),
+        )
+
+        monkeypatch.setattr(
+            views.ManageProfileViews, 'default_response', pretend.stub()
+        )
+
+        view = views.ManageProfileViews(request)
+
+        assert view.delete_account() == view.default_response
+        assert request.session.flash.calls == [
+            pretend.call(
+                "Could not delete account - 'invalid' is not the same as "
+                "'username'",
+                queue='error',
+            )
+        ]
+
+    def test_delete_account_has_active_projects(self, monkeypatch):
+        request = pretend.stub(
+            params={'confirm_username': 'username'},
+            user=pretend.stub(username='username'),
+            session=pretend.stub(
+                flash=pretend.call_recorder(lambda *a, **kw: None),
+            ),
+            find_service=lambda *a, **kw: pretend.stub(),
+        )
+
+        monkeypatch.setattr(
+            views.ManageProfileViews, 'default_response', pretend.stub()
+        )
+        monkeypatch.setattr(
+            views.ManageProfileViews, 'active_projects', [pretend.stub()]
+        )
+
+        view = views.ManageProfileViews(request)
+
+        assert view.delete_account() == view.default_response
+        assert request.session.flash.calls == [
+            pretend.call(
+                "Cannot delete account with active project ownerships.",
+                queue='error',
+            )
+        ]
+
 
 class TestManageProjects:
 
diff --git a/tests/unit/test_email.py b/tests/unit/test_email.py
--- a/tests/unit/test_email.py
+++ b/tests/unit/test_email.py
@@ -261,3 +261,47 @@ def test_password_change_email(
         assert send_email.delay.calls == [
             pretend.call('Email Body', [stub_user.email], 'Email Subject'),
         ]
+
+
+class TestAccountDeletionEmail:
+
+    def test_account_deletion_email(
+            self, pyramid_request, pyramid_config, monkeypatch):
+
+        stub_user = pretend.stub(
+            email='email',
+            username='username',
+        )
+        subject_renderer = pyramid_config.testing_add_renderer(
+            'email/account-deleted.subject.txt'
+        )
+        subject_renderer.string_response = 'Email Subject'
+        body_renderer = pyramid_config.testing_add_renderer(
+            'email/account-deleted.body.txt'
+        )
+        body_renderer.string_response = 'Email Body'
+
+        send_email = pretend.stub(
+            delay=pretend.call_recorder(lambda *args, **kwargs: None)
+        )
+        pyramid_request.task = pretend.call_recorder(
+            lambda *args, **kwargs: send_email
+        )
+        monkeypatch.setattr(email, 'send_email', send_email)
+
+        result = email.send_account_deletion_email(
+            pyramid_request,
+            user=stub_user,
+        )
+
+        assert result == {
+            'username': stub_user.username,
+        }
+        subject_renderer.assert_()
+        body_renderer.assert_(username=stub_user.username)
+        assert pyramid_request.task.calls == [
+            pretend.call(send_email),
+        ]
+        assert send_email.delay.calls == [
+            pretend.call('Email Body', [stub_user.email], 'Email Subject'),
+        ]