Merge ~ilasc/launchpad:close-account-celery-job into launchpad:master
- Git
- lp:~ilasc/launchpad
- close-account-celery-job
- Merge into 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) |
Related bugs: |
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
Description of the change
To post a comment you must log in.
Revision history for this message
Ioana Lasc (ilasc) wrote : | # |
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
1 | diff --git a/lib/lp/app/errors.py b/lib/lp/app/errors.py |
2 | index 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.""" |
24 | diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml |
25 | index 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"/> |
41 | diff --git a/lib/lp/registry/enums.py b/lib/lp/registry/enums.py |
42 | index 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.""" |
57 | diff --git a/lib/lp/registry/interfaces/persontransferjob.py b/lib/lp/registry/interfaces/persontransferjob.py |
58 | index 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 | |
106 | diff --git a/lib/lp/registry/model/persontransferjob.py b/lib/lp/registry/model/persontransferjob.py |
107 | index 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): |
226 | diff --git a/lib/lp/registry/tests/test_person_close_account_job.py b/lib/lp/registry/tests/test_person_close_account_job.py |
227 | new file mode 100644 |
228 | index 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) |
370 | diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf |
371 | index 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 |
Moved back a step in the implementation of PersonCloseAcco untJob to a thin wrapper around the existing close account script.
MP ready for a direction review.