← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/close-account-polish into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/close-account-polish into lp:launchpad with lp:~cjwatson/launchpad/close-account-perms as a prerequisite.

Commit message:
Make close-account safer to run.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/close-account-polish/+merge/359865

I went through the list of FKs to Person.id and identified quite a few more that can safely be either ignored or removed.  For the remainder, I borrowed part of personmerge's approach: we now programmatically check all remaining FKs and bail out if there's anything left that we haven't considered.  (For example, if the person whose account is being closed still owns a project, we need to decide what to do about that.)

I also arranged to preserve the Account.status_history audit trail (it seemed best to use a new terminal AccountStatus.CLOSED status), and to solve only those owned questions that are in non-final states.

This should make close-account somewhat less eccentric and (I hope) closer to being safe to use on production: it's more careful to preserve audit trails, and bails out rather than doing the wrong thing if it encounters something it doesn't understand.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/close-account-polish into lp:launchpad.
=== modified file 'lib/lp/registry/personmerge.py'
--- lib/lp/registry/personmerge.py	2018-10-05 15:35:34 +0000
+++ lib/lp/registry/personmerge.py	2018-11-29 19:04:47 +0000
@@ -775,18 +775,7 @@
         ]
 
     references = list(postgresql.listReferences(cur, 'person', 'id'))
-
-    # Sanity check. If we have an indirect reference, it must
-    # be ON DELETE CASCADE. We only have one case of this at the moment,
-    # but this code ensures we catch any new ones added incorrectly.
-    for src_tab, src_col, ref_tab, ref_col, updact, delact in references:
-        # If the ref_tab and ref_col is not Person.id, then we have
-        # an indirect reference. Ensure the update action is 'CASCADE'
-        if ref_tab != 'person' and ref_col != 'id':
-            if updact != 'c':
-                raise RuntimeError(
-                    '%s.%s reference to %s.%s must be ON UPDATE CASCADE'
-                    % (src_tab, src_col, ref_tab, ref_col))
+    postgresql.check_indirect_references(references)
 
     # These rows are in a UNIQUE index, and we can only move them
     # to the new Person if there is not already an entry. eg. if

=== modified file 'lib/lp/registry/scripts/closeaccount.py'
--- lib/lp/registry/scripts/closeaccount.py	2018-11-29 19:04:47 +0000
+++ lib/lp/registry/scripts/closeaccount.py	2018-11-29 19:04:47 +0000
@@ -11,15 +11,32 @@
     Lower,
     Or,
     )
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
 
 from lp.answers.enums import QuestionStatus
 from lp.answers.model.question import Question
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.bugs.model.bugtask import BugTask
-from lp.registry.interfaces.person import PersonCreationRationale
-from lp.registry.model.person import Person
+from lp.registry.interfaces.person import (
+    IPersonSet,
+    PersonCreationRationale,
+    )
+from lp.registry.model.person import (
+    Person,
+    PersonSettings,
+    )
+from lp.services.database import postgresql
+from lp.services.database.constants import DEFAULT
 from lp.services.database.interfaces import IMasterStore
-from lp.services.identity.model.account import Account
+from lp.services.database.sqlbase import cursor
+from lp.services.identity.interfaces.account import (
+    AccountCreationRationale,
+    AccountStatus,
+    IAccountSet,
+    )
 from lp.services.identity.model.emailaddress import EmailAddress
