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

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
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
William Grant (wgrant) wrote :

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.

review: Approve (code)
Revision history for this message
Colin Watson (cjwatson) wrote :

I've proposed https://code.launchpad.net/~cjwatson/launchpad/close-account-polish/+merge/359865 to try to make this safer and somewhat less weird.

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()