launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #26738
[Merge] ~ilasc/launchpad:close-account-celery-job into launchpad:master
Ioana Lasc has proposed merging ~ilasc/launchpad:close-account-celery-job into launchpad:master.
Commit message:
Add a close account celery job
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~ilasc/launchpad/+git/launchpad/+merge/400261
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~ilasc/launchpad:close-account-celery-job into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 2911da2..6a8e811 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -153,6 +153,7 @@ public.bugtrackercomponentgroup = SELECT, INSERT, UPDATE
public.bugtrackerperson = SELECT, UPDATE
public.bugwatchactivity = SELECT, INSERT, UPDATE
public.buildfarmjob = DELETE
+public.closeaccountjobobject = SELECT, INSERT, UPDATE
public.codeimport = SELECT, INSERT, UPDATE, DELETE
public.codeimportevent = SELECT, INSERT, UPDATE
public.codeimporteventdata = SELECT, INSERT
diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
index 7a7a396..c959246 100644
--- a/lib/lp/registry/configure.zcml
+++ b/lib/lp/registry/configure.zcml
@@ -418,6 +418,19 @@
<!-- string:${id} because id is an int -->
+ <!-- Close Account Celery Job -->
+ <class
+ class="lp.registry.model.closeaccount.CloseAccountJobObject">
+ <allow
+ interface="lp.registry.model.closeaccount.ICloseAccountJob"/>
+ </class>
+
+ <securedutility
+ component="lp.registry.model.closeaccount.CloseAccountJob"
+ provides="lp.registry.model.closeaccount.ICloseAccountJobSource">
+ <allow interface="lp.registry.model.closeaccount.ICloseAccountJobSource" />
+ </securedutility>
+
<class
class="lp.registry.model.person.IrcID">
<allow
diff --git a/lib/lp/registry/model/closeaccount.py b/lib/lp/registry/model/closeaccount.py
new file mode 100644
index 0000000..087d63c
--- /dev/null
+++ b/lib/lp/registry/model/closeaccount.py
@@ -0,0 +1,601 @@
+# Copyright 2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+import pytz
+
+from lazr.delegates import delegate_to
+
+from storm.exceptions import IntegrityError
+from zope.schema._bootstrapfields import Object
+
+from lp.services.database.enumcol import EnumCol
+from lazr.enum import (
+ DBEnumeratedType,
+ DBItem,
+ )
+from lp.app.errors import NotFoundError
+from lp.services.database.interfaces import IStore
+from lp.services.database.locking import AdvisoryLockHeld
+from lp.services.database.stormbase import StormBase
+from lp.services.job.interfaces.job import IRunnableJob, IJobSource, IJob
+from lp.services.job.runner import BaseRunnableJob
+from zope.interface import (
+ implementer,
+ provider, Interface,
+)
+from storm.properties import (
+ DateTime,
+ Unicode,
+ )
+from lp.services.config import config
+from storm.locals import (
+ Int,
+ Reference,
+ )
+from lp.services.job.model.job import (
+ Job,
+ )
+from lp.services.scripts import log
+
+from storm.expr import (
+ LeftJoin,
+ Lower,
+ Or,
+ )
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.answers.enums import QuestionStatus
+from lp.answers.model.question import Question
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.bugs.model.bugtask import BugTask
+from lp.registry.interfaces.person import PersonCreationRationale
+from lp.registry.model.person import (
+ Person,
+ PersonSettings,
+ )
+from lp.registry.model.product import Product
+from lp.registry.model.productseries import ProductSeries
+from lp.services.database import postgresql
+from lp.services.database.constants import DEFAULT
+from lp.services.database.interfaces import IMasterStore
+from lp.services.database.sqlbase import cursor
+from lp.services.identity.interfaces.account import (
+ AccountCreationRationale,
+ AccountStatus,
+ )
+from lp.services.identity.model.emailaddress import EmailAddress
+from lp.services.openid.model.openididentifier import OpenIdIdentifier
+from lp.services.scripts.base import (
+ LaunchpadScriptFailure,
+ )
+from lp.soyuz.enums import (
+ ArchiveStatus,
+ ArchiveSubscriberStatus,
+ )
+from lp.soyuz.interfaces.archivesubscriber import IArchiveSubscriberSet
+from lp.soyuz.model.archive import Archive
+from lp.soyuz.model.archivesubscriber import ArchiveSubscriber
+from lp import _
+
+
+class ICloseAccountRunnable(IRunnableJob):
+ """A Job that closes an account."""
+
+
+class ICloseAccountJob(Interface):
+
+ job = Object(title=_('The common Job attributes'), schema=IJob,
+ required=True)
+
+
+class ICloseAccountJobSource(IJobSource):
+
+ def create(username):
+ """Close an account for the given username.
+
+ :param username: The username to close the account for.
+ """
+
+
+class CloseAccountJobType(DBEnumeratedType):
+ """Values that IQuestionJob.job_type can take."""
+
+ CLOSE_ACCOUNT = DBItem(0, """
+ Close account
+
+ Close a Launchpad account for a given username.
+ """)
+
+
+@implementer(ICloseAccountJob)
+class CloseAccountJobObject(StormBase):
+
+ __storm_table__ = 'CloseAccountJobObject'
+ id = Int(primary=True)
+ status = Int(name='status', allow_none=True)
+
+ job_id = Int(name='job', allow_none=False)
+ job = Reference(job_id, 'Job.id')
+ job_type = EnumCol(enum=CloseAccountJobType, notNull=True)
+
+ username = Unicode(allow_none=False)
+ exception = Unicode(allow_none=True)
+
+ date_created = DateTime(tzinfo=pytz.UTC, allow_none=True)
+ date_finished = DateTime(tzinfo=pytz.UTC, allow_none=True)
+
+ def __init__(self, username, job_type):
+ """Constructor.
+
+ Extra keyword arguments are used to construct the underlying Job
+ object.
+
+ :param username: The database username this job relates to.
+ :param metadata: The type-specific variables, as a JSON-compatible
+ dict.
+ """
+ super(CloseAccountJobObject, self).__init__()
+ self.username = username
+ self.job = Job()
+ self.job_type = job_type
+
+
+@delegate_to(ICloseAccountJob)
+@implementer(ICloseAccountRunnable)
+@provider(ICloseAccountJobSource)
+class CloseAccountJob(BaseRunnableJob):
+ """A Job that closes and account for the provided username."""
+ class_job_type = CloseAccountJobType.CLOSE_ACCOUNT
+
+ max_retries = 5
+
+ retry_error_types = (AdvisoryLockHeld,)
+
+ config = config.ICloseAccountJobSource
+
+ def __init__(self, job):
+ self.context = job
+
+ @classmethod
+ def get(cls, job_id):
+ close_account_job = IStore(CloseAccountJobObject).get(CloseAccountJobObject, job_id)
+ if close_account_job.job_type != cls.class_job_type:
+ raise NotFoundError(
+ "No object found with id %d and type %s" %
+ (job_id, cls.class_job_type.title))
+ return cls(close_account_job)
+
+ @classmethod
+ def iterReady(cls):
+ jobs = IMasterStore(CloseAccountJobObject).find(
+ CloseAccountJobObject,
+ CloseAccountJobObject.job_type == cls.class_job_type,
+ CloseAccountJobObject.job == Job.id,
+ Job.id.is_in(Job.ready_jobs))
+ return (cls(job) for job in jobs)
+
+ @classmethod
+ def create(cls, username):
+ close_account_job = CloseAccountJobObject(username, CloseAccountJobType.CLOSE_ACCOUNT)
+ job = cls(close_account_job)
+ job.celeryRunOnCommit()
+ return job
+
+ def run(self):
+ # with try_advisory_lock(
+ # LockType.CLOSE_ACCOUNT, self.username):
+ result = self.close_account(self.context.username)
+ if result is not True:
+ self.context.exception = result
+
+ def close_account(self, username):
+ """Close a person's account.
+
+ Return True on success, or log an error message and return False
+ """
+ store = IMasterStore(Person)
+ janitor = getUtility(ILaunchpadCelebrities).janitor
+
+ cur = cursor()
+ references = list(postgresql.listReferences(cur, 'person', 'id'))
+ postgresql.check_indirect_references(references)
+
+ person = store.using(
+ Person,
+ LeftJoin(EmailAddress, Person.id == EmailAddress.personID)
+ ).find(
+ Person,
+ Or(Person.name == username,
+ Lower(EmailAddress.email) == Lower(username))
+ ).order_by(Person.id).config(distinct=True).one()
+ if person is None:
+ return "User %s does not exist" % username
+ person_name = person.name
+
+ # We don't do teams
+ if person.is_team:
+ return "%s is a team" % person_name
+
+ log.info("Closing %s's account" % person_name)
+
+ def table_notification(table):
+ log.debug("Handling the %s table" % table)
+
+ # All names starting with 'removed' are blacklisted, so this will always
+ # succeed.
+ new_name = 'removed%d' % person.id
+
+ # Some references can safely remain in place and link to the cleaned-out
+ # Person row.
+ skip = {
+ # These references express some kind of audit trail. The actions in
+ # question still happened, and in some cases the rows may still have
+ # functional significance (e.g. subscriptions or access grants), but
+ # we no longer identify the actor.
+ ('accessartifactgrant', 'grantor'),
+ ('accesspolicygrant', 'grantor'),
+ ('binarypackagepublishinghistory', 'removed_by'),
+ ('branch', 'registrant'),
+ ('branchmergeproposal', 'merge_reporter'),
+ ('branchmergeproposal', 'merger'),
+ ('branchmergeproposal', 'queuer'),
+ ('branchmergeproposal', 'registrant'),
+ ('branchmergeproposal', 'reviewer'),
+ ('branchsubscription', 'subscribed_by'),
+ ('bug', 'owner'),
+ ('bug', 'who_made_private'),
+ ('bugactivity', 'person'),
+ ('bugnomination', 'decider'),
+ ('bugnomination', 'owner'),
+ ('bugtask', 'owner'),
+ ('bugsubscription', 'subscribed_by'),
+ ('codeimport', 'owner'),
+ ('codeimport', 'registrant'),
+ ('codeimportjob', 'requesting_user'),
+ ('codeimportevent', 'person'),
+ ('codeimportresult', 'requesting_user'),
+ ('distroarchseriesfilter', 'creator'),
+ ('faq', 'last_updated_by'),
+ ('featureflagchangelogentry', 'person'),
+ ('gitactivity', 'changee'),
+ ('gitactivity', 'changer'),
+ ('gitrepository', 'registrant'),
+ ('gitrule', 'creator'),
+ ('gitrulegrant', 'grantor'),
+ ('gitsubscription', 'subscribed_by'),
+ ('job', 'requester'),
+ ('message', 'owner'),
+ ('messageapproval', 'disposed_by'),
+ ('messageapproval', 'posted_by'),
+ ('packagecopyrequest', 'requester'),
+ ('packagediff', 'requester'),
+ ('packageupload', 'signing_key_owner'),
+ ('personlocation', 'last_modified_by'),
+ ('persontransferjob', 'major_person'),
+ ('persontransferjob', 'minor_person'),
+ ('poexportrequest', 'person'),
+ ('pofile', 'lasttranslator'),
+ ('pofiletranslator', 'person'),
+ ('product', 'registrant'),
+ ('question', 'answerer'),
+ ('questionreopening', 'answerer'),
+ ('questionreopening', 'reopener'),
+ ('snapbuild', 'requester'),
+ ('sourcepackagepublishinghistory', 'creator'),
+ ('sourcepackagepublishinghistory', 'removed_by'),
+ ('sourcepackagepublishinghistory', 'sponsor'),
+ ('sourcepackagerecipebuild', 'requester'),
+ ('sourcepackagerelease', 'creator'),
+ ('sourcepackagerelease', 'maintainer'),
+ ('sourcepackagerelease', 'signing_key_owner'),
+ ('specification', 'approver'),
+ ('specification', 'completer'),
+ ('specification', 'drafter'),
+ ('specification', 'goal_decider'),
+ ('specification', 'goal_proposer'),
+ ('specification', 'last_changed_by'),
+ ('specification', 'owner'),
+ ('specification', 'starter'),
+ ('structuralsubscription', 'subscribed_by'),
+ ('teammembership', 'acknowledged_by'),
+ ('teammembership', 'last_changed_by'),
+ ('teammembership', 'proposed_by'),
+ ('teammembership', 'reviewed_by'),
+ ('translationimportqueueentry', 'importer'),
+ ('translationmessage', 'reviewer'),
+ ('translationmessage', 'submitter'),
+ ('translationrelicensingagreement', 'person'),
+ ('usertouseremail', 'recipient'),
+ ('usertouseremail', 'sender'),
+ ('xref', 'creator'),
+
+ # This is maintained by trigger functions and a garbo job. It
+ # doesn't need to be updated immediately.
+ ('bugsummary', 'viewed_by'),
+
+ # XXX cjwatson 2019-05-02 bug=1827399: This is suboptimal because it
+ # does retain some personal information, but it's currently hard to
+ # deal with due to the size and complexity of references to it. We
+ # can hopefully provide a garbo job for this eventually.
+ ('revisionauthor', 'person'),
+ }
+
+ # If all the teams that the user owns
+ # have been deleted (not just one) skip Person.teamowner
+ teams = store.find(Person, Person.teamowner == person)
+ if all(team.merged is not None for team in teams):
+ skip.add(('person', 'teamowner'))
+
+ reference_names = {
+ (src_tab, src_col) for src_tab, src_col, _, _, _, _ in references}
+ for src_tab, src_col in skip:
+ if (src_tab, src_col) not in reference_names:
+ raise AssertionError(
+ "%s.%s is not a Person reference; possible typo?" %
+ (src_tab, src_col))
+
+ # XXX cjwatson 2018-11-29: Registrants could possibly be left as-is, but
+ # perhaps we should pretend that the registrant was ~registry in that
+ # case instead?
+
+ # Remove the EmailAddress. This is the most important step, as
+ # people requesting account removal seem to primarily be interested
+ # in ensuring we no longer store this information.
+ table_notification('EmailAddress')
+ store.find(EmailAddress, EmailAddress.personID == person.id).remove()
+
+ # Clean out personal details from the Person table
+ table_notification('Person')
+ person.display_name = 'Removed by request'
+ person.name = new_name
+ person.homepage_content = None
+ person.icon = None
+ person.mugshot = None
+ person.hide_email_addresses = False
+ person.registrant = None
+ person.logo = None
+ person.creation_rationale = PersonCreationRationale.UNKNOWN
+ person.creation_comment = None
+
+ # Keep the corresponding PersonSettings row, but reset everything to the
+ # defaults.
+ table_notification('PersonSettings')
+ store.find(PersonSettings, PersonSettings.personID == person.id).set(
+ selfgenerated_bugnotifications=DEFAULT,
+ # XXX cjwatson 2018-11-29: These two columns have NULL defaults, but
+ # perhaps shouldn't?
+ expanded_notification_footers=False,
+ require_strong_email_authentication=False)
+ skip.add(('personsettings', 'person'))
+
+ # Remove almost everything from the Account row and the corresponding
+ # OpenIdIdentifier rows, preserving only a minimal audit trail.
+ if person.account is not None:
+ table_notification('Account')
+ account = removeSecurityProxy(person.account)
+ account.displayname = 'Removed by request'
+ account.creation_rationale = AccountCreationRationale.UNKNOWN
+ person.setAccountStatus(
+ AccountStatus.CLOSED, janitor, "Closed using close-account.")
+
+ table_notification('OpenIdIdentifier')
+ store.find(
+ OpenIdIdentifier,
+ OpenIdIdentifier.account_id == account.id).remove()
+
+ # Reassign their bugs
+ table_notification('BugTask')
+ store.find(BugTask, BugTask.assignee_id == person.id).set(assignee_id=None)
+
+ # Reassign questions assigned to the user, and close all their questions
+ # in non-final states since nobody else can.
+ table_notification('Question')
+ store.find(Question, Question.assignee_id == person.id).set(
+ assignee_id=None)
+ owned_non_final_questions = store.find(
+ Question, Question.owner_id == person.id,
+ Question.status.is_in([
+ QuestionStatus.OPEN, QuestionStatus.NEEDSINFO,
+ QuestionStatus.ANSWERED,
+ ]))
+ owned_non_final_questions.set(
+ status=QuestionStatus.SOLVED,
+ whiteboard=(
+ u'Closed by Launchpad due to owner requesting account removal'))
+ skip.add(('question', 'owner'))
+
+ # Remove rows from tables in simple cases in the given order
+ removals = [
+ # Trash their email addresses. People who request complete account
+ # removal would be unhappy if they reregistered with their old email
+ # address and this resurrected their deleted account, as the email
+ # address is probably the piece of data we store that they were most
+ # concerned with being removed from our systems.
+ ('EmailAddress', 'person'),
+
+ # Login and OAuth tokens are no longer interesting if the user can
+ # no longer log in.
+ ('LoginToken', 'requester'),
+ ('OAuthAccessToken', 'person'),
+ ('OAuthRequestToken', 'person'),
+
+ # Trash their codes of conduct and GPG keys
+ ('SignedCodeOfConduct', 'owner'),
+ ('GpgKey', 'owner'),
+
+ # Subscriptions and notifications
+ ('BranchSubscription', 'person'),
+ ('BugMute', 'person'),
+ ('BugNotificationRecipient', 'person'),
+ ('BugSubscription', 'person'),
+ ('BugSubscriptionFilterMute', 'person'),
+ ('GitSubscription', 'person'),
+ ('MailingListSubscription', 'person'),
+ ('QuestionSubscription', 'person'),
+ ('SpecificationSubscription', 'person'),
+ ('StructuralSubscription', 'subscriber'),
+
+ # Personal stuff, freeing up the namespace for others who want to play
+ # or just to remove any fingerprints identifying the user.
+ ('IrcId', 'person'),
+ ('JabberId', 'person'),
+ ('WikiName', 'person'),
+ ('PersonLanguage', 'person'),
+ ('PersonLocation', 'person'),
+ ('SshKey', 'person'),
+
+ # Karma
+ ('Karma', 'person'),
+ ('KarmaCache', 'person'),
+ ('KarmaTotalCache', 'person'),
+
+ # Team memberships
+ ('TeamMembership', 'person'),
+ ('TeamParticipation', 'person'),
+
+ # Contacts
+ ('AnswerContact', 'person'),
+
+ # Pending items in queues
+ ('POExportRequest', 'person'),
+
+ # Access grants
+ ('AccessArtifactGrant', 'grantee'),
+ ('AccessPolicyGrant', 'grantee'),
+ ('ArchivePermission', 'person'),
+ ('GitRuleGrant', 'grantee'),
+ ('SharingJob', 'grantee'),
+
+ # Soyuz reporting
+ ('LatestPersonSourcePackageReleaseCache', 'creator'),
+ ('LatestPersonSourcePackageReleaseCache', 'maintainer'),
+
+ # "Affects me too" information
+ ('BugAffectsPerson', 'person'),
+ ]
+ for table, person_id_column in removals:
+ table_notification(table)
+ store.execute("""
+ DELETE FROM %(table)s WHERE %(person_id_column)s = ?
+ """ % {
+ 'table': table,
+ 'person_id_column': person_id_column,
+ },
+ (person.id,))
+
+ # Trash Sprint Attendance records in the future.
+ table_notification('SprintAttendance')
+ store.execute("""
+ DELETE FROM SprintAttendance
+ USING Sprint
+ WHERE Sprint.id = SprintAttendance.sprint
+ AND attendee = ?
+ AND Sprint.time_starts > CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
+ """, (person.id,))
+ # Any remaining past sprint attendance records can harmlessly refer to
+ # the placeholder person row.
+ skip.add(('sprintattendance', 'attendee'))
+
+ # generate_ppa_htaccess currently relies on seeing active
+ # ArchiveAuthToken rows so that it knows which ones to remove from
+ # .htpasswd files on disk in response to the cancellation of the
+ # corresponding ArchiveSubscriber rows; but even once PPA authorisation
+ # is handled dynamically, we probably still want to have the per-person
+ # audit trail here.
+ archive_subscriber_ids = set(store.find(
+ ArchiveSubscriber.id,
+ ArchiveSubscriber.subscriber_id == person.id,
+ ArchiveSubscriber.status == ArchiveSubscriberStatus.CURRENT))
+ if archive_subscriber_ids:
+ getUtility(IArchiveSubscriberSet).cancel(
+ archive_subscriber_ids, janitor)
+ skip.add(('archivesubscriber', 'subscriber'))
+ skip.add(('archiveauthtoken', 'person'))
+
+ # Remove hardware submissions.
+ table_notification('HWSubmissionDevice')
+ store.execute("""
+ DELETE FROM HWSubmissionDevice
+ USING HWSubmission
+ WHERE HWSubmission.id = HWSubmissionDevice.submission
+ AND owner = ?
+ """, (person.id,))
+ table_notification('HWSubmission')
+ store.execute("""
+ DELETE FROM HWSubmission
+ WHERE HWSubmission.owner = ?
+ """, (person.id,))
+
+ # Purge deleted PPAs. This is safe because the archive can only be in
+ # the DELETED status if the publisher has removed it from disk and set
+ # all its publications to DELETED.
+ # XXX cjwatson 2019-08-09: This will fail if anything non-trivial has
+ # been done in this person's PPAs; and it's not obvious what to do in
+ # more complicated cases such as builds having been copied out
+ # elsewhere. It's good enough for some simple cases, though.
+ try:
+ store.find(
+ Archive,
+ Archive.owner == person,
+ Archive.status == ArchiveStatus.DELETED).remove()
+ except IntegrityError:
+ raise LaunchpadScriptFailure(
+ "Can't delete non-trivial PPAs for user %s" % person_name)
+
+ has_references = False
+
+ # Check for active related projects, and skip inactive ones.
+ for col in 'bug_supervisor', 'driver', 'owner':
+ # Raw SQL because otherwise using Product._owner while displaying it
+ # as Product.owner is too fiddly.
+ result = store.execute("""
+ SELECT COUNT(*) FROM product WHERE active AND %(col)s = ?
+ """ % {'col': col},
+ (person.id,))
+ count = result.get_one()[0]
+ if count:
+ log.error(
+ "User %s is still referenced by %d product.%s values" %
+ (person_name, count, col))
+ has_references = True
+ skip.add(('product', col))
+ for col in 'driver', 'owner':
+ count = store.find(
+ ProductSeries,
+ ProductSeries.product == Product.id, Product.active,
+ getattr(ProductSeries, col) == person).count()
+ if count:
+ log.error(
+ "User %s is still referenced by %d productseries.%s values" %
+ (person_name, count, col))
+ has_references = True
+ skip.add(('productseries', col))
+
+ # Closing the account will only work if all references have been handled
+ # by this point. If not, it's safer to bail out. It's OK if this
+ # doesn't work in all conceivable situations, since some of them may
+ # require careful thought and decisions by a human administrator.
+ for src_tab, src_col, ref_tab, ref_col, updact, delact in references:
+ if (src_tab, src_col) in skip:
+ continue
+ result = store.execute("""
+ SELECT COUNT(*) FROM %(src_tab)s WHERE %(src_col)s = ?
+ """ % {
+ 'src_tab': src_tab,
+ 'src_col': src_col,
+ },
+ (person.id,))
+ count = result.get_one()[0]
+ if count:
+ log.error(
+ "User %s is still referenced by %d %s.%s values" %
+ (person_name, count, src_tab, src_col))
+ has_references = True
+ if has_references:
+ return "User %s is still referenced" % person_name
+
+ return True
+
diff --git a/lib/lp/registry/tests/test_closeaccount.py b/lib/lp/registry/tests/test_closeaccount.py
new file mode 100644
index 0000000..0d4a1dc
--- /dev/null
+++ b/lib/lp/registry/tests/test_closeaccount.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+# NOTE: The first line above must stay first; do not move the copyright
+# notice to the top. See http://www.python.org/dev/peps/pep-0263/.
+#
+# Copyright 2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for Close Account Celery Job."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.registry.interfaces.person import IPersonSet
+from lp.registry.model.closeaccount import ICloseAccountJobSource
+from lp.services.job.interfaces.job import JobStatus
+from lp.testing import TestCaseWithFactory
+from lp.services.job.runner import JobRunner
+
+from lp.testing.dbuser import dbuser
+from lp.testing.layers import (
+ DatabaseFunctionalLayer,
+ )
+from lp.services.config import config
+
+
+class TestGitRepositoryRescan(TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ def test_close_account_job_nonexistent_username(self):
+ # The job completes with the expected exception logged:
+ # states that the user does not exist in LP
+ job_source = getUtility(ICloseAccountJobSource)
+ jobs = list(job_source.iterReady())
+
+ # at this point we have no jobs
+ self.assertEqual([], jobs)
+
+ getUtility(ICloseAccountJobSource).create('nonexistent_username')
+ jobs = list(job_source.iterReady())
+ jobs[0] = removeSecurityProxy(jobs[0])
+ with dbuser(config.ICloseAccountJobSource.dbuser):
+ JobRunner(jobs).runAll()
+
+ self.assertEqual(JobStatus.COMPLETED, jobs[0].status)
+ self.assertEqual(u'User nonexistent_username does not exist',
+ jobs[0].context.exception)
+
+ def test_close_account_job_valid_username(self):
+ # The job completes and the username is now anonymized
+ user_to_delete = self.factory.makePerson(name='delete-me')
+ job_source = getUtility(ICloseAccountJobSource)
+ jobs = list(job_source.iterReady())
+
+ # at this point we have no jobs
+ self.assertEqual([], jobs)
+
+ getUtility(ICloseAccountJobSource).create(user_to_delete.name)
+ jobs = list(job_source.iterReady())
+ jobs[0] = removeSecurityProxy(jobs[0])
+ with dbuser(config.ICloseAccountJobSource.dbuser):
+ JobRunner(jobs).runAll()
+
+ self.assertEqual(JobStatus.COMPLETED, jobs[0].status)
+ self.assertIsNone(jobs[0].context.exception)
+ person = removeSecurityProxy(
+ getUtility(IPersonSet).getByName(user_to_delete.name))
+ self.assertEqual(person.name, u'removed%d' % user_to_delete.id)
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index db22192..1ee193e 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -1992,6 +1992,11 @@ link: IBranchMergeProposalJobSource
module: lp.services.webhooks.interfaces
dbuser: webhookrunner
+[ICloseAccountJobSource]
+module: lp.registry.model.closeaccount
+dbuser: launchpad
+crontab_group: FREQUENT
+
[job_runner_queues]
# The names of all queues.
queues: launchpad_job launchpad_job_slow bzrsyncd_job bzrsyncd_job_slow branch_write_job branch_write_job_slow celerybeat
diff --git a/lib/lp/services/database/locking.py b/lib/lp/services/database/locking.py
index 266f971..c7f1a27 100644
--- a/lib/lp/services/database/locking.py
+++ b/lib/lp/services/database/locking.py
@@ -44,6 +44,11 @@ class LockType(DBEnumeratedType):
Package copy.
""")
+ CLOSE_ACCOUNT = DBItem(3, """Close account.
+
+ Close account.
+ """)
+
@contextmanager
def try_advisory_lock(lock_type, lock_id, store):