+from lp.services.openid.model.openididentifier import OpenIdIdentifier
 from lp.services.scripts.base import (
     LaunchpadScript,
     LaunchpadScriptFailure,
@@ -33,6 +50,10 @@
     """
     store = IMasterStore(Person)
 
+    cur = cursor()
+    references = list(postgresql.listReferences(cur, 'person', 'id'))
+    postgresql.check_indirect_references(references)
+
     row = store.using(
         Person,
         LeftJoin(EmailAddress, Person.id == EmailAddress.personID)
@@ -57,6 +78,78 @@
     # succeed.
     new_name = 'removed%d' % person_id
 
+    # Some references can safely remain in place and link to the cleaned-out
+    # Person row.
+    skip = {
+        # These references express some kind of audit trail.  The actions in
+        # question still happened, and in some cases the rows may still have
+        # functional significance (e.g. subscriptions or access grants), but
+        # we no longer identify the actor.
+        ('accessartifactgrant', 'grantor'),
+        ('accesspolicygrant', 'grantor'),
+        ('binarypackagepublishinghistory', 'removed_by'),
+        ('branchmergeproposal', 'merge_reporter'),
+        ('branchmergeproposal', 'merger'),
+        ('branchmergeproposal', 'queuer'),
+        ('branchmergeproposal', 'reviewer'),
+        ('branchsubscription', 'subscribed_by'),
+        ('bug', 'who_made_private'),
+        ('bugactivity', 'person'),
+        ('bugnomination', 'decider'),
+        ('bugsubscription', 'subscribed_by'),
+        ('faq', 'last_updated_by'),
+        ('featureflagchangelogentry', 'person'),
+        ('gitactivity', 'changee'),
+        ('gitactivity', 'changer'),
+        ('gitrule', 'creator'),
+        ('gitrulegrant', 'grantor'),
+        ('gitsubscription', 'subscribed_by'),
+        ('messageapproval', 'disposed_by'),
+        ('messageapproval', 'posted_by'),
+        ('packagecopyrequest', 'requester'),
+        ('packagediff', 'requester'),
+        ('personlocation', 'last_modified_by'),
+        ('persontransferjob', 'major_person'),
+        ('persontransferjob', 'minor_person'),
+        ('poexportrequest', 'person'),
+        ('question', 'answerer'),
+        ('questionreopening', 'answerer'),
+        ('questionreopening', 'reopener'),
+        ('snapbuild', 'requester'),
+        ('sourcepackagepublishinghistory', 'creator'),
+        ('sourcepackagepublishinghistory', 'removed_by'),
+        ('sourcepackagepublishinghistory', 'sponsor'),
+        ('sourcepackagerecipebuild', 'requester'),
+        ('specification', 'approver'),
+        ('specification', 'completer'),
+        ('specification', 'drafter'),
+        ('specification', 'goal_decider'),
+        ('specification', 'goal_proposer'),
+        ('specification', 'last_changed_by'),
+        ('specification', 'starter'),
+        ('structuralsubscription', 'subscribed_by'),
+        ('teammembership', 'acknowledged_by'),
+        ('teammembership', 'proposed_by'),
+        ('teammembership', 'reviewed_by'),
+        ('translationimportqueueentry', 'importer'),
+        ('translationmessage', 'reviewer'),
+        ('translationmessage', 'submitter'),
+        ('usertouseremail', 'recipient'),
+        ('usertouseremail', 'sender'),
+        ('xref', 'creator'),
+        }
+    reference_names = {
+        (src_tab, src_col) for src_tab, src_col, _, _, _, _ in references}
+    for src_tab, src_col in skip:
+        if (src_tab, src_col) not in reference_names:
+            raise AssertionError(
+                "%s.%s is not a Person reference; possible typo?" %
+                (src_tab, src_col))
+
+    # XXX cjwatson 2018-11-29: Registrants could possibly be left as-is, but
+    # perhaps we should pretend that the registrant was ~registry in that
+    # case instead?
+
     # Remove the EmailAddress. This is the most important step, as
     # people requesting account removal seem to primarily be interested
     # in ensuring we no longer store this information.
@@ -65,41 +158,64 @@
 
     # Clean out personal details from the Person table
     table_notification('Person')
-    store.find(Person, Person.id == person_id).set(
-        display_name='Removed by request',
-        name=new_name,
-        accountID=None,
-        homepage_content=None,
-        iconID=None,
-        mugshotID=None,
-        hide_email_addresses=True,
-        registrantID=None,
-        logoID=None,
-        creation_rationale=PersonCreationRationale.UNKNOWN,
-        creation_comment=None)
-
-    # Remove the Account. We don't set the status to deactivated,
-    # as this script is used to satisfy people who insist on us removing
-    # all their personal details from our systems. This includes any
-    # identification tokens like email addresses or openid identifiers.
-    # So the Account record would be unusable, and contain no useful
-    # information.
-    table_notification('Account')
+    person = removeSecurityProxy(getUtility(IPersonSet).get(person_id))
+    person.display_name = 'Removed by request'
+    person.name = new_name
+    person.homepage_content = None
+    person.icon = None
+    person.mugshot = None
+    person.hide_email_addresses = False
+    person.registrant = None
+    person.logo = None
+    person.creation_rationale = PersonCreationRationale.UNKNOWN
+    person.creation_comment = None
+
+    # Keep the corresponding PersonSettings row, but reset everything to the
+    # defaults.
+    table_notification('PersonSettings')
+    store.find(PersonSettings, PersonSettings.personID == person_id).set(
+        selfgenerated_bugnotifications=DEFAULT,
+        # XXX cjwatson 2018-11-29: These two columns have NULL defaults, but
+        # perhaps shouldn't?
+        expanded_notification_footers=False,
+        require_strong_email_authentication=False)
+    skip.add(('personsettings', 'person'))
+
+    # Remove almost everything from the Account row and the corresponding
+    # OpenIdIdentifier rows, preserving only a minimal audit trail.
     if account_id is not None:
-        store.find(Account, Account.id == account_id).remove()
+        table_notification('Account')
+        account = removeSecurityProxy(getUtility(IAccountSet).get(account_id))
+        account.displayname = 'Removed by request'
+        account.creation_rationale = AccountCreationRationale.UNKNOWN
+        janitor = getUtility(ILaunchpadCelebrities).janitor
+        person.setAccountStatus(
+            AccountStatus.CLOSED, janitor, "Closed using close-account.")
+
+        table_notification('OpenIdIdentifier')
+        store.find(
+            OpenIdIdentifier,
+            OpenIdIdentifier.account_id == account_id).remove()
 
     # Reassign their bugs
     table_notification('BugTask')
     store.find(BugTask, BugTask.assigneeID == person_id).set(assigneeID=None)
 
     # Reassign questions assigned to the user, and close all their questions
-    # since nobody else can
+    # in non-final states since nobody else can.
     table_notification('Question')
     store.find(Question, Question.assigneeID == person_id).set(assigneeID=None)
-    store.find(Question, Question.ownerID == person_id).set(
+    owned_non_final_questions = store.find(
+        Question, Question.ownerID == person_id,
+        Question.status.is_in([
+            QuestionStatus.OPEN, QuestionStatus.NEEDSINFO,
+            QuestionStatus.ANSWERED,
+            ]))
+    owned_non_final_questions.set(
         status=QuestionStatus.SOLVED,
         whiteboard=(
             'Closed by Launchpad due to owner requesting account removal'))
+    skip.add(('question', 'owner'))
 
     # Remove rows from tables in simple cases in the given order
     removals = [
@@ -116,10 +232,14 @@
 
         # Subscriptions
         ('BranchSubscription', 'person'),
+        ('BugMute', 'person'),
+        ('BugSubscription', 'person'),
+        ('BugSubscriptionFilterMute', 'person'),
         ('GitSubscription', 'person'),
-        ('BugSubscription', 'person'),
+        ('MailingListSubscription', 'person'),
         ('QuestionSubscription', 'person'),
         ('SpecificationSubscription', 'person'),
+        ('StructuralSubscription', 'subscriber'),
 
         # Personal stuff, freeing up the namespace for others who want to play
         # or just to remove any fingerprints identifying the user.
@@ -146,7 +266,11 @@
         ('POExportRequest', 'person'),
 
         # Access grants
+        ('AccessArtifactGrant', 'grantee'),
+        ('AccessPolicyGrant', 'grantee'),
+        ('ArchivePermission', 'person'),
         ('GitRuleGrant', 'grantee'),
+        ('SharingJob', 'grantee'),
         ]
     for table, person_id_column in removals:
         table_notification(table)
@@ -167,6 +291,33 @@
             AND attendee = ?
             AND Sprint.time_starts > CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
         """, (person_id,))
+    # Any remaining past sprint attendance records can harmlessly refer to
+    # the placeholder person row.
+    skip.add(('sprintattendance', 'attendee'))
+
+    # Closing the account will only work if all references have been handled
+    # by this point.  If not, it's safer to bail out.  It's OK if this
+    # doesn't work in all conceivable situations, since some of them may
+    # require careful thought and decisions by a human administrator.
+    has_references = False
+    for src_tab, src_col, ref_tab, ref_col, updact, delact in references:
+        if (src_tab, src_col) in skip:
+            continue
+        result = store.execute("""
+            SELECT COUNT(*) FROM %(src_tab)s WHERE %(src_col)s = ?
+            """ % {
+                'src_tab': src_tab,
+                'src_col': src_col,
+                },
+            (person_id,))
+        count = result.get_one()[0]
+        if count:
+            log.error(
+                "User %s is still referenced by %d %s.%s values" %
+                (username, count, src_tab, src_col))
+            has_references = True
+    if has_references:
+        raise LaunchpadScriptFailure("User %s is still referenced" % username)
 
     return True
 

=== modified file 'lib/lp/registry/scripts/tests/test_closeaccount.py'
--- lib/lp/registry/scripts/tests/test_closeaccount.py	2018-11-29 19:04:47 +0000
+++ lib/lp/registry/scripts/tests/test_closeaccount.py	2018-11-29 19:04:47 +0000
@@ -7,20 +7,28 @@
 
 __metaclass__ = type
 
