Merge ~ilasc/launchpad:close-account-celery-job into launchpad:master

Proposed by Ioana Lasc
Status: Merged
Approved by: Ioana Lasc
Approved revision: 29e216c06817a5192df110886d545df9d82c762d
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~ilasc/launchpad:close-account-celery-job
Merge into: launchpad:master
Diff against target: 393 lines (+266/-0)
7 files modified
lib/lp/app/errors.py (+5/-0)
lib/lp/registry/configure.zcml (+6/-0)
lib/lp/registry/enums.py (+5/-0)
lib/lp/registry/interfaces/persontransferjob.py (+31/-0)
lib/lp/registry/model/persontransferjob.py (+75/-0)
lib/lp/registry/tests/test_person_close_account_job.py (+138/-0)
lib/lp/services/config/schema-lazr.conf (+6/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+400878@code.launchpad.net

Commit message

Add a close account celery job

To post a comment you must log in.
Revision history for this message
Ioana Lasc (ilasc) wrote :

Moved back a step in the implementation of PersonCloseAccountJob to a thin wrapper around the existing close account script.
MP ready for a direction review.

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

Much better than before, thanks! A few details to fix up, but the direction is fine and this looks pretty close.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/app/errors.py b/lib/lp/app/errors.py
2index f9df912..af5c4fa 100644
3--- a/lib/lp/app/errors.py
4+++ b/lib/lp/app/errors.py
5@@ -12,6 +12,7 @@ __all__ = [
6 'POSTToNonCanonicalURL',
7 'ServiceUsageForbidden',
8 'SubscriptionPrivacyViolation',
9+ 'TeamAccountNotClosable',
10 'TranslationUnavailable',
11 'UnexpectedFormData',
12 'UserCannotUnsubscribePerson',
13@@ -29,6 +30,10 @@ class TranslationUnavailable(Exception):
14 """Translation objects are unavailable."""
15
16
17+class TeamAccountNotClosable(Exception):
18+ """We do not close team accounts."""
19+
20+
21 @error_status(http_client.NOT_FOUND)
22 class NotFoundError(KeyError):
23 """Launchpad object not found."""
24diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
25index 7a7a396..318e09e 100644
26--- a/lib/lp/registry/configure.zcml
27+++ b/lib/lp/registry/configure.zcml
28@@ -105,6 +105,12 @@
29 </securedutility>
30
31 <securedutility
32+ component=".model.persontransferjob.PersonCloseAccountJob"
33+ provides=".interfaces.persontransferjob.IPersonCloseAccountJobs">
34+ <allow interface=".interfaces.persontransferjob.IPersonCloseAccountJobs"/>
35+ </securedutility>
36+
37+ <securedutility
38 component=".model.persontransferjob.TeamInvitationNotificationJob"
39 provides=".interfaces.persontransferjob.ITeamInvitationNotificationJobSource">
40 <allow interface=".interfaces.persontransferjob.ITeamInvitationNotificationJobSource"/>
41diff --git a/lib/lp/registry/enums.py b/lib/lp/registry/enums.py
42index b71dec6..0ca6af9 100644
43--- a/lib/lp/registry/enums.py
44+++ b/lib/lp/registry/enums.py
45@@ -380,6 +380,11 @@ class PersonTransferJobType(DBEnumeratedType):
46 Notify team admins that a member renewed their own membership.
47 """)
48
49+ CLOSE_ACCOUNT = DBItem(7, """Close account.
50+
51+ Close account for a given username.
52+ """)
53+
54
55 class ProductJobType(DBEnumeratedType):
56 """Values that IProductJob.job_type can take."""
57diff --git a/lib/lp/registry/interfaces/persontransferjob.py b/lib/lp/registry/interfaces/persontransferjob.py
58index 3e80aed..66874ec 100644
59--- a/lib/lp/registry/interfaces/persontransferjob.py
60+++ b/lib/lp/registry/interfaces/persontransferjob.py
61@@ -9,6 +9,8 @@ __all__ = [
62 'IExpiringMembershipNotificationJobSource',
63 'IMembershipNotificationJob',
64 'IMembershipNotificationJobSource',
65+ 'IPersonCloseAccountJob',
66+ 'IPersonCloseAccountJobs',
67 'IPersonDeactivateJob',
68 'IPersonDeactivateJobSource',
69 'IPersonMergeJob',
70@@ -171,6 +173,35 @@ class IPersonDeactivateJobSource(IJobSource):
71 """
72
73
74+class IPersonCloseAccountJob(IPersonTransferJob):
75+ """A Job that closes the account for a person."""
76+
77+ person = PublicPersonChoice(
78+ title=_('Alias for major_person attribute'),
79+ vocabulary='ValidPersonOrTeam',
80+ required=True)
81+
82+ def getErrorRecipients(self):
83+ """See `BaseRunnableJob`."""
84+
85+
86+class IPersonCloseAccountJobs(IJobSource):
87+ """An interface for acquiring ICloseAccountJobs."""
88+
89+ def create(person):
90+ """Create a new IPersonCloseAccountJob.
91+
92+ :param person: A `IPerson` to close the account for.
93+ """
94+
95+ def find(person=None):
96+ """Finds pending close account jobs.
97+
98+ :param person: Match jobs on `person`, or `None` to ignore.
99+ :return: A `ResultSet` yielding `IPersonCloseAccountJob`.
100+ """
101+
102+
103 class ITeamInvitationNotificationJob(IPersonTransferJob):
104 """A Job to notify about team joining invitations."""
105
106diff --git a/lib/lp/registry/model/persontransferjob.py b/lib/lp/registry/model/persontransferjob.py
107index 60ec767..010a1af 100644
108--- a/lib/lp/registry/model/persontransferjob.py
109+++ b/lib/lp/registry/model/persontransferjob.py
110@@ -6,6 +6,7 @@
111 __metaclass__ = type
112 __all__ = [
113 'MembershipNotificationJob',
114+ 'PersonCloseAccountJob',
115 'PersonTransferJob',
116 ]
117
118@@ -24,12 +25,14 @@ from storm.locals import (
119 Reference,
120 Unicode,
121 )
122+import transaction
123 from zope.component import getUtility
124 from zope.interface import (
125 implementer,
126 provider,
127 )
128
129+from lp.app.errors import TeamAccountNotClosable
130 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
131 from lp.registry.enums import PersonTransferJobType
132 from lp.registry.interfaces.person import (
133@@ -42,6 +45,8 @@ from lp.registry.interfaces.persontransferjob import (
134 IExpiringMembershipNotificationJobSource,
135 IMembershipNotificationJob,
136 IMembershipNotificationJobSource,
137+ IPersonCloseAccountJob,
138+ IPersonCloseAccountJobs,
139 IPersonDeactivateJob,
140 IPersonDeactivateJobSource,
141 IPersonMergeJob,
142@@ -59,6 +64,7 @@ from lp.registry.interfaces.teammembership import TeamMembershipStatus
143 from lp.registry.mail.teammembership import TeamMembershipMailer
144 from lp.registry.model.person import Person
145 from lp.registry.personmerge import merge_people
146+from lp.registry.scripts.closeaccount import close_account
147 from lp.services.config import config
148 from lp.services.database.decoratedresultset import DecoratedResultSet
149 from lp.services.database.enumcol import EnumCol
150@@ -437,6 +443,75 @@ class PersonDeactivateJob(PersonTransferJobDerived):
151 return 'deactivating ~%s' % self.person.name
152
153
154+@implementer(IPersonCloseAccountJob)
155+@provider(IPersonCloseAccountJobs)
156+class PersonCloseAccountJob(PersonTransferJobDerived):
157+ """A Job that closes account for a person."""
158+
159+ class_job_type = PersonTransferJobType.CLOSE_ACCOUNT
160+
161+ config = config.IPersonCloseAccountJobs
162+
163+ @classmethod
164+ def create(cls, person):
165+ """See `IPersonCloseAccountJobs`."""
166+
167+ # We don't delete teams
168+ if person.is_team:
169+ raise TeamAccountNotClosable("%s is a team" % person.name)
170+
171+ return super(PersonCloseAccountJob, cls).create(
172+ minor_person=getUtility(ILaunchpadCelebrities).janitor,
173+ major_person=person, metadata={})
174+
175+ @classmethod
176+ def find(cls, person=None):
177+ """See `IPersonMergeJobSource`."""
178+ conditions = [
179+ PersonCloseAccountJob.job_type == cls.class_job_type,
180+ PersonCloseAccountJob.job_id == Job.id,
181+ Job._status.is_in(Job.PENDING_STATUSES)]
182+ arg_conditions = []
183+ if person:
184+ arg_conditions.append(PersonCloseAccountJob.major_person == person)
185+ conditions.extend(arg_conditions)
186+ return DecoratedResultSet(
187+ IStore(PersonCloseAccountJob).find(
188+ PersonCloseAccountJob, *conditions), cls)
189+
190+ @property
191+ def person(self):
192+ """See `IPersonCloseAccountJob`."""
193+ return self.major_person
194+
195+ @property
196+ def log_name(self):
197+ return self.__class__.__name__
198+
199+ def getErrorRecipients(self):
200+ """See `IPersonCloseAccountJob`."""
201+ return [format_address_for_person(self.person)]
202+
203+ def run(self):
204+ """Perform the account closure."""
205+ try:
206+ close_account(self.person.name, log)
207+ transaction.commit()
208+ except Exception:
209+ log.error(
210+ "%s Account closure failed for user %s", self.log_name,
211+ self.person.name)
212+ transaction.abort()
213+
214+ def __repr__(self):
215+ return (
216+ "<{self.__class__.__name__} to close account "
217+ "~{self.person.name}").format(self=self)
218+
219+ def getOperationDescription(self):
220+ return 'closing account for ~%s' % self.person.name
221+
222+
223 @implementer(ITeamInvitationNotificationJob)
224 @provider(ITeamInvitationNotificationJobSource)
225 class TeamInvitationNotificationJob(PersonTransferJobDerived):
226diff --git a/lib/lp/registry/tests/test_person_close_account_job.py b/lib/lp/registry/tests/test_person_close_account_job.py
227new file mode 100644
228index 0000000..14c6be9
229--- /dev/null
230+++ b/lib/lp/registry/tests/test_person_close_account_job.py
231@@ -0,0 +1,138 @@
232+# Copyright 2021 Canonical Ltd. This software is licensed under the
233+# GNU Affero General Public License version 3 (see the file LICENSE).
234+
235+"""Tests for `PersonCloseAccountJob`."""
236+
237+__metaclass__ = type
238+
239+from testtools.matchers import (
240+ Not,
241+ StartsWith,
242+ )
243+import transaction
244+from zope.component import getUtility
245+from zope.security.proxy import removeSecurityProxy
246+
247+from lp.app.errors import TeamAccountNotClosable
248+from lp.registry.interfaces.person import IPersonSet
249+from lp.registry.interfaces.persontransferjob import IPersonCloseAccountJobs
250+from lp.registry.model.persontransferjob import PersonCloseAccountJob
251+from lp.services.config import config
252+from lp.services.features.testing import FeatureFixture
253+from lp.services.identity.interfaces.account import (
254+ AccountStatus,
255+ IAccountSet,
256+ )
257+from lp.services.identity.interfaces.emailaddress import IEmailAddressSet
258+from lp.services.job.interfaces.job import JobStatus
259+from lp.services.job.runner import JobRunner
260+from lp.services.job.tests import block_on_job
261+from lp.services.log.logger import BufferLogger
262+from lp.services.scripts import log
263+from lp.testing import TestCaseWithFactory
264+from lp.testing.dbuser import dbuser
265+from lp.testing.layers import (
266+ CeleryJobLayer,
267+ LaunchpadZopelessLayer,
268+ )
269+
270+
271+class TestPersonCloseAccountJob(TestCaseWithFactory):
272+
273+ layer = LaunchpadZopelessLayer
274+
275+ def test_close_account_job_valid_username(self):
276+ user_to_delete = self.factory.makePerson(name=u'delete-me')
277+ job_source = getUtility(IPersonCloseAccountJobs)
278+ jobs = list(job_source.iterReady())
279+
280+ # at this point we have no jobs
281+ self.assertEqual([], jobs)
282+
283+ getUtility(IPersonCloseAccountJobs).create(user_to_delete)
284+ jobs = list(job_source.iterReady())
285+ jobs[0] = removeSecurityProxy(jobs[0])
286+ with dbuser(config.IPersonCloseAccountJobs.dbuser):
287+ JobRunner(jobs).runAll()
288+
289+ self.assertEqual(JobStatus.COMPLETED, jobs[0].status)
290+ person = removeSecurityProxy(
291+ getUtility(IPersonSet).getByName(user_to_delete.name))
292+ self.assertEqual(person.name, u'removed%d' % user_to_delete.id)
293+
294+ def test_close_account_job_valid_email(self):
295+ user_to_delete = self.factory.makePerson(
296+ email=u'delete-me@example.com')
297+ getUtility(
298+ IPersonCloseAccountJobs).create(user_to_delete)
299+ job_source = getUtility(IPersonCloseAccountJobs)
300+ jobs = list(job_source.iterReady())
301+ jobs[0] = removeSecurityProxy(jobs[0])
302+ with dbuser(config.IPersonCloseAccountJobs.dbuser):
303+ JobRunner(jobs).runAll()
304+ self.assertEqual(JobStatus.COMPLETED, jobs[0].status)
305+ person = removeSecurityProxy(
306+ getUtility(IPersonSet).getByName(user_to_delete.name))
307+ self.assertEqual(person.name, u'removed%d' % user_to_delete.id)
308+
309+ def test_team(self):
310+ team = self.factory.makeTeam()
311+ self.assertRaisesWithContent(
312+ TeamAccountNotClosable,
313+ "%s is a team" % team.name,
314+ getUtility(IPersonCloseAccountJobs).create,
315+ team)
316+
317+ def test_unhandled_reference(self):
318+ user_to_delete = self.factory.makePerson(name=u'delete-me')
319+ self.factory.makeProduct(owner=user_to_delete)
320+ person = removeSecurityProxy(
321+ getUtility(IPersonSet).getByName(user_to_delete.name))
322+ person_id = person.id
323+ account_id = person.account.id
324+ job = PersonCloseAccountJob.create(user_to_delete)
325+ logger = BufferLogger()
326+ with log.use(logger),\
327+ dbuser(config.IPersonCloseAccountJobs.dbuser):
328+ job.run()
329+ error_message = (
330+ {u'ERROR User delete-me is still '
331+ u'referenced by 1 product.owner values',
332+ u'ERROR User delete-me is still '
333+ u'referenced by 1 productseries.owner values',
334+ })
335+ self.assertTrue(
336+ error_message.issubset(logger.getLogBuffer().splitlines()))
337+ self.assertNotRemoved(account_id, person_id)
338+
339+ def assertNotRemoved(self, account_id, person_id):
340+ account = getUtility(IAccountSet).get(account_id)
341+ self.assertNotEqual('Removed by request', account.displayname)
342+ self.assertEqual(AccountStatus.ACTIVE, account.status)
343+ person = getUtility(IPersonSet).get(person_id)
344+ self.assertEqual(account, person.account)
345+ self.assertNotEqual('Removed by request', person.display_name)
346+ self.assertThat(person.name, Not(StartsWith('removed')))
347+ self.assertNotEqual(
348+ [], list(getUtility(IEmailAddressSet).getByPerson(person)))
349+ self.assertNotEqual([], list(account.openid_identifiers))
350+
351+
352+class TestPersonCloseAccountJobViaCelery(TestCaseWithFactory):
353+
354+ layer = CeleryJobLayer
355+
356+ def test_PersonCloseAccountJob(self):
357+ """PersonCloseAccountJob runs under Celery."""
358+ self.useFixture(FeatureFixture(
359+ {'jobs.celery.enabled_classes':
360+ 'PersonCloseAccountJob'}))
361+ user_to_delete = self.factory.makePerson()
362+
363+ with block_on_job():
364+ job = PersonCloseAccountJob.create(user_to_delete)
365+ transaction.commit()
366+ person = removeSecurityProxy(
367+ getUtility(IPersonSet).getByName(user_to_delete.name))
368+ self.assertEqual(JobStatus.COMPLETED, job.status)
369+ self.assertEqual(person.name, u'removed%d' % user_to_delete.id)
370diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
371index db22192..158a30b 100644
372--- a/lib/lp/services/config/schema-lazr.conf
373+++ b/lib/lp/services/config/schema-lazr.conf
374@@ -1798,6 +1798,7 @@ job_sources:
375 IOCIRecipeRequestBuildsJobSource,
376 IOCIRegistryUploadJobSource,
377 IPackageUploadNotificationJobSource,
378+ IPersonCloseAccountJobs,
379 IPersonDeactivateJobSource,
380 IPersonMergeJobSource,
381 IPlainPackageCopyJobSource,
382@@ -1992,6 +1993,11 @@ link: IBranchMergeProposalJobSource
383 module: lp.services.webhooks.interfaces
384 dbuser: webhookrunner
385
386+[IPersonCloseAccountJobs]
387+module: lp.registry.interfaces.persontransferjob
388+dbuser: launchpad
389+crontab_group: MAIN
390+
391 [job_runner_queues]
392 # The names of all queues.
393 queues: launchpad_job launchpad_job_slow bzrsyncd_job bzrsyncd_job_slow branch_write_job branch_write_job_slow celerybeat

Subscribers

People subscribed via source and target branches

to status/vote changes: