Merge lp:~cjwatson/launchpad/close-account-perms into lp:launchpad
- close-account-perms
- Merge into devel
Proposed by
Colin Watson
Status: | Merged |
---|---|
Merged at revision: | 18828 |
Proposed branch: | lp:~cjwatson/launchpad/close-account-perms |
Merge into: | lp:launchpad |
Diff against target: |
684 lines (+314/-312) 6 files modified
database/schema/security.cfg (+2/-0) lib/lp/registry/scripts/closeaccount.py (+193/-0) lib/lp/registry/scripts/tests/test_closeaccount.py (+113/-0) lib/lp/services/identity/doc/close-account.txt (+0/-83) lib/lp/services/identity/tests/test_doc.py (+3/-18) scripts/close-account.py (+3/-211) |
To merge this branch: | bzr merge lp:~cjwatson/launchpad/close-account-perms |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
William Grant | code | Approve | |
Review via email: mp+359773@code.launchpad.net |
Commit message
Make close-account work as a non-superuser.
Description of the change
I also Stormified and generally modernised close-account.
To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote : | # |
I've proposed https:/
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'database/schema/security.cfg' |
2 | --- database/schema/security.cfg 2018-10-16 10:10:10 +0000 |
3 | +++ database/schema/security.cfg 2018-11-28 18:58:57 +0000 |
4 | @@ -333,7 +333,9 @@ |
5 | [launchpad] |
6 | groups=launchpad_main |
7 | type=user |
8 | +public.karma = SELECT, INSERT, UPDATE, DELETE |
9 | public.sharingjob = SELECT, INSERT, UPDATE, DELETE |
10 | +public.signedcodeofconduct = SELECT, INSERT, UPDATE, DELETE |
11 | |
12 | [script] |
13 | public.accessartifact = SELECT |
14 | |
15 | === added file 'lib/lp/registry/scripts/closeaccount.py' |
16 | --- lib/lp/registry/scripts/closeaccount.py 1970-01-01 00:00:00 +0000 |
17 | +++ lib/lp/registry/scripts/closeaccount.py 2018-11-28 18:58:57 +0000 |
18 | @@ -0,0 +1,193 @@ |
19 | +# Copyright 2009-2018 Canonical Ltd. This software is licensed under the |
20 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
21 | + |
22 | +"""Remove personal details of a user from the database, leaving a stub.""" |
23 | + |
24 | +__metaclass__ = type |
25 | +__all__ = ['CloseAccountScript'] |
26 | + |
27 | +from storm.expr import ( |
28 | + LeftJoin, |
29 | + Lower, |
30 | + Or, |
31 | + ) |
32 | + |
33 | +from lp.answers.enums import QuestionStatus |
34 | +from lp.answers.model.question import Question |
35 | +from lp.bugs.model.bugtask import BugTask |
36 | +from lp.registry.interfaces.person import PersonCreationRationale |
37 | +from lp.registry.model.person import Person |
38 | +from lp.services.database.interfaces import IMasterStore |
39 | +from lp.services.identity.model.account import Account |
40 | +from lp.services.identity.model.emailaddress import EmailAddress |
41 | +from lp.services.scripts.base import ( |
42 | + LaunchpadScript, |
43 | + LaunchpadScriptFailure, |
44 | + ) |
45 | + |
46 | + |
47 | +def close_account(username, log): |
48 | + """Close a person's account. |
49 | + |
50 | + Return True on success, or log an error message and return False |
51 | + """ |
52 | + store = IMasterStore(Person) |
53 | + |
54 | + row = store.using( |
55 | + Person, |
56 | + LeftJoin(EmailAddress, Person.id == EmailAddress.personID) |
57 | + ).find( |
58 | + (Person.id, Person.accountID, Person.name, Person.teamownerID), |
59 | + Or(Person.name == username, |
60 | + Lower(EmailAddress.email) == Lower(username))).one() |
61 | + if row is None: |
62 | + raise LaunchpadScriptFailure("User %s does not exist" % username) |
63 | + person_id, account_id, username, teamowner_id = row |
64 | + |
65 | + # We don't do teams |
66 | + if teamowner_id is not None: |
67 | + raise LaunchpadScriptFailure("%s is a team" % username) |
68 | + |
69 | + log.info("Closing %s's account" % username) |
70 | + |
71 | + def table_notification(table): |
72 | + log.debug("Handling the %s table" % table) |
73 | + |
74 | + # All names starting with 'removed' are blacklisted, so this will always |
75 | + # succeed. |
76 | + new_name = 'removed%d' % person_id |
77 | + |
78 | + # Remove the EmailAddress. This is the most important step, as |
79 | + # people requesting account removal seem to primarily be interested |
80 | + # in ensuring we no longer store this information. |
81 | + table_notification('EmailAddress') |
82 | + store.find(EmailAddress, EmailAddress.personID == person_id).remove() |
83 | + |
84 | + # Clean out personal details from the Person table |
85 | + table_notification('Person') |
86 | + store.find(Person, Person.id == person_id).set( |
87 | + display_name='Removed by request', |
88 | + name=new_name, |
89 | + accountID=None, |
90 | + homepage_content=None, |
91 | + iconID=None, |
92 | + mugshotID=None, |
93 | + hide_email_addresses=True, |
94 | + registrantID=None, |
95 | + logoID=None, |
96 | + creation_rationale=PersonCreationRationale.UNKNOWN, |
97 | + creation_comment=None) |
98 | + |
99 | + # Remove the Account. We don't set the status to deactivated, |
100 | + # as this script is used to satisfy people who insist on us removing |
101 | + # all their personal details from our systems. This includes any |
102 | + # identification tokens like email addresses or openid identifiers. |
103 | + # So the Account record would be unusable, and contain no useful |
104 | + # information. |
105 | + table_notification('Account') |
106 | + if account_id is not None: |
107 | + store.find(Account, Account.id == account_id).remove() |
108 | + |
109 | + # Reassign their bugs |
110 | + table_notification('BugTask') |
111 | + store.find(BugTask, BugTask.assigneeID == person_id).set(assigneeID=None) |
112 | + |
113 | + # Reassign questions assigned to the user, and close all their questions |
114 | + # since nobody else can |
115 | + table_notification('Question') |
116 | + store.find(Question, Question.assigneeID == person_id).set(assigneeID=None) |
117 | + store.find(Question, Question.ownerID == person_id).set( |
118 | + status=QuestionStatus.SOLVED, |
119 | + whiteboard=( |
120 | + 'Closed by Launchpad due to owner requesting account removal')) |
121 | + |
122 | + # Remove rows from tables in simple cases in the given order |
123 | + removals = [ |
124 | + # Trash their email addresses. People who request complete account |
125 | + # removal would be unhappy if they reregistered with their old email |
126 | + # address and this resurrected their deleted account, as the email |
127 | + # address is probably the piece of data we store that they were most |
128 | + # concerned with being removed from our systems. |
129 | + ('EmailAddress', 'person'), |
130 | + |
131 | + # Trash their codes of conduct and GPG keys |
132 | + ('SignedCodeOfConduct', 'owner'), |
133 | + ('GpgKey', 'owner'), |
134 | + |
135 | + # Subscriptions |
136 | + ('BranchSubscription', 'person'), |
137 | + ('GitSubscription', 'person'), |
138 | + ('BugSubscription', 'person'), |
139 | + ('QuestionSubscription', 'person'), |
140 | + ('SpecificationSubscription', 'person'), |
141 | + |
142 | + # Personal stuff, freeing up the namespace for others who want to play |
143 | + # or just to remove any fingerprints identifying the user. |
144 | + ('IrcId', 'person'), |
145 | + ('JabberId', 'person'), |
146 | + ('WikiName', 'person'), |
147 | + ('PersonLanguage', 'person'), |
148 | + ('PersonLocation', 'person'), |
149 | + ('SshKey', 'person'), |
150 | + |
151 | + # Karma |
152 | + ('Karma', 'person'), |
153 | + ('KarmaCache', 'person'), |
154 | + ('KarmaTotalCache', 'person'), |
155 | + |
156 | + # Team memberships |
157 | + ('TeamMembership', 'person'), |
158 | + ('TeamParticipation', 'person'), |
159 | + |
160 | + # Contacts |
161 | + ('AnswerContact', 'person'), |
162 | + |
163 | + # Pending items in queues |
164 | + ('POExportRequest', 'person'), |
165 | + |
166 | + # Access grants |
167 | + ('GitRuleGrant', 'grantee'), |
168 | + ] |
169 | + for table, person_id_column in removals: |
170 | + table_notification(table) |
171 | + store.execute(""" |
172 | + DELETE FROM %(table)s WHERE %(person_id_column)s = ? |
173 | + """ % { |
174 | + 'table': table, |
175 | + 'person_id_column': person_id_column, |
176 | + }, |
177 | + (person_id,)) |
178 | + |
179 | + # Trash Sprint Attendance records in the future. |
180 | + table_notification('SprintAttendance') |
181 | + store.execute(""" |
182 | + DELETE FROM SprintAttendance |
183 | + USING Sprint |
184 | + WHERE Sprint.id = SprintAttendance.sprint |
185 | + AND attendee = ? |
186 | + AND Sprint.time_starts > CURRENT_TIMESTAMP AT TIME ZONE 'UTC' |
187 | + """, (person_id,)) |
188 | + |
189 | + return True |
190 | + |
191 | + |
192 | +class CloseAccountScript(LaunchpadScript): |
193 | + |
194 | + usage = '%prog [options] (username|email) [...]' |
195 | + description = ( |
196 | + "Close a person's account, deleting as much personal information " |
197 | + "as possible.") |
198 | + |
199 | + def main(self): |
200 | + if not self.args: |
201 | + raise LaunchpadScriptFailure( |
202 | + "Must specify username (Person.name)") |
203 | + |
204 | + for username in self.args: |
205 | + try: |
206 | + close_account(unicode(username), self.logger) |
207 | + except Exception: |
208 | + self.txn.abort() |
209 | + raise |
210 | + self.logger.debug("Committing changes") |
211 | + self.txn.commit() |
212 | |
213 | === added file 'lib/lp/registry/scripts/tests/test_closeaccount.py' |
214 | --- lib/lp/registry/scripts/tests/test_closeaccount.py 1970-01-01 00:00:00 +0000 |
215 | +++ lib/lp/registry/scripts/tests/test_closeaccount.py 2018-11-28 18:58:57 +0000 |
216 | @@ -0,0 +1,113 @@ |
217 | +# Copyright 2018 Canonical Ltd. This software is licensed under the |
218 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
219 | + |
220 | +"""Test the close-account script.""" |
221 | + |
222 | +from __future__ import absolute_import, print_function, unicode_literals |
223 | + |
224 | +__metaclass__ = type |
225 | + |
226 | +from zope.component import getUtility |
227 | +from zope.security.proxy import removeSecurityProxy |
228 | + |
229 | +from lp.registry.interfaces.person import IPersonSet |
230 | +from lp.registry.scripts.closeaccount import CloseAccountScript |
231 | +from lp.services.identity.interfaces.account import ( |
232 | + AccountStatus, |
233 | + IAccountSet, |
234 | + ) |
235 | +from lp.services.log.logger import DevNullLogger |
236 | +from lp.services.scripts.base import LaunchpadScriptFailure |
237 | +from lp.testing import TestCaseWithFactory |
238 | +from lp.testing.dbuser import dbuser |
239 | +from lp.testing.faketransaction import FakeTransaction |
240 | +from lp.testing.layers import ZopelessDatabaseLayer |
241 | + |
242 | + |
243 | +class TestCloseAccount(TestCaseWithFactory): |
244 | + """Test the close-account script. |
245 | + |
246 | + Unfortunately, we have no way of detecting schema updates containing new |
247 | + information that needs to be removed or sanitized on account closure |
248 | + apart from reviewers noticing and prompting developers to update this |
249 | + script. See Bug #120506 for more details. |
250 | + """ |
251 | + |
252 | + layer = ZopelessDatabaseLayer |
253 | + |
254 | + def makeScript(self, test_args): |
255 | + script = CloseAccountScript(test_args=test_args) |
256 | + script.logger = DevNullLogger() |
257 | + script.txn = FakeTransaction() |
258 | + return script |
259 | + |
260 | + def getSampleUser(self, name, email): |
261 | + """Return a sampledata account with some personal information.""" |
262 | + person = getUtility(IPersonSet).getByEmail(email) |
263 | + account = removeSecurityProxy(person.account) |
264 | + self.assertEqual(AccountStatus.ACTIVE, account.status) |
265 | + self.assertEqual(name, person.name) |
266 | + return person.id, account.id |
267 | + |
268 | + def assertRemoved(self, account_id, person_id): |
269 | + # We can't just set the account to DEACTIVATED, as the |
270 | + # close-account.py script is used to satisfy people who insist on us |
271 | + # removing all their personal details from our system. The Account |
272 | + # has been removed entirely. |
273 | + self.assertRaises( |
274 | + LookupError, getUtility(IAccountSet).get, account_id) |
275 | + |
276 | + # The Person record still exists to maintain links with information |
277 | + # that won't be removed, such as bug comments, but has been |
278 | + # anonymized. |
279 | + person = getUtility(IPersonSet).get(person_id) |
280 | + self.assertStartsWith(person.name, 'removed') |
281 | + self.assertEqual('Removed by request', person.display_name) |
282 | + |
283 | + def assertNotRemoved(self, account_id, person_id): |
284 | + account = getUtility(IAccountSet).get(account_id) |
285 | + self.assertEqual(AccountStatus.ACTIVE, account.status) |
286 | + person = getUtility(IPersonSet).get(person_id) |
287 | + self.assertIsNotNone(person.account) |
288 | + |
289 | + def test_nonexistent(self): |
290 | + script = self.makeScript(['nonexistent-person']) |
291 | + with dbuser('launchpad'): |
292 | + self.assertRaisesWithContent( |
293 | + LaunchpadScriptFailure, |
294 | + 'User nonexistent-person does not exist', |
295 | + script.main) |
296 | + |
297 | + def test_team(self): |
298 | + team = self.factory.makeTeam() |
299 | + script = self.makeScript([team.name]) |
300 | + with dbuser('launchpad'): |
301 | + self.assertRaisesWithContent( |
302 | + LaunchpadScriptFailure, |
303 | + '%s is a team' % team.name, |
304 | + script.main) |
305 | + |
306 | + def test_single_by_name(self): |
307 | + person_id, account_id = self.getSampleUser('mark', 'mark@example.com') |
308 | + script = self.makeScript(['mark']) |
309 | + with dbuser('launchpad'): |
310 | + script.main() |
311 | + self.assertRemoved(account_id, person_id) |
312 | + |
313 | + def test_single_by_email(self): |
314 | + person_id, account_id = self.getSampleUser('mark', 'mark@example.com') |
315 | + script = self.makeScript(['mark@example.com']) |
316 | + with dbuser('launchpad'): |
317 | + script.main() |
318 | + self.assertRemoved(account_id, person_id) |
319 | + |
320 | + def test_multiple(self): |
321 | + persons = [self.factory.makePerson() for _ in range(3)] |
322 | + person_ids = [person.id for person in persons] |
323 | + account_ids = [person.account.id for person in persons] |
324 | + script = self.makeScript([persons[0].name, persons[1].name]) |
325 | + with dbuser('launchpad'): |
326 | + script.main() |
327 | + self.assertRemoved(account_ids[0], person_ids[0]) |
328 | + self.assertRemoved(account_ids[1], person_ids[1]) |
329 | + self.assertNotRemoved(account_ids[2], person_ids[2]) |
330 | |
331 | === removed file 'lib/lp/services/identity/doc/close-account.txt' |
332 | --- lib/lp/services/identity/doc/close-account.txt 2011-12-29 05:29:36 +0000 |
333 | +++ lib/lp/services/identity/doc/close-account.txt 1970-01-01 00:00:00 +0000 |
334 | @@ -1,83 +0,0 @@ |
335 | -We have a command line script that can be used to close accounts. |
336 | - |
337 | -Unfortunately, we have no way of detecting schema updates containing new |
338 | -information that needs to be removed or sanitized on account closure apart |
339 | -from reviewers noticing and prompting developers to update this script. |
340 | - |
341 | -See Bug #120506 for more details. |
342 | - |
343 | - |
344 | -Get Mark's account and person entries. |
345 | ->>> from lp.registry.interfaces.person import IPersonSet |
346 | ->>> from zope.security.proxy import removeSecurityProxy |
347 | ->>> mark_person = getUtility(IPersonSet).getByEmail('mark@example.com') |
348 | ->>> mark_account = removeSecurityProxy(mark_person.account) |
349 | - |
350 | - |
351 | -Mark's account is active and contains personal information. |
352 | - |
353 | ->>> print mark_account.status.name |
354 | -ACTIVE |
355 | ->>> print mark_account.displayname |
356 | -Mark Shuttleworth |
357 | ->>> print mark_person.name |
358 | -mark |
359 | ->>> print mark_person.displayname |
360 | -Mark Shuttleworth |
361 | - |
362 | - |
363 | -Store the id's so we can retrieve the records later. |
364 | - |
365 | ->>> mark_person_id = mark_person.id |
366 | ->>> mark_account_id = mark_account.id |
367 | - |
368 | - |
369 | -Lets close his account. |
370 | - |
371 | ->>> import os.path |
372 | ->>> from lp.services.config import config |
373 | ->>> from lp.testing.script import run_script |
374 | ->>> script = os.path.join(config.root, 'scripts', 'close-account.py') |
375 | ->>> (result, out, err) = run_script(script, args=['mark@example.com']) |
376 | ->>> print result |
377 | -0 |
378 | ->>> print out |
379 | ->>> print err |
380 | -INFO...Closing mark's account |
381 | - |
382 | - |
383 | -Now, start a new transaction so we can see the changes the script made. |
384 | - |
385 | ->>> from lp.testing.layers import LaunchpadZopelessLayer |
386 | ->>> LaunchpadZopelessLayer.abort() |
387 | - |
388 | - |
389 | -We can't just set the account to DEACTIVATED, as the close-account.py |
390 | -script is used to satisty people who insist on us removing all their |
391 | -personal details from our system. The Account has been removed entirely. |
392 | - |
393 | ->>> from lp.services.identity.model.account import Account |
394 | ->>> Account.get(mark_account_id) |
395 | -Traceback (most recent call last): |
396 | -... |
397 | -SQLObjectNotFound: ... |
398 | - |
399 | - |
400 | -The Person record still exists to maintain links with information that won't |
401 | -be removed, such as bug comments, but has been anonymized. |
402 | - |
403 | ->>> from lp.registry.model.person import Person |
404 | ->>> mark_person = Person.get(mark_person_id) |
405 | ->>> print mark_person.name |
406 | -removed... |
407 | ->>> print mark_person.displayname |
408 | -Removed by request |
409 | - |
410 | - |
411 | -Flag the database as dirty since it has been modified without the test suite |
412 | -knowing. |
413 | - |
414 | ->>> from lp.testing.layers import DatabaseLayer |
415 | ->>> DatabaseLayer.force_dirty_database() |
416 | - |
417 | - |
418 | |
419 | === modified file 'lib/lp/services/identity/tests/test_doc.py' |
420 | --- lib/lp/services/identity/tests/test_doc.py 2012-01-01 02:58:52 +0000 |
421 | +++ lib/lp/services/identity/tests/test_doc.py 2018-11-28 18:58:57 +0000 |
422 | @@ -1,4 +1,4 @@ |
423 | -# Copyright 2009-2011 Canonical Ltd. This software is licensed under the |
424 | +# Copyright 2009-2018 Canonical Ltd. This software is licensed under the |
425 | # GNU Affero General Public License version 3 (see the file LICENSE). |
426 | |
427 | """ |
428 | @@ -8,27 +8,12 @@ |
429 | import os |
430 | |
431 | from lp.services.testing import build_test_suite |
432 | -from lp.testing.layers import ( |
433 | - DatabaseFunctionalLayer, |
434 | - LaunchpadZopelessLayer, |
435 | - ) |
436 | -from lp.testing.systemdocs import ( |
437 | - LayeredDocFileSuite, |
438 | - setUp, |
439 | - tearDown, |
440 | - ) |
441 | +from lp.testing.layers import DatabaseFunctionalLayer |
442 | |
443 | |
444 | here = os.path.dirname(os.path.realpath(__file__)) |
445 | |
446 | |
447 | -special = { |
448 | - 'close-account.txt': LayeredDocFileSuite( |
449 | - '../doc/close-account.txt', setUp=setUp, tearDown=tearDown, |
450 | - layer=LaunchpadZopelessLayer), |
451 | - } |
452 | - |
453 | - |
454 | def test_suite(): |
455 | - suite = build_test_suite(here, special, layer=DatabaseFunctionalLayer) |
456 | + suite = build_test_suite(here, layer=DatabaseFunctionalLayer) |
457 | return suite |
458 | |
459 | === modified file 'scripts/close-account.py' |
460 | --- scripts/close-account.py 2018-09-12 11:53:08 +0000 |
461 | +++ scripts/close-account.py 2018-11-28 18:58:57 +0000 |
462 | @@ -5,219 +5,11 @@ |
463 | |
464 | """Remove personal details of a user from the database, leaving a stub.""" |
465 | |
466 | -__metaclass__ = type |
467 | -__all__ = [] |
468 | - |
469 | import _pythonpath |
470 | |
471 | -from optparse import OptionParser |
472 | -import sys |
473 | - |
474 | -from lp.answers.enums import QuestionStatus |
475 | -from lp.registry.interfaces.person import PersonCreationRationale |
476 | -from lp.services.database.sqlbase import ( |
477 | - connect, |
478 | - sqlvalues, |
479 | - ) |
480 | -from lp.services.scripts import ( |
481 | - db_options, |
482 | - logger, |
483 | - logger_options, |
484 | - ) |
485 | - |
486 | - |
487 | -def close_account(con, log, username): |
488 | - """Close a person's account. |
489 | - |
490 | - Return True on success, or log an error message and return False |
491 | - """ |
492 | - cur = con.cursor() |
493 | - cur.execute(""" |
494 | - SELECT Person.id, Person.account, name, teamowner |
495 | - FROM Person |
496 | - LEFT OUTER JOIN EmailAddress ON Person.id = EmailAddress.person |
497 | - WHERE name = %(username)s OR lower(email) = lower(%(username)s) |
498 | - """, vars()) |
499 | - try: |
500 | - person_id, account_id, username, teamowner = cur.fetchone() |
501 | - except TypeError: |
502 | - log.fatal("User %s does not exist" % username) |
503 | - return False |
504 | - |
505 | - # We don't do teams |
506 | - if teamowner is not None: |
507 | - log.fatal("%s is a team" % username) |
508 | - return False |
509 | - |
510 | - log.info("Closing %s's account" % username) |
511 | - |
512 | - def table_notification(table): |
513 | - log.debug("Handling the %s table" % table) |
514 | - |
515 | - # All names starting with 'removed' are blacklisted, so this will always |
516 | - # succeed. |
517 | - new_name = 'removed%d' % person_id |
518 | - |
519 | - # Remove the EmailAddress. This is the most important step, as |
520 | - # people requesting account removal seem to primarily be interested |
521 | - # in ensuring we no longer store this information. |
522 | - table_notification('EmailAddress') |
523 | - cur.execute(""" |
524 | - DELETE FROM EmailAddress WHERE person = %s |
525 | - """ % sqlvalues(person_id)) |
526 | - |
527 | - # Clean out personal details from the Person table |
528 | - table_notification('Person') |
529 | - unknown_rationale = PersonCreationRationale.UNKNOWN.value |
530 | - cur.execute(""" |
531 | - UPDATE Person |
532 | - SET |
533 | - displayname = 'Removed by request', |
534 | - name=%(new_name)s, |
535 | - language = NULL, |
536 | - account = NULL, |
537 | - homepage_content = NULL, |
538 | - icon = NULL, |
539 | - mugshot = NULL, |
540 | - hide_email_addresses = TRUE, |
541 | - registrant = NULL, |
542 | - logo = NULL, |
543 | - creation_rationale = %(unknown_rationale)s, |
544 | - creation_comment = NULL |
545 | - WHERE id = %(person_id)s |
546 | - """, vars()) |
547 | - |
548 | - # Remove the Account. We don't set the status to deactivated, |
549 | - # as this script is used to satisfy people who insist on us removing |
550 | - # all their personal details from our systems. This includes any |
551 | - # identification tokens like email addresses or openid identifiers. |
552 | - # So the Account record would be unusable, and contain no useful |
553 | - # information. |
554 | - table_notification('Account') |
555 | - if account_id is not None: |
556 | - cur.execute(""" |
557 | - DELETE FROM Account WHERE id = %s |
558 | - """ % sqlvalues(account_id)) |
559 | - |
560 | - # Reassign their bugs |
561 | - table_notification('BugTask') |
562 | - cur.execute(""" |
563 | - UPDATE BugTask SET assignee = NULL WHERE assignee = %(person_id)s |
564 | - """, vars()) |
565 | - |
566 | - # Reassign questions assigned to the user, and close all their questions |
567 | - # since nobody else can |
568 | - table_notification('Question') |
569 | - cur.execute(""" |
570 | - UPDATE Question SET assignee=NULL WHERE assignee=%(person_id)s |
571 | - """, vars()) |
572 | - closed_question_status = QuestionStatus.SOLVED.value |
573 | - cur.execute(""" |
574 | - UPDATE Question |
575 | - SET status=%(closed_question_status)s, whiteboard= |
576 | - 'Closed by Launchpad due to owner requesting account removal' |
577 | - WHERE owner=%(person_id)s |
578 | - """, vars()) |
579 | - |
580 | - # Remove rows from tables in simple cases in the given order |
581 | - removals = [ |
582 | - # Trash their email addresses. People who request complete account |
583 | - # removal would be unhappy if they reregistered with their old email |
584 | - # address and this resurrected their deleted account, as the email |
585 | - # address is probably the piece of data we store that they were most |
586 | - # concerned with being removed from our systems. |
587 | - ('EmailAddress', 'person'), |
588 | - |
589 | - # Trash their codes of conduct and GPG keys |
590 | - ('SignedCodeOfConduct', 'owner'), |
591 | - ('GpgKey', 'owner'), |
592 | - |
593 | - # Subscriptions |
594 | - ('BranchSubscription', 'person'), |
595 | - ('GitSubscription', 'person'), |
596 | - ('BugSubscription', 'person'), |
597 | - ('QuestionSubscription', 'person'), |
598 | - ('SpecificationSubscription', 'person'), |
599 | - |
600 | - # Personal stuff, freeing up the namespace for others who want to play |
601 | - # or just to remove any fingerprints identifying the user. |
602 | - ('IrcId', 'person'), |
603 | - ('JabberId', 'person'), |
604 | - ('WikiName', 'person'), |
605 | - ('PersonLanguage', 'person'), |
606 | - ('PersonLocation', 'person'), |
607 | - ('SshKey', 'person'), |
608 | - |
609 | - # Karma |
610 | - ('Karma', 'person'), |
611 | - ('KarmaCache', 'person'), |
612 | - ('KarmaTotalCache', 'person'), |
613 | - |
614 | - # Team memberships |
615 | - ('TeamMembership', 'person'), |
616 | - ('TeamParticipation', 'person'), |
617 | - |
618 | - # Contacts |
619 | - ('AnswerContact', 'person'), |
620 | - |
621 | - # Pending items in queues |
622 | - ('POExportRequest', 'person'), |
623 | - |
624 | - # Access grants |
625 | - ('GitRuleGrant', 'grantee'), |
626 | - ] |
627 | - for table, person_id_column in removals: |
628 | - table_notification(table) |
629 | - cur.execute(""" |
630 | - DELETE FROM %(table)s WHERE %(person_id_column)s=%(person_id)d |
631 | - """ % vars()) |
632 | - |
633 | - # Trash Sprint Attendance records in the future. |
634 | - table_notification('SprintAttendance') |
635 | - cur.execute(""" |
636 | - DELETE FROM SprintAttendance |
637 | - USING Sprint |
638 | - WHERE Sprint.id = SprintAttendance.sprint |
639 | - AND attendee=%(person_id)s |
640 | - AND Sprint.time_starts > CURRENT_TIMESTAMP AT TIME ZONE 'UTC' |
641 | - """, vars()) |
642 | - |
643 | - return True |
644 | - |
645 | - |
646 | -def main(): |
647 | - parser = OptionParser( |
648 | - '%prog [options] (username|email) [...]' |
649 | - ) |
650 | - db_options(parser) |
651 | - logger_options(parser) |
652 | - |
653 | - (options, args) = parser.parse_args() |
654 | - |
655 | - if len(args) == 0: |
656 | - parser.error("Must specify username (Person.name)") |
657 | - |
658 | - log = logger(options) |
659 | - |
660 | - con = None |
661 | - try: |
662 | - log.debug("Connecting to database") |
663 | - con = connect() |
664 | - for username in args: |
665 | - if not close_account(con, log, username): |
666 | - log.debug("Rolling back") |
667 | - con.rollback() |
668 | - return 1 |
669 | - log.debug("Committing changes") |
670 | - con.commit() |
671 | - return 0 |
672 | - except: |
673 | - log.exception("Unhandled exception") |
674 | - log.debug("Rolling back") |
675 | - if con is not None: |
676 | - con.rollback() |
677 | - return 1 |
678 | +from lp.registry.scripts.closeaccount import CloseAccountScript |
679 | |
680 | |
681 | if __name__ == '__main__': |
682 | - sys.exit(main()) |
683 | + script = CloseAccountScript('close-account', dbuser='launchpad') |
684 | + script.run() |
I think we need to look over all Person FKs and work out what to do. The script has only been used a handful of times and then probably not for more than a decade, and it has weird stuff that bypasses audit trails, sets invalid questions to solved, wipes the audit trail in Account. status_ comment, etc.
The port to Storm seems fine, but this can't go near production yet.