+from testtools.matchers import (
+    Not,
+    StartsWith,
+    )
+import transaction
+from twisted.python.compat import nativeString
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
+from lp.answers.enums import QuestionStatus
 from lp.registry.interfaces.person import IPersonSet
 from lp.registry.scripts.closeaccount import CloseAccountScript
+from lp.services.database.sqlbase import flush_database_caches
 from lp.services.identity.interfaces.account import (
     AccountStatus,
     IAccountSet,
     )
-from lp.services.log.logger import DevNullLogger
+from lp.services.identity.interfaces.emailaddress import IEmailAddressSet
+from lp.services.log.logger import BufferLogger
 from lp.services.scripts.base import LaunchpadScriptFailure
 from lp.testing import TestCaseWithFactory
 from lp.testing.dbuser import dbuser
-from lp.testing.faketransaction import FakeTransaction
 from lp.testing.layers import ZopelessDatabaseLayer
 
 
@@ -37,38 +45,81 @@
 
     def makeScript(self, test_args):
         script = CloseAccountScript(test_args=test_args)
-        script.logger = DevNullLogger()
-        script.txn = FakeTransaction()
+        script.logger = BufferLogger()
+        script.txn = transaction
         return script
 
-    def getSampleUser(self, name, email):
-        """Return a sampledata account with some personal information."""
-        person = getUtility(IPersonSet).getByEmail(email)
-        account = removeSecurityProxy(person.account)
-        self.assertEqual(AccountStatus.ACTIVE, account.status)
-        self.assertEqual(name, person.name)
-        return person.id, account.id
+    def runScript(self, script):
+        try:
+            script.main()
+        finally:
+            self.addDetail("log", script.logger.content)
+            flush_database_caches()
+
+    def makePopulatedUser(self):
+        """Return a person and account linked to some personal information."""
+        person = self.factory.makePerson(karma=10)
+        self.assertEqual(AccountStatus.ACTIVE, person.account.status)
+        self.assertNotEqual([], list(person.account.openid_identifiers))
+        self.factory.makeBugTask().transitionToAssignee(person, validate=False)
+        self.factory.makeQuestion().assignee = person
+        self.factory.makeQuestion(owner=person)
+        self.factory.makeGPGKey(owner=person)
+        self.factory.makeBranchSubscription(person=person)
+        self.factory.makeBug().subscribe(person, person)
+        self.factory.makeGitSubscription(person=person)
+        team = self.factory.makeTeam()
+        self.factory.makeMailingList(team, team.teamowner).subscribe(person)
+        self.factory.makeQuestionSubscription(person=person)
+        self.factory.makeSpecification().subscribe(person)
+        person.addLanguage(self.factory.makeLanguage())
+        self.factory.makeSSHKey(person=person)
+        self.factory.makeAccessArtifactGrant(grantee=person)
+        self.factory.makeAccessPolicyGrant(grantee=person)
+        self.factory.makeGitRuleGrant(grantee=person)
+        person.selfgenerated_bugnotifications = True
+        person.expanded_notification_footers = True
+        person.require_strong_email_authentication = True
+        return person, person.id, person.account.id
 
     def assertRemoved(self, account_id, person_id):
