Merge lp:~cjwatson/launchpad/close-account-polish into lp:launchpad
- close-account-polish
- Merge into devel
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 |
Related bugs: |
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.
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.
William Grant (wgrant) : | # |
Colin Watson (cjwatson) : | # |
Preview Diff
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]) |