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

Proposed by Colin Watson
Status: Merged
Merged at revision: 18829
Proposed branch: lp:~cjwatson/launchpad/close-account-polish
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/close-account-perms
Diff against target: 710 lines (+386/-93)
6 files modified
lib/lp/registry/personmerge.py (+1/-12)
lib/lp/registry/scripts/closeaccount.py (+187/-40)
lib/lp/registry/scripts/tests/test_closeaccount.py (+155/-30)
lib/lp/services/database/postgresql.py (+14/-0)
lib/lp/services/identity/interfaces/account.py (+15/-4)
lib/lp/services/identity/tests/test_account.py (+14/-7)
To merge this branch: bzr merge lp:~cjwatson/launchpad/close-account-polish
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+359865@code.launchpad.net

Commit message

Make close-account safer to run.

Description of the change

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.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
review: Approve (code)
Revision history for this message
Colin Watson (cjwatson) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/registry/personmerge.py'
2--- lib/lp/registry/personmerge.py 2018-11-29 13:48:39 +0000
3+++ lib/lp/registry/personmerge.py 2018-12-03 11:35:13 +0000
4@@ -771,18 +771,7 @@
5 ]
6
7 references = list(postgresql.listReferences(cur, 'person', 'id'))
8-
9- # Sanity check. If we have an indirect reference, it must
10- # be ON DELETE CASCADE. We only have one case of this at the moment,
11- # but this code ensures we catch any new ones added incorrectly.
12- for src_tab, src_col, ref_tab, ref_col, updact, delact in references:
13- # If the ref_tab and ref_col is not Person.id, then we have
14- # an indirect reference. Ensure the update action is 'CASCADE'
15- if ref_tab != 'person' and ref_col != 'id':
16- if updact != 'c':
17- raise RuntimeError(
18- '%s.%s reference to %s.%s must be ON UPDATE CASCADE'
19- % (src_tab, src_col, ref_tab, ref_col))
20+ postgresql.check_indirect_references(references)
21
22 # These rows are in a UNIQUE index, and we can only move them
23 # to the new Person if there is not already an entry. eg. if
24
25=== modified file 'lib/lp/registry/scripts/closeaccount.py'
26--- lib/lp/registry/scripts/closeaccount.py 2018-12-03 11:35:13 +0000
27+++ lib/lp/registry/scripts/closeaccount.py 2018-12-03 11:35:13 +0000
28@@ -11,15 +11,28 @@
29 Lower,
30 Or,
31 )
32+from zope.component import getUtility
33+from zope.security.proxy import removeSecurityProxy
34
35 from lp.answers.enums import QuestionStatus
36 from lp.answers.model.question import Question
37+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
38 from lp.bugs.model.bugtask import BugTask
39 from lp.registry.interfaces.person import PersonCreationRationale
40-from lp.registry.model.person import Person
41+from lp.registry.model.person import (
42+ Person,
43+ PersonSettings,
44+ )
45+from lp.services.database import postgresql
46+from lp.services.database.constants import DEFAULT
47 from lp.services.database.interfaces import IMasterStore
48-from lp.services.identity.model.account import Account
49+from lp.services.database.sqlbase import cursor
50+from lp.services.identity.interfaces.account import (
51+ AccountCreationRationale,
52+ AccountStatus,
53+ )
54 from lp.services.identity.model.emailaddress import EmailAddress
55+from lp.services.openid.model.openididentifier import OpenIdIdentifier
56 from lp.services.scripts.base import (
57 LaunchpadScript,
58 LaunchpadScriptFailure,
59@@ -33,73 +46,171 @@
60 """
61 store = IMasterStore(Person)
62
63- row = store.using(
64+ cur = cursor()
65+ references = list(postgresql.listReferences(cur, 'person', 'id'))
66+ postgresql.check_indirect_references(references)
67+
68+ person = store.using(
69 Person,
70 LeftJoin(EmailAddress, Person.id == EmailAddress.personID)
71 ).find(
72- (Person.id, Person.accountID, Person.name, Person.teamownerID),
73+ Person,
74 Or(Person.name == username,
75 Lower(EmailAddress.email) == Lower(username))).one()
76- if row is None:
77+ if person is None:
78 raise LaunchpadScriptFailure("User %s does not exist" % username)
79- person_id, account_id, username, teamowner_id = row
80+ person_name = person.name
81
82 # We don't do teams
83- if teamowner_id is not None:
84- raise LaunchpadScriptFailure("%s is a team" % username)
85+ if person.is_team:
86+ raise LaunchpadScriptFailure("%s is a team" % person_name)
87
88- log.info("Closing %s's account" % username)
89+ log.info("Closing %s's account" % person_name)
90
91 def table_notification(table):
92 log.debug("Handling the %s table" % table)
93
94 # All names starting with 'removed' are blacklisted, so this will always
95 # succeed.
96- new_name = 'removed%d' % person_id
97+ new_name = 'removed%d' % person.id
98+
99+ # Some references can safely remain in place and link to the cleaned-out
100+ # Person row.
101+ skip = {
102+ # These references express some kind of audit trail. The actions in
103+ # question still happened, and in some cases the rows may still have
104+ # functional significance (e.g. subscriptions or access grants), but
105+ # we no longer identify the actor.
106+ ('accessartifactgrant', 'grantor'),
107+ ('accesspolicygrant', 'grantor'),
108+ ('binarypackagepublishinghistory', 'removed_by'),
109+ ('branchmergeproposal', 'merge_reporter'),
110+ ('branchmergeproposal', 'merger'),
111+ ('branchmergeproposal', 'queuer'),
112+ ('branchmergeproposal', 'reviewer'),
113+ ('branchsubscription', 'subscribed_by'),
114+ ('bug', 'who_made_private'),
115+ ('bugactivity', 'person'),
116+ ('bugnomination', 'decider'),
117+ ('bugsubscription', 'subscribed_by'),
118+ ('faq', 'last_updated_by'),
119+ ('featureflagchangelogentry', 'person'),
120+ ('gitactivity', 'changee'),
121+ ('gitactivity', 'changer'),
122+ ('gitrule', 'creator'),
123+ ('gitrulegrant', 'grantor'),
124+ ('gitsubscription', 'subscribed_by'),
125+ ('messageapproval', 'disposed_by'),
126+ ('messageapproval', 'posted_by'),
127+ ('packagecopyrequest', 'requester'),
128+ ('packagediff', 'requester'),
129+ ('personlocation', 'last_modified_by'),
130+ ('persontransferjob', 'major_person'),
131+ ('persontransferjob', 'minor_person'),
132+ ('poexportrequest', 'person'),
133+ ('question', 'answerer'),
134+ ('questionreopening', 'answerer'),
135+ ('questionreopening', 'reopener'),
136+ ('snapbuild', 'requester'),
137+ ('sourcepackagepublishinghistory', 'creator'),
138+ ('sourcepackagepublishinghistory', 'removed_by'),
139+ ('sourcepackagepublishinghistory', 'sponsor'),
140+ ('sourcepackagerecipebuild', 'requester'),
141+ ('specification', 'approver'),
142+ ('specification', 'completer'),
143+ ('specification', 'drafter'),
144+ ('specification', 'goal_decider'),
145+ ('specification', 'goal_proposer'),
146+ ('specification', 'last_changed_by'),
147+ ('specification', 'starter'),
148+ ('structuralsubscription', 'subscribed_by'),
149+ ('teammembership', 'acknowledged_by'),
150+ ('teammembership', 'proposed_by'),
151+ ('teammembership', 'reviewed_by'),
152+ ('translationimportqueueentry', 'importer'),
153+ ('translationmessage', 'reviewer'),
154+ ('translationmessage', 'submitter'),
155+ ('usertouseremail', 'recipient'),
156+ ('usertouseremail', 'sender'),
157+ ('xref', 'creator'),
158+ }
159+ reference_names = {
160+ (src_tab, src_col) for src_tab, src_col, _, _, _, _ in references}
161+ for src_tab, src_col in skip:
162+ if (src_tab, src_col) not in reference_names:
163+ raise AssertionError(
164+ "%s.%s is not a Person reference; possible typo?" %
165+ (src_tab, src_col))
166+
167+ # XXX cjwatson 2018-11-29: Registrants could possibly be left as-is, but
168+ # perhaps we should pretend that the registrant was ~registry in that
169+ # case instead?
170
171 # Remove the EmailAddress. This is the most important step, as
172 # people requesting account removal seem to primarily be interested
173 # in ensuring we no longer store this information.
174 table_notification('EmailAddress')
175- store.find(EmailAddress, EmailAddress.personID == person_id).remove()
176+ store.find(EmailAddress, EmailAddress.personID == person.id).remove()
177
178 # Clean out personal details from the Person table
179 table_notification('Person')
180- store.find(Person, Person.id == person_id).set(
181- display_name='Removed by request',
182- name=new_name,
183- accountID=None,
184- homepage_content=None,
185- iconID=None,
186- mugshotID=None,
187- hide_email_addresses=True,
188- registrantID=None,
189- logoID=None,
190- creation_rationale=PersonCreationRationale.UNKNOWN,
191- creation_comment=None)
192-
193- # Remove the Account. We don't set the status to deactivated,
194- # as this script is used to satisfy people who insist on us removing
195- # all their personal details from our systems. This includes any
196- # identification tokens like email addresses or openid identifiers.
197- # So the Account record would be unusable, and contain no useful
198- # information.
199- table_notification('Account')
200- if account_id is not None:
201- store.find(Account, Account.id == account_id).remove()
202+ person.display_name = 'Removed by request'
203+ person.name = new_name
204+ person.homepage_content = None
205+ person.icon = None
206+ person.mugshot = None
207+ person.hide_email_addresses = False
208+ person.registrant = None
209+ person.logo = None
210+ person.creation_rationale = PersonCreationRationale.UNKNOWN
211+ person.creation_comment = None
212+
213+ # Keep the corresponding PersonSettings row, but reset everything to the
214+ # defaults.
215+ table_notification('PersonSettings')
216+ store.find(PersonSettings, PersonSettings.personID == person.id).set(
217+ selfgenerated_bugnotifications=DEFAULT,
218+ # XXX cjwatson 2018-11-29: These two columns have NULL defaults, but
219+ # perhaps shouldn't?
220+ expanded_notification_footers=False,
221+ require_strong_email_authentication=False)
222+ skip.add(('personsettings', 'person'))
223+
224+ # Remove almost everything from the Account row and the corresponding
225+ # OpenIdIdentifier rows, preserving only a minimal audit trail.
226+ if person.account is not None:
227+ table_notification('Account')
228+ account = removeSecurityProxy(person.account)
229+ account.displayname = 'Removed by request'
230+ account.creation_rationale = AccountCreationRationale.UNKNOWN
231+ janitor = getUtility(ILaunchpadCelebrities).janitor
232+ person.setAccountStatus(
233+ AccountStatus.CLOSED, janitor, "Closed using close-account.")
234+
235+ table_notification('OpenIdIdentifier')
236+ store.find(
237+ OpenIdIdentifier,
238+ OpenIdIdentifier.account_id == account.id).remove()
239
240 # Reassign their bugs
241 table_notification('BugTask')
242- store.find(BugTask, BugTask.assigneeID == person_id).set(assigneeID=None)
243+ store.find(BugTask, BugTask.assigneeID == person.id).set(assigneeID=None)
244
245 # Reassign questions assigned to the user, and close all their questions
246- # since nobody else can
247+ # in non-final states since nobody else can.
248 table_notification('Question')
249- store.find(Question, Question.assigneeID == person_id).set(assigneeID=None)
250- store.find(Question, Question.ownerID == person_id).set(
251+ store.find(Question, Question.assigneeID == person.id).set(assigneeID=None)
252+ owned_non_final_questions = store.find(
253+ Question, Question.ownerID == person.id,
254+ Question.status.is_in([
255+ QuestionStatus.OPEN, QuestionStatus.NEEDSINFO,
256+ QuestionStatus.ANSWERED,
257+ ]))
258+ owned_non_final_questions.set(
259 status=QuestionStatus.SOLVED,
260 whiteboard=(
261 'Closed by Launchpad due to owner requesting account removal'))
262+ skip.add(('question', 'owner'))
263
264 # Remove rows from tables in simple cases in the given order
265 removals = [
266@@ -116,10 +227,14 @@
267
268 # Subscriptions
269 ('BranchSubscription', 'person'),
270+ ('BugMute', 'person'),
271+ ('BugSubscription', 'person'),
272+ ('BugSubscriptionFilterMute', 'person'),
273 ('GitSubscription', 'person'),
274- ('BugSubscription', 'person'),
275+ ('MailingListSubscription', 'person'),
276 ('QuestionSubscription', 'person'),
277 ('SpecificationSubscription', 'person'),
278+ ('StructuralSubscription', 'subscriber'),
279
280 # Personal stuff, freeing up the namespace for others who want to play
281 # or just to remove any fingerprints identifying the user.
282@@ -146,7 +261,11 @@
283 ('POExportRequest', 'person'),
284
285 # Access grants
286+ ('AccessArtifactGrant', 'grantee'),
287+ ('AccessPolicyGrant', 'grantee'),
288+ ('ArchivePermission', 'person'),
289 ('GitRuleGrant', 'grantee'),
290+ ('SharingJob', 'grantee'),
291 ]
292 for table, person_id_column in removals:
293 table_notification(table)
294@@ -156,7 +275,7 @@
295 'table': table,
296 'person_id_column': person_id_column,
297 },
298- (person_id,))
299+ (person.id,))
300
301 # Trash Sprint Attendance records in the future.
302 table_notification('SprintAttendance')
303@@ -166,7 +285,35 @@
304 WHERE Sprint.id = SprintAttendance.sprint
305 AND attendee = ?
306 AND Sprint.time_starts > CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
307- """, (person_id,))
308+ """, (person.id,))
309+ # Any remaining past sprint attendance records can harmlessly refer to
310+ # the placeholder person row.
311+ skip.add(('sprintattendance', 'attendee'))
312+
313+ # Closing the account will only work if all references have been handled
314+ # by this point. If not, it's safer to bail out. It's OK if this
315+ # doesn't work in all conceivable situations, since some of them may
316+ # require careful thought and decisions by a human administrator.
317+ has_references = False
318+ for src_tab, src_col, ref_tab, ref_col, updact, delact in references:
319+ if (src_tab, src_col) in skip:
320+ continue
321+ result = store.execute("""
322+ SELECT COUNT(*) FROM %(src_tab)s WHERE %(src_col)s = ?
323+ """ % {
324+ 'src_tab': src_tab,
325+ 'src_col': src_col,
326+ },
327+ (person.id,))
328+ count = result.get_one()[0]
329+ if count:
330+ log.error(
331+ "User %s is still referenced by %d %s.%s values" %
332+ (person_name, count, src_tab, src_col))
333+ has_references = True
334+ if has_references:
335+ raise LaunchpadScriptFailure(
336+ "User %s is still referenced" % person_name)
337
338 return True
339
340
341=== modified file 'lib/lp/registry/scripts/tests/test_closeaccount.py'
342--- lib/lp/registry/scripts/tests/test_closeaccount.py 2018-12-03 11:35:13 +0000
343+++ lib/lp/registry/scripts/tests/test_closeaccount.py 2018-12-03 11:35:13 +0000
344@@ -7,20 +7,28 @@
345
346 __metaclass__ = type
347
348+from testtools.matchers import (
349+ Not,
350+ StartsWith,
351+ )
352+import transaction
353+from twisted.python.compat import nativeString
354 from zope.component import getUtility
355 from zope.security.proxy import removeSecurityProxy
356
357+from lp.answers.enums import QuestionStatus
358 from lp.registry.interfaces.person import IPersonSet
359 from lp.registry.scripts.closeaccount import CloseAccountScript
360+from lp.services.database.sqlbase import flush_database_caches
361 from lp.services.identity.interfaces.account import (
362 AccountStatus,
363 IAccountSet,
364 )
365-from lp.services.log.logger import DevNullLogger
366+from lp.services.identity.interfaces.emailaddress import IEmailAddressSet
367+from lp.services.log.logger import BufferLogger
368 from lp.services.scripts.base import LaunchpadScriptFailure
369 from lp.testing import TestCaseWithFactory
370 from lp.testing.dbuser import dbuser
371-from lp.testing.faketransaction import FakeTransaction
372 from lp.testing.layers import ZopelessDatabaseLayer
373
374
375@@ -37,38 +45,81 @@
376
377 def makeScript(self, test_args):
378 script = CloseAccountScript(test_args=test_args)
379- script.logger = DevNullLogger()
380- script.txn = FakeTransaction()
381+ script.logger = BufferLogger()
382+ script.txn = transaction
383 return script
384
385- def getSampleUser(self, name, email):
386- """Return a sampledata account with some personal information."""
387- person = getUtility(IPersonSet).getByEmail(email)
388- account = removeSecurityProxy(person.account)
389- self.assertEqual(AccountStatus.ACTIVE, account.status)
390- self.assertEqual(name, person.name)
391- return person.id, account.id
392+ def runScript(self, script):
393+ try:
394+ script.main()
395+ finally:
396+ self.addDetail("log", script.logger.content)
397+ flush_database_caches()
398+
399+ def makePopulatedUser(self):
400+ """Return a person and account linked to some personal information."""
401+ person = self.factory.makePerson(karma=10)
402+ self.assertEqual(AccountStatus.ACTIVE, person.account.status)
403+ self.assertNotEqual([], list(person.account.openid_identifiers))
404+ self.factory.makeBugTask().transitionToAssignee(person, validate=False)
405+ self.factory.makeQuestion().assignee = person
406+ self.factory.makeQuestion(owner=person)
407+ self.factory.makeGPGKey(owner=person)
408+ self.factory.makeBranchSubscription(person=person)
409+ self.factory.makeBug().subscribe(person, person)
410+ self.factory.makeGitSubscription(person=person)
411+ team = self.factory.makeTeam()
412+ self.factory.makeMailingList(team, team.teamowner).subscribe(person)
413+ self.factory.makeQuestionSubscription(person=person)
414+ self.factory.makeSpecification().subscribe(person)
415+ person.addLanguage(self.factory.makeLanguage())
416+ self.factory.makeSSHKey(person=person)
417+ self.factory.makeAccessArtifactGrant(grantee=person)
418+ self.factory.makeAccessPolicyGrant(grantee=person)
419+ self.factory.makeGitRuleGrant(grantee=person)
420+ person.selfgenerated_bugnotifications = True
421+ person.expanded_notification_footers = True
422+ person.require_strong_email_authentication = True
423+ return person, person.id, person.account.id
424
425 def assertRemoved(self, account_id, person_id):
426- # We can't just set the account to DEACTIVATED, as the
427- # close-account.py script is used to satisfy people who insist on us
428- # removing all their personal details from our system. The Account
429- # has been removed entirely.
430- self.assertRaises(
431- LookupError, getUtility(IAccountSet).get, account_id)
432+ # The Account row still exists, but has been anonymised, leaving
433+ # only a minimal audit trail.
434+ account = getUtility(IAccountSet).get(account_id)
435+ self.assertEqual('Removed by request', account.displayname)
436+ self.assertEqual(AccountStatus.CLOSED, account.status)
437+ self.assertIn('Closed using close-account.', account.status_history)
438
439- # The Person record still exists to maintain links with information
440+ # The Person row still exists to maintain links with information
441 # that won't be removed, such as bug comments, but has been
442- # anonymized.
443+ # anonymised.
444 person = getUtility(IPersonSet).get(person_id)
445- self.assertStartsWith(person.name, 'removed')
446+ self.assertThat(person.name, StartsWith('removed'))
447 self.assertEqual('Removed by request', person.display_name)
448+ self.assertEqual(account, person.account)
449+
450+ # The corresponding PersonSettings row has been reset to the
451+ # defaults.
452+ self.assertFalse(person.selfgenerated_bugnotifications)
453+ self.assertFalse(person.expanded_notification_footers)
454+ self.assertFalse(person.require_strong_email_authentication)
455+
456+ # EmailAddress and OpenIdIdentifier rows have been removed.
457+ self.assertEqual(
458+ [], list(getUtility(IEmailAddressSet).getByPerson(person)))
459+ self.assertEqual([], list(account.openid_identifiers))
460
461 def assertNotRemoved(self, account_id, person_id):
462 account = getUtility(IAccountSet).get(account_id)
463+ self.assertNotEqual('Removed by request', account.displayname)
464 self.assertEqual(AccountStatus.ACTIVE, account.status)
465 person = getUtility(IPersonSet).get(person_id)
466- self.assertIsNotNone(person.account)
467+ self.assertEqual(account, person.account)
468+ self.assertNotEqual('Removed by request', person.display_name)
469+ self.assertThat(person.name, Not(StartsWith('removed')))
470+ self.assertNotEqual(
471+ [], list(getUtility(IEmailAddressSet).getByPerson(person)))
472+ self.assertNotEqual([], list(account.openid_identifiers))
473
474 def test_nonexistent(self):
475 script = self.makeScript(['nonexistent-person'])
476@@ -76,7 +127,7 @@
477 self.assertRaisesWithContent(
478 LaunchpadScriptFailure,
479 'User nonexistent-person does not exist',
480- script.main)
481+ self.runScript, script)
482
483 def test_team(self):
484 team = self.factory.makeTeam()
485@@ -85,20 +136,37 @@
486 self.assertRaisesWithContent(
487 LaunchpadScriptFailure,
488 '%s is a team' % team.name,
489- script.main)
490+ self.runScript, script)
491+
492+ def test_unhandled_reference(self):
493+ person = self.factory.makePerson()
494+ person_id = person.id
495+ account_id = person.account.id
496+ self.factory.makeProduct(owner=person)
497+ script = self.makeScript([nativeString(person.name)])
498+ with dbuser('launchpad'):
499+ self.assertRaisesWithContent(
500+ LaunchpadScriptFailure,
501+ 'User %s is still referenced' % person.name,
502+ self.runScript, script)
503+ self.assertIn(
504+ 'ERROR User %s is still referenced by 1 product.owner values' % (
505+ person.name),
506+ script.logger.getLogBuffer())
507+ self.assertNotRemoved(account_id, person_id)
508
509 def test_single_by_name(self):
510- person_id, account_id = self.getSampleUser('mark', 'mark@example.com')
511- script = self.makeScript(['mark'])
512+ person, person_id, account_id = self.makePopulatedUser()
513+ script = self.makeScript([nativeString(person.name)])
514 with dbuser('launchpad'):
515- script.main()
516+ self.runScript(script)
517 self.assertRemoved(account_id, person_id)
518
519 def test_single_by_email(self):
520- person_id, account_id = self.getSampleUser('mark', 'mark@example.com')
521- script = self.makeScript(['mark@example.com'])
522+ person, person_id, account_id = self.makePopulatedUser()
523+ script = self.makeScript([nativeString(person.preferredemail.email)])
524 with dbuser('launchpad'):
525- script.main()
526+ self.runScript(script)
527 self.assertRemoved(account_id, person_id)
528
529 def test_multiple(self):
530@@ -107,7 +175,64 @@
531 account_ids = [person.account.id for person in persons]
532 script = self.makeScript([persons[0].name, persons[1].name])
533 with dbuser('launchpad'):
534- script.main()
535+ self.runScript(script)
536 self.assertRemoved(account_ids[0], person_ids[0])
537 self.assertRemoved(account_ids[1], person_ids[1])
538 self.assertNotRemoved(account_ids[2], person_ids[2])
539+
540+ def test_retains_audit_trail(self):
541+ person = self.factory.makePerson()
542+ person_id = person.id
543+ account_id = person.account.id
544+ branch_subscription = self.factory.makeBranchSubscription(
545+ subscribed_by=person)
546+ snap = self.factory.makeSnap()
547+ snap_build = self.factory.makeSnapBuild(requester=person, snap=snap)
548+ specification = self.factory.makeSpecification(drafter=person)
549+ script = self.makeScript([nativeString(person.name)])
550+ with dbuser('launchpad'):
551+ self.runScript(script)
552+ self.assertRemoved(account_id, person_id)
553+ self.assertEqual(person, branch_subscription.subscribed_by)
554+ self.assertEqual(person, snap_build.requester)
555+ self.assertEqual(person, specification.drafter)
556+
557+ def test_solves_questions_in_non_final_states(self):
558+ person = self.factory.makePerson()
559+ person_id = person.id
560+ account_id = person.account.id
561+ questions = []
562+ for status in (
563+ QuestionStatus.OPEN, QuestionStatus.NEEDSINFO,
564+ QuestionStatus.ANSWERED):
565+ question = self.factory.makeQuestion(owner=person)
566+ removeSecurityProxy(question).status = status
567+ questions.append(question)
568+ script = self.makeScript([nativeString(person.name)])
569+ with dbuser('launchpad'):
570+ self.runScript(script)
571+ self.assertRemoved(account_id, person_id)
572+ for question in questions:
573+ self.assertEqual(QuestionStatus.SOLVED, question.status)
574+ self.assertEqual(
575+ 'Closed by Launchpad due to owner requesting account removal',
576+ question.whiteboard)
577+
578+ def test_skips_questions_in_final_states(self):
579+ person = self.factory.makePerson()
580+ person_id = person.id
581+ account_id = person.account.id
582+ questions = {}
583+ for status in (
584+ QuestionStatus.SOLVED, QuestionStatus.EXPIRED,
585+ QuestionStatus.INVALID):
586+ question = self.factory.makeQuestion(owner=person)
587+ removeSecurityProxy(question).status = status
588+ questions[status] = question
589+ script = self.makeScript([nativeString(person.name)])
590+ with dbuser('launchpad'):
591+ self.runScript(script)
592+ self.assertRemoved(account_id, person_id)
593+ for question_status, question in questions.items():
594+ self.assertEqual(question_status, question.status)
595+ self.assertIsNone(question.whiteboard)
596
597=== modified file 'lib/lp/services/database/postgresql.py'
598--- lib/lp/services/database/postgresql.py 2018-03-21 14:54:49 +0000
599+++ lib/lp/services/database/postgresql.py 2018-12-03 11:35:13 +0000
600@@ -240,6 +240,20 @@
601 return rv
602
603
604+def check_indirect_references(references):
605+ # Sanity check. If we have an indirect reference, it must
606+ # be ON DELETE CASCADE. We only have one case of this at the moment,
607+ # but this code ensures we catch any new ones added incorrectly.
608+ for src_tab, src_col, ref_tab, ref_col, updact, delact in references:
609+ # If the ref_tab and ref_col is not Person.id, then we have
610+ # an indirect reference. Ensure the update action is 'CASCADE'
611+ if ref_tab != 'person' and ref_col != 'id':
612+ if updact != 'c':
613+ raise RuntimeError(
614+ '%s.%s reference to %s.%s must be ON UPDATE CASCADE'
615+ % (src_tab, src_col, ref_tab, ref_col))
616+
617+
618 def generateResetSequencesSQL(cur):
619 """Return SQL that will reset table sequences to match the data in them.
620 """
621
622=== modified file 'lib/lp/services/identity/interfaces/account.py'
623--- lib/lp/services/identity/interfaces/account.py 2016-04-28 02:25:46 +0000
624+++ lib/lp/services/identity/interfaces/account.py 2018-12-03 11:35:13 +0000
625@@ -84,10 +84,17 @@
626 The account has been suspended by a Launchpad admin.
627 """)
628
629+ CLOSED = DBItem(50, """
630+ Closed
631+
632+ The account has been permanently closed, removing as much personal
633+ information as possible.
634+ """)
635+
636
637 INACTIVE_ACCOUNT_STATUSES = [
638 AccountStatus.PLACEHOLDER, AccountStatus.DEACTIVATED,
639- AccountStatus.SUSPENDED]
640+ AccountStatus.SUSPENDED, AccountStatus.CLOSED]
641
642
643 class AccountCreationRationale(DBEnumeratedType):
644@@ -238,9 +245,13 @@
645 AccountStatus.NOACCOUNT, AccountStatus.ACTIVE],
646 AccountStatus.NOACCOUNT: [AccountStatus.ACTIVE],
647 AccountStatus.ACTIVE: [
648- AccountStatus.DEACTIVATED, AccountStatus.SUSPENDED],
649- AccountStatus.DEACTIVATED: [AccountStatus.ACTIVE],
650- AccountStatus.SUSPENDED: [AccountStatus.DEACTIVATED],
651+ AccountStatus.DEACTIVATED, AccountStatus.SUSPENDED,
652+ AccountStatus.CLOSED],
653+ AccountStatus.DEACTIVATED: [
654+ AccountStatus.ACTIVE, AccountStatus.CLOSED],
655+ AccountStatus.SUSPENDED: [
656+ AccountStatus.DEACTIVATED, AccountStatus.CLOSED],
657+ AccountStatus.CLOSED: [],
658 }
659
660 def constraint(self, value):
661
662=== modified file 'lib/lp/services/identity/tests/test_account.py'
663--- lib/lp/services/identity/tests/test_account.py 2015-01-07 00:35:41 +0000
664+++ lib/lp/services/identity/tests/test_account.py 2018-12-03 11:35:13 +0000
665@@ -54,31 +54,38 @@
666 account = self.factory.makeAccount(status=AccountStatus.NOACCOUNT)
667 login_celebrity('admin')
668 self.assertCannotTransition(
669- account, [AccountStatus.DEACTIVATED, AccountStatus.SUSPENDED])
670+ account,
671+ [AccountStatus.DEACTIVATED, AccountStatus.SUSPENDED,
672+ AccountStatus.CLOSED])
673 self.assertCanTransition(account, [AccountStatus.ACTIVE])
674
675 def test_status_from_active(self):
676- # The status may change from ACTIVE to DEACTIVATED or SUSPENDED.
677+ # The status may change from ACTIVE to DEACTIVATED, SUSPENDED, or
678+ # CLOSED.
679 account = self.factory.makeAccount(status=AccountStatus.ACTIVE)
680 login_celebrity('admin')
681 self.assertCannotTransition(account, [AccountStatus.NOACCOUNT])
682 self.assertCanTransition(
683- account, [AccountStatus.DEACTIVATED, AccountStatus.SUSPENDED])
684+ account,
685+ [AccountStatus.DEACTIVATED, AccountStatus.SUSPENDED,
686+ AccountStatus.CLOSED])
687
688 def test_status_from_deactivated(self):
689- # The status may change from DEACTIVATED to ACTIVATED.
690+ # The status may change from DEACTIVATED to ACTIVATED or CLOSED.
691 account = self.factory.makeAccount()
692 login_celebrity('admin')
693 account.setStatus(AccountStatus.DEACTIVATED, None, 'gbcw')
694 self.assertCannotTransition(
695 account, [AccountStatus.NOACCOUNT, AccountStatus.SUSPENDED])
696- self.assertCanTransition(account, [AccountStatus.ACTIVE])
697+ self.assertCanTransition(
698+ account, [AccountStatus.ACTIVE, AccountStatus.CLOSED])
699
700 def test_status_from_suspended(self):
701- # The status may change from SUSPENDED to DEACTIVATED.
702+ # The status may change from SUSPENDED to DEACTIVATED or CLOSED.
703 account = self.factory.makeAccount()
704 login_celebrity('admin')
705 account.setStatus(AccountStatus.SUSPENDED, None, 'spammer!')
706 self.assertCannotTransition(
707 account, [AccountStatus.NOACCOUNT, AccountStatus.ACTIVE])
708- self.assertCanTransition(account, [AccountStatus.DEACTIVATED])
709+ self.assertCanTransition(
710+ account, [AccountStatus.DEACTIVATED, AccountStatus.CLOSED])