-        # We can't just set the account to DEACTIVATED, as the
-        # close-account.py script is used to satisfy people who insist on us
-        # removing all their personal details from our system.  The Account
-        # has been removed entirely.
-        self.assertRaises(
-            LookupError, getUtility(IAccountSet).get, account_id)
+        # The Account row still exists, but has been anonymised, leaving
+        # only a minimal audit trail.
+        account = getUtility(IAccountSet).get(account_id)
+        self.assertEqual('Removed by request', account.displayname)
+        self.assertEqual(AccountStatus.CLOSED, account.status)
+        self.assertIn('Closed using close-account.', account.status_history)
 
-        # The Person record still exists to maintain links with information
+        # The Person row still exists to maintain links with information
         # that won't be removed, such as bug comments, but has been
-        # anonymized.
+        # anonymised.
         person = getUtility(IPersonSet).get(person_id)
-        self.assertStartsWith(person.name, 'removed')
+        self.assertThat(person.name, StartsWith('removed'))
         self.assertEqual('Removed by request', person.display_name)
+        self.assertEqual(account, person.account)
+
+        # The corresponding PersonSettings row has been reset to the
+        # defaults.
+        self.assertFalse(person.selfgenerated_bugnotifications)
+        self.assertFalse(person.expanded_notification_footers)
+        self.assertFalse(person.require_strong_email_authentication)
+
+        # EmailAddress and OpenIdIdentifier rows have been removed.
+        self.assertEqual(
+            [], list(getUtility(IEmailAddressSet).getByPerson(person)))
+        self.assertEqual([], list(account.openid_identifiers))
 
     def assertNotRemoved(self, account_id, person_id):
         account = getUtility(IAccountSet).get(account_id)
+        self.assertNotEqual('Removed by request', account.displayname)
         self.assertEqual(AccountStatus.ACTIVE, account.status)
         person = getUtility(IPersonSet).get(person_id)
-        self.assertIsNotNone(person.account)
+        self.assertEqual(account, person.account)
+        self.assertNotEqual('Removed by request', person.display_name)
+        self.assertThat(person.name, Not(StartsWith('removed')))
+        self.assertNotEqual(
+            [], list(getUtility(IEmailAddressSet).getByPerson(person)))
+        self.assertNotEqual([], list(account.openid_identifiers))
 
     def test_nonexistent(self):
         script = self.makeScript(['nonexistent-person'])
@@ -76,7 +127,7 @@
             self.assertRaisesWithContent(
                 LaunchpadScriptFailure,
                 'User nonexistent-person does not exist',
-                script.main)
+                self.runScript, script)
 
     def test_team(self):
         team = self.factory.makeTeam()
@@ -85,20 +136,37 @@
             self.assertRaisesWithContent(
                 LaunchpadScriptFailure,
                 '%s is a team' % team.name,
-                script.main)
+                self.runScript, script)
+
+    def test_unhandled_reference(self):
+        person = self.factory.makePerson()
+        person_id = person.id
+        account_id = person.account.id
+        self.factory.makeProduct(owner=person)
+        script = self.makeScript([nativeString(person.name)])
+        with dbuser('launchpad'):
+            self.assertRaisesWithContent(
+                LaunchpadScriptFailure,
+                'User %s is still referenced' % person.name,
+                self.runScript, script)
+        self.assertIn(
+            'ERROR User %s is still referenced by 1 product.owner values' % (
+                person.name),
+            script.logger.getLogBuffer())
+        self.assertNotRemoved(account_id, person_id)
 
     def test_single_by_name(self):
-        person_id, account_id = self.getSampleUser('mark', 'mark@xxxxxxxxxxx')
-        script = self.makeScript(['mark'])
+        person, person_id, account_id = self.makePopulatedUser()
+        script = self.makeScript([nativeString(person.name)])
         with dbuser('launchpad'):
-            script.main()
+            self.runScript(script)
         self.assertRemoved(account_id, person_id)
 
     def test_single_by_email(self):
-        person_id, account_id = self.getSampleUser('mark', 'mark@xxxxxxxxxxx')
-        script = self.makeScript(['mark@xxxxxxxxxxx'])
+        person, person_id, account_id = self.makePopulatedUser()
+        script = self.makeScript([nativeString(person.preferredemail.email)])
         with dbuser('launchpad'):
-            script.main()
+            self.runScript(script)
         self.assertRemoved(account_id, person_id)
 
     def test_multiple(self):
@@ -107,7 +175,64 @@
         account_ids = [person.account.id for person in persons]
         script = self.makeScript([persons[0].name, persons[1].name])
         with dbuser('launchpad'):
-            script.main()
+            self.runScript(script)
         self.assertRemoved(account_ids[0], person_ids[0])
         self.assertRemoved(account_ids[1], person_ids[1])
         self.assertNotRemoved(account_ids[2], person_ids[2])
+
+    def test_retains_audit_trail(self):
+        person = self.factory.makePerson()
+        person_id = person.id
+        account_id = person.account.id
+        branch_subscription = self.factory.makeBranchSubscription(
+            subscribed_by=person)
+        snap = self.factory.makeSnap()
+        snap_build = self.factory.makeSnapBuild(requester=person, snap=snap)
+        specification = self.factory.makeSpecification(drafter=person)
+        script = self.makeScript([nativeString(person.name)])
+        with dbuser('launchpad'):
+            self.runScript(script)
+        self.assertRemoved(account_id, person_id)
+        self.assertEqual(person, branch_subscription.subscribed_by)
+        self.assertEqual(person, snap_build.requester)
+        self.assertEqual(person, specification.drafter)
+
+    def test_solves_questions_in_non_final_states(self):
+        person = self.factory.makePerson()
+        person_id = person.id
+        account_id = person.account.id
+        questions = []
+        for status in (
+                QuestionStatus.OPEN, QuestionStatus.NEEDSINFO,
+                QuestionStatus.ANSWERED):
+            question = self.factory.makeQuestion(owner=person)
+            removeSecurityProxy(question).status = status
+            questions.append(question)
+        script = self.makeScript([nativeString(person.name)])
+        with dbuser('launchpad'):
+            self.runScript(script)
+        self.assertRemoved(account_id, person_id)
+        for question in questions:
+            self.assertEqual(QuestionStatus.SOLVED, question.status)
+            self.assertEqual(
+                'Closed by Launchpad due to owner requesting account removal',
+                question.whiteboard)
+
+    def test_skips_questions_in_final_states(self):
+        person = self.factory.makePerson()
+        person_id = person.id
+        account_id = person.account.id
+        questions = {}
+        for status in (
+                QuestionStatus.SOLVED, QuestionStatus.EXPIRED,
+                QuestionStatus.INVALID):
+            question = self.factory.makeQuestion(owner=person)
+            removeSecurityProxy(question).status = status
+            questions[status] = question
+        script = self.makeScript([nativeString(person.name)])
+        with dbuser('launchpad'):
+            self.runScript(script)
+        self.assertRemoved(account_id, person_id)
+        for question_status, question in questions.items():
+            self.assertEqual(question_status, question.status)
+            self.assertIsNone(question.whiteboard)

=== modified file 'lib/lp/services/database/postgresql.py'
--- lib/lp/services/database/postgresql.py	2018-03-21 14:54:49 +0000
+++ lib/lp/services/database/postgresql.py	2018-11-29 19:04:47 +0000
@@ -240,6 +240,20 @@
     return rv
 
 
+def check_indirect_references(references):
+    # Sanity check. If we have an indirect reference, it must
+    # be ON DELETE CASCADE. We only have one case of this at the moment,
+    # but this code ensures we catch any new ones added incorrectly.
+    for src_tab, src_col, ref_tab, ref_col, updact, delact in references:
+        # If the ref_tab and ref_col is not Person.id, then we have
+        # an indirect reference. Ensure the update action is 'CASCADE'
+        if ref_tab != 'person' and ref_col != 'id':
+            if updact != 'c':
+                raise RuntimeError(
+                    '%s.%s reference to %s.%s must be ON UPDATE CASCADE'
+                    % (src_tab, src_col, ref_tab, ref_col))
+
+
 def generateResetSequencesSQL(cur):
     """Return SQL that will reset table sequences to match the data in them.
     """

=== modified file 'lib/lp/services/identity/interfaces/account.py'
--- lib/lp/services/identity/interfaces/account.py	2016-04-28 02:25:46 +0000
+++ lib/lp/services/identity/interfaces/account.py	2018-11-29 19:04:47 +0000
@@ -84,10 +84,17 @@
         The account has been suspended by a Launchpad admin.
         """)
 
+    CLOSED = DBItem(50, """
+        Closed
+
+        The account has been permanently closed, removing as much personal
+        information as possible.
+        """)
+
 
 INACTIVE_ACCOUNT_STATUSES = [
     AccountStatus.PLACEHOLDER, AccountStatus.DEACTIVATED,
-    AccountStatus.SUSPENDED]
+    AccountStatus.SUSPENDED, AccountStatus.CLOSED]
 
 
 class AccountCreationRationale(DBEnumeratedType):
@@ -238,9 +245,13 @@
             AccountStatus.NOACCOUNT, AccountStatus.ACTIVE],
         AccountStatus.NOACCOUNT: [AccountStatus.ACTIVE],
         AccountStatus.ACTIVE: [
-            AccountStatus.DEACTIVATED, AccountStatus.SUSPENDED],
-        AccountStatus.DEACTIVATED: [AccountStatus.ACTIVE],
-        AccountStatus.SUSPENDED: [AccountStatus.DEACTIVATED],
+            AccountStatus.DEACTIVATED, AccountStatus.SUSPENDED,
+            AccountStatus.CLOSED],
+        AccountStatus.DEACTIVATED: [
+            AccountStatus.ACTIVE, AccountStatus.CLOSED],
+        AccountStatus.SUSPENDED: [
+            AccountStatus.DEACTIVATED, AccountStatus.CLOSED],
+        AccountStatus.CLOSED: [],
         }
 
     def constraint(self, value):

=== modified file 'lib/lp/services/identity/tests/test_account.py'
--- lib/lp/services/identity/tests/test_account.py	2015-01-07 00:35:41 +0000
+++ lib/lp/services/identity/tests/test_account.py	2018-11-29 19:04:47 +0000
@@ -54,31 +54,38 @@
         account = self.factory.makeAccount(status=AccountStatus.NOACCOUNT)
         login_celebrity('admin')
         self.assertCannotTransition(
-            account, [AccountStatus.DEACTIVATED, AccountStatus.SUSPENDED])
+            account,
+            [AccountStatus.DEACTIVATED, AccountStatus.SUSPENDED,
+             AccountStatus.CLOSED])
         self.assertCanTransition(account, [AccountStatus.ACTIVE])
 
     def test_status_from_active(self):
-        # The status may change from ACTIVE to DEACTIVATED or SUSPENDED.
+        # The status may change from ACTIVE to DEACTIVATED, SUSPENDED, or
+        # CLOSED.
         account = self.factory.makeAccount(status=AccountStatus.ACTIVE)
         login_celebrity('admin')
         self.assertCannotTransition(account, [AccountStatus.NOACCOUNT])
         self.assertCanTransition(
-            account, [AccountStatus.DEACTIVATED, AccountStatus.SUSPENDED])
+            account,
+            [AccountStatus.DEACTIVATED, AccountStatus.SUSPENDED,
+             AccountStatus.CLOSED])
 
     def test_status_from_deactivated(self):
-        # The status may change from DEACTIVATED to ACTIVATED.
+        # The status may change from DEACTIVATED to ACTIVATED or CLOSED.
         account = self.factory.makeAccount()
         login_celebrity('admin')
         account.setStatus(AccountStatus.DEACTIVATED, None, 'gbcw')
         self.assertCannotTransition(
             account, [AccountStatus.NOACCOUNT, AccountStatus.SUSPENDED])
-        self.assertCanTransition(account, [AccountStatus.ACTIVE])
+        self.assertCanTransition(
+            account, [AccountStatus.ACTIVE, AccountStatus.CLOSED])
 
     def test_status_from_suspended(self):
-        # The status may change from SUSPENDED to DEACTIVATED.
+        # The status may change from SUSPENDED to DEACTIVATED or CLOSED.
         account = self.factory.makeAccount()
         login_celebrity('admin')
         account.setStatus(AccountStatus.SUSPENDED, None, 'spammer!')
         self.assertCannotTransition(
             account, [AccountStatus.NOACCOUNT, AccountStatus.ACTIVE])
-        self.assertCanTransition(account, [AccountStatus.DEACTIVATED])
+        self.assertCanTransition(
+            account, [AccountStatus.DEACTIVATED, AccountStatus.CLOSED])


Follow ups