launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #26860
[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/400878
--
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/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
index 7a7a396..ac3d82b 100644
--- a/lib/lp/registry/configure.zcml
+++ b/lib/lp/registry/configure.zcml
@@ -105,6 +105,12 @@
</securedutility>
<securedutility
+ component=".model.persontransferjob.PersonCloseAccountJob"
+ provides=".interfaces.persontransferjob.IPersonCloseAccountJobSource">
+ <allow interface=".interfaces.persontransferjob.IPersonCloseAccountJobSource"/>
+ </securedutility>
+
+ <securedutility
component=".model.persontransferjob.TeamInvitationNotificationJob"
provides=".interfaces.persontransferjob.ITeamInvitationNotificationJobSource">
<allow interface=".interfaces.persontransferjob.ITeamInvitationNotificationJobSource"/>
diff --git a/lib/lp/registry/enums.py b/lib/lp/registry/enums.py
index b71dec6..0ca6af9 100644
--- a/lib/lp/registry/enums.py
+++ b/lib/lp/registry/enums.py
@@ -380,6 +380,11 @@ class PersonTransferJobType(DBEnumeratedType):
Notify team admins that a member renewed their own membership.
""")
+ CLOSE_ACCOUNT = DBItem(7, """Close account.
+
+ Close account for a given username.
+ """)
+
class ProductJobType(DBEnumeratedType):
"""Values that IProductJob.job_type can take."""
diff --git a/lib/lp/registry/interfaces/persontransferjob.py b/lib/lp/registry/interfaces/persontransferjob.py
index 3e80aed..e445553 100644
--- a/lib/lp/registry/interfaces/persontransferjob.py
+++ b/lib/lp/registry/interfaces/persontransferjob.py
@@ -9,6 +9,8 @@ __all__ = [
'IExpiringMembershipNotificationJobSource',
'IMembershipNotificationJob',
'IMembershipNotificationJobSource',
+ 'IPersonCloseAccountJob',
+ 'IPersonCloseAccountJobSource',
'IPersonDeactivateJob',
'IPersonDeactivateJobSource',
'IPersonMergeJob',
@@ -171,6 +173,34 @@ class IPersonDeactivateJobSource(IJobSource):
"""
+class IPersonCloseAccountJob(IPersonTransferJob):
+ """A Job that closes the account for a person."""
+
+ person = PublicPersonChoice(
+ title=_('Alias for person attribute'), vocabulary='ValidPersonOrTeam',
+ required=True)
+
+ def getErrorRecipients(self):
+ """See `BaseRunnableJob`."""
+
+
+class IPersonCloseAccountJobSource(IJobSource):
+ """An interface for acquiring ICloseAccountJobs."""
+
+ def create(person):
+ """Create a new IPersonCloseAccountJob.
+
+ :param person: A `IPerson` to close the account for.
+ """
+
+ def find(person=None):
+ """Finds pending close account jobs.
+
+ :param person: Match jobs on `person`, or `None` to ignore.
+ :return: A `ResultSet` yielding `IPersonCloseAccountJob`.
+ """
+
+
class ITeamInvitationNotificationJob(IPersonTransferJob):
"""A Job to notify about team joining invitations."""
diff --git a/lib/lp/registry/model/persontransferjob.py b/lib/lp/registry/model/persontransferjob.py
index 60ec767..5cef604 100644
--- a/lib/lp/registry/model/persontransferjob.py
+++ b/lib/lp/registry/model/persontransferjob.py
@@ -6,6 +6,7 @@
__metaclass__ = type
__all__ = [
'MembershipNotificationJob',
+ 'PersonCloseAccountJob',
'PersonTransferJob',
]
@@ -15,8 +16,11 @@ from lazr.delegates import delegate_to
import pytz
import simplejson
import six
+from storm.exceptions import IntegrityError
from storm.expr import (
And,
+ LeftJoin,
+ Lower,
Or,
)
from storm.locals import (
@@ -24,24 +28,32 @@ from storm.locals import (
Reference,
Unicode,
)
+import transaction
from zope.component import getUtility
from zope.interface import (
implementer,
provider,
)
+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.enums import PersonTransferJobType
from lp.registry.interfaces.person import (
IPerson,
IPersonSet,
ITeam,
+ PersonCreationRationale,
)
from lp.registry.interfaces.persontransferjob import (
IExpiringMembershipNotificationJob,
IExpiringMembershipNotificationJobSource,
IMembershipNotificationJob,
IMembershipNotificationJobSource,
+ IPersonCloseAccountJob,
+ IPersonCloseAccountJobSource,
IPersonDeactivateJob,
IPersonDeactivateJobSource,
IPersonMergeJob,
@@ -57,23 +69,45 @@ from lp.registry.interfaces.persontransferjob import (
)
from lp.registry.interfaces.teammembership import TeamMembershipStatus
from lp.registry.mail.teammembership import TeamMembershipMailer
-from lp.registry.model.person import Person
+from lp.registry.model.person import (
+ Person,
+ PersonSettings,
+ )
+from lp.registry.model.product import Product
+from lp.registry.model.productseries import ProductSeries
from lp.registry.personmerge import merge_people
from lp.services.config import config
+from lp.services.database import postgresql
+from lp.services.database.constants import DEFAULT
from lp.services.database.decoratedresultset import DecoratedResultSet
from lp.services.database.enumcol import EnumCol
from lp.services.database.interfaces import (
IMasterStore,
IStore,
)
+from lp.services.database.sqlbase import cursor
from lp.services.database.stormbase import StormBase
+from lp.services.identity.interfaces.account import (
+ AccountCreationRationale,
+ AccountStatus,
+ )
+from lp.services.identity.model.emailaddress import EmailAddress
from lp.services.job.model.job import (
EnumeratedSubclass,
Job,
)
from lp.services.job.runner import BaseRunnableJob
from lp.services.mail.sendmail import format_address_for_person
+from lp.services.openid.model.openididentifier import OpenIdIdentifier
from lp.services.scripts import log
+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
@implementer(IPersonTransferJob)
@@ -437,6 +471,491 @@ class PersonDeactivateJob(PersonTransferJobDerived):
return 'deactivating ~%s' % self.person.name
+@implementer(IPersonCloseAccountJob)
+@provider(IPersonCloseAccountJobSource)
+class PersonCloseAccountJob(PersonTransferJobDerived):
+ """A Job that closes account for a person."""
+
+ class_job_type = PersonTransferJobType.CLOSE_ACCOUNT
+
+ config = config.IPersonCloseAccountJobSource
+
+ @classmethod
+ def create(cls, username):
+ """See `IPersonCloseAccountJobSource`."""
+ # Minor person has to be not null, so use the janitor.
+ store = IMasterStore(Person)
+ janitor = getUtility(ILaunchpadCelebrities).janitor
+
+ 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:
+ raise TypeError("User %s does not exist" % username)
+ person_name = person.name
+
+ # We don't do teams
+ if person.is_team:
+ raise TypeError("%s is a team" % person_name)
+
+ log.info("Closing %s's account" % person_name)
+
+ return super(PersonCloseAccountJob, cls).create(
+ minor_person=janitor, major_person=person, metadata={})
+
+ @classmethod
+ def find(cls, person=None):
+ """See `IPersonMergeJobSource`."""
+ conditions = [
+ PersonCloseAccountJob.job_type == cls.class_job_type,
+ PersonCloseAccountJob.job_id == Job.id,
+ Job._status.is_in(Job.PENDING_STATUSES)]
+ arg_conditions = []
+ if person:
+ arg_conditions.append(PersonCloseAccountJob.major_person == person)
+ conditions.extend(arg_conditions)
+ return DecoratedResultSet(
+ IStore(PersonCloseAccountJob).find(
+ PersonCloseAccountJob, *conditions), cls)
+
+ @property
+ def person(self):
+ """See `IPersonCloseAccountJob`."""
+ return self.major_person
+
+ @property
+ def log_name(self):
+ return self.__class__.__name__
+
+ def getErrorRecipients(self):
+ """See `IPersonCloseAccountJob`."""
+ return [format_address_for_person(self.person)]
+
+ def run(self):
+ """Perform the account closure."""
+ try:
+ self.close_account(self.person)
+ except Exception:
+ log.error(
+ "%s Account clossure failed for user %s", self.log_name,
+ self.person.name)
+ transaction.abort()
+
+ def __repr__(self):
+ return (
+ "<{self.__class__.__name__} to close account "
+ "~{self.person.name}").format(self=self)
+
+ def getOperationDescription(self):
+ return 'closing account for ~%s' % self.person.name
+
+ def close_account(self, person):
+ """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)
+ username = self.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 '
+ u'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:
+ log.error(
+ "%s Can't delete non-trivial PPAs for user %s", self.log_name,
+ username)
+ raise IntegrityError(
+ "Can't delete non-trivial PPAs for user %s" % username)
+
+ 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" %
+ (username, 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"
+ % (username, 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" %
+ (username, count, src_tab, src_col))
+ has_references = True
+ if has_references:
+ log.error(
+ "%s User %s is still referenced", self.log_name,
+ username)
+ raise LaunchpadScriptFailure(
+ "User %s is still referenced" % username)
+
+ return True
+
+
@implementer(ITeamInvitationNotificationJob)
@provider(ITeamInvitationNotificationJobSource)
class TeamInvitationNotificationJob(PersonTransferJobDerived):
diff --git a/lib/lp/registry/scripts/closeaccount.py b/lib/lp/registry/scripts/closeaccount.py
index 6fa8585..77c7990 100644
--- a/lib/lp/registry/scripts/closeaccount.py
+++ b/lib/lp/registry/scripts/closeaccount.py
@@ -1,3 +1,4 @@
+<<<<<<< lib/lp/registry/scripts/closeaccount.py
# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
@@ -492,3 +493,5 @@ class CloseAccountScript(LaunchpadScript):
else:
self.logger.debug("Committing changes")
self.txn.commit()
+=======
+>>>>>>> lib/lp/registry/scripts/closeaccount.py
diff --git a/lib/lp/registry/scripts/tests/test_closeaccount.py b/lib/lp/registry/tests/test_person_close_account_job.py
similarity index 54%
rename from lib/lp/registry/scripts/tests/test_closeaccount.py
rename to lib/lp/registry/tests/test_person_close_account_job.py
index 33ccd7a..5d73e7a 100644
--- a/lib/lp/registry/scripts/tests/test_closeaccount.py
+++ b/lib/lp/registry/tests/test_person_close_account_job.py
@@ -1,13 +1,10 @@
-# Copyright 2018-2020 Canonical Ltd. This software is licensed under the
+# Copyright 2021 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
-"""Test the close-account script."""
-
-from __future__ import absolute_import, print_function, unicode_literals
+"""Tests for `PersonDeactivateJob`."""
__metaclass__ = type
-import six
from storm.store import Store
from testtools.matchers import (
MatchesSetwise,
@@ -25,226 +22,139 @@ from lp.app.interfaces.launchpad import ILaunchpadCelebrities
from lp.archivepublisher.config import getPubConfig
from lp.archivepublisher.publishing import Publisher
from lp.bugs.model.bugsummary import BugSummary
-from lp.code.enums import (
- CodeImportResultStatus,
- TargetRevisionControlSystems,
- )
+from lp.code.enums import TargetRevisionControlSystems, CodeImportResultStatus
from lp.code.interfaces.codeimportjob import ICodeImportJobWorkflow
from lp.code.tests.helpers import GitHostingFixture
from lp.registry.interfaces.person import IPersonSet
+from lp.registry.interfaces.persontransferjob import (
+ IPersonCloseAccountJobSource,
+ )
from lp.registry.interfaces.teammembership import ITeamMembershipSet
-from lp.registry.scripts.closeaccount import CloseAccountScript
+from lp.registry.model.persontransferjob import PersonCloseAccountJob
from lp.scripts.garbo import PopulateLatestPersonSourcePackageReleaseCache
+from lp.services.config import config
from lp.services.database.interfaces import IStore
-from lp.services.database.sqlbase import (
- flush_database_caches,
- get_transaction_timestamp,
- )
+from lp.services.database.sqlbase import get_transaction_timestamp
+from lp.services.features.testing import FeatureFixture
from lp.services.identity.interfaces.account import (
AccountStatus,
IAccountSet,
)
from lp.services.identity.interfaces.emailaddress import IEmailAddressSet
-from lp.services.job.interfaces.job import JobType
+from lp.services.job.interfaces.job import JobStatus, JobType
from lp.services.job.model.job import Job
-from lp.services.log.logger import (
- BufferLogger,
- DevNullLogger,
- )
+from lp.services.job.runner import JobRunner
+from lp.services.job.tests import block_on_job
+from lp.services.log.logger import BufferLogger, DevNullLogger
+from lp.services.scripts import log
from lp.services.scripts.base import LaunchpadScriptFailure
from lp.services.verification.interfaces.authtoken import LoginTokenType
from lp.services.verification.interfaces.logintoken import ILoginTokenSet
-from lp.soyuz.enums import (
- ArchiveSubscriberStatus,
- PackagePublishingStatus,
- )
+from lp.soyuz.enums import PackagePublishingStatus, ArchiveSubscriberStatus
from lp.soyuz.model.archive import Archive
from lp.soyuz.model.archiveauthtoken import ArchiveAuthToken
from lp.soyuz.tests.test_publishing import SoyuzTestPublisher
-from lp.testing import (
- login_celebrity,
- TestCaseWithFactory,
- )
+from lp.testing import TestCaseWithFactory, login_celebrity
from lp.testing.dbuser import dbuser
-from lp.testing.layers import LaunchpadZopelessLayer
+from lp.testing.layers import (
+ CeleryJobLayer,
+ LaunchpadZopelessLayer,
+ )
from lp.translations.interfaces.pofiletranslator import IPOFileTranslatorSet
from lp.translations.interfaces.translationsperson import ITranslationsPerson
-class TestCloseAccount(TestCaseWithFactory):
- """Test the close-account script.
-
- Unfortunately, we have no way of detecting schema updates containing new
- information that needs to be removed or sanitized on account closure
- apart from reviewers noticing and prompting developers to update this
- script. See Bug #120506 for more details.
- """
+class TestPersonCloseAccountJob(TestCaseWithFactory):
layer = LaunchpadZopelessLayer
- def makeScript(self, test_args):
- script = CloseAccountScript(test_args=test_args)
- script.logger = BufferLogger()
- script.txn = transaction
- return script
-
- def runScript(self, script):
- try:
- script.main()
- finally:
- self.addDetail("log", script.logger.content)
- flush_database_caches()
-
- def makePopulatedUser(self):
- """Return a person and account linked to some personal information."""
- person = self.factory.makePerson(karma=10)
- self.assertEqual(AccountStatus.ACTIVE, person.account.status)
- self.assertNotEqual([], list(person.account.openid_identifiers))
- self.factory.makeBugTask().transitionToAssignee(person, validate=False)
- self.factory.makeQuestion().assignee = person
- self.factory.makeQuestion(owner=person)
- self.factory.makeGPGKey(owner=person)
- self.factory.makeBranchSubscription(person=person)
- self.factory.makeBug().subscribe(person, person)
- self.factory.makeGitSubscription(person=person)
- team = self.factory.makeTeam()
- self.factory.makeMailingList(team, team.teamowner).subscribe(person)
- self.factory.makeQuestionSubscription(person=person)
- self.factory.makeSpecification().subscribe(person)
- person.addLanguage(self.factory.makeLanguage())
- self.factory.makeSSHKey(person=person)
- self.factory.makeAccessArtifactGrant(grantee=person)
- self.factory.makeAccessPolicyGrant(grantee=person)
- self.factory.makeGitRuleGrant(grantee=person)
- person.selfgenerated_bugnotifications = True
- person.expanded_notification_footers = True
- person.require_strong_email_authentication = True
- return person, person.id, person.account.id
-
- def assertRemoved(self, account_id, person_id):
- # The Account row still exists, but has been anonymised, leaving
- # only a minimal audit trail.
- account = getUtility(IAccountSet).get(account_id)
- self.assertEqual('Removed by request', account.displayname)
- self.assertEqual(AccountStatus.CLOSED, account.status)
- self.assertIn('Closed using close-account.', account.status_history)
-
- # The Person row still exists to maintain links with information
- # that won't be removed, such as bug comments, but has been
- # anonymised.
- person = getUtility(IPersonSet).get(person_id)
- self.assertThat(person.name, StartsWith('removed'))
- self.assertEqual('Removed by request', person.display_name)
- self.assertEqual(account, person.account)
-
- # The corresponding PersonSettings row has been reset to the
- # defaults.
- self.assertFalse(person.selfgenerated_bugnotifications)
- self.assertFalse(person.expanded_notification_footers)
- self.assertFalse(person.require_strong_email_authentication)
-
- # EmailAddress and OpenIdIdentifier rows have been removed.
- self.assertEqual(
- [], list(getUtility(IEmailAddressSet).getByPerson(person)))
- self.assertEqual([], list(account.openid_identifiers))
-
- def assertNotRemoved(self, account_id, person_id):
- account = getUtility(IAccountSet).get(account_id)
- self.assertNotEqual('Removed by request', account.displayname)
- self.assertEqual(AccountStatus.ACTIVE, account.status)
- person = getUtility(IPersonSet).get(person_id)
- self.assertEqual(account, person.account)
- self.assertNotEqual('Removed by request', person.display_name)
- self.assertThat(person.name, Not(StartsWith('removed')))
- self.assertNotEqual(
- [], list(getUtility(IEmailAddressSet).getByPerson(person)))
- self.assertNotEqual([], list(account.openid_identifiers))
-
- def test_nonexistent(self):
- script = self.makeScript(['nonexistent-person'])
- with dbuser('launchpad'):
- self.assertRaisesWithContent(
- LaunchpadScriptFailure,
- 'User nonexistent-person does not exist',
- self.runScript, script)
+ def test_close_account_job_nonexistent_username_email(self):
+ self.assertRaisesWithContent(
+ TypeError,
+ "User nonexistent_username does not exist",
+ getUtility(IPersonCloseAccountJobSource).create,
+ u'nonexistent_username')
+
+ self.assertRaisesWithContent(
+ TypeError,
+ "User nonexistent_email@xxxxxxxxxxxxx does not exist",
+ getUtility(IPersonCloseAccountJobSource).create,
+ u'nonexistent_email@xxxxxxxxxxxxx')
+
+ def test_close_account_job_valid_username(self):
+ user_to_delete = self.factory.makePerson(name=u'delete-me')
+ job_source = getUtility(IPersonCloseAccountJobSource)
+ jobs = list(job_source.iterReady())
+
+ # at this point we have no jobs
+ self.assertEqual([], jobs)
+
+ getUtility(IPersonCloseAccountJobSource).create(u'delete-me')
+ jobs = list(job_source.iterReady())
+ jobs[0] = removeSecurityProxy(jobs[0])
+ with dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ JobRunner(jobs).runAll()
+
+ self.assertEqual(JobStatus.COMPLETED, jobs[0].status)
+ person = removeSecurityProxy(
+ getUtility(IPersonSet).getByName(user_to_delete.name))
+ self.assertEqual(person.name, u'removed%d' % user_to_delete.id)
+
+ def test_close_account_job_valid_email(self):
+ user_to_delete = self.factory.makePerson(
+ email=u'delete-me@xxxxxxxxxxx')
+ getUtility(
+ IPersonCloseAccountJobSource).create(u'delete-me@xxxxxxxxxxx')
+ self.assertJobCompletes()
+
+ person = removeSecurityProxy(
+ getUtility(IPersonSet).getByName(user_to_delete.name))
+ self.assertEqual(person.name, u'removed%d' % user_to_delete.id)
def test_team(self):
team = self.factory.makeTeam()
- script = self.makeScript([team.name])
- with dbuser('launchpad'):
- self.assertRaisesWithContent(
- LaunchpadScriptFailure,
- '%s is a team' % team.name,
- self.runScript, script)
+ self.assertRaisesWithContent(
+ TypeError,
+ "%s is a team" % team.name,
+ getUtility(IPersonCloseAccountJobSource).create,
+ team.name)
def test_unhandled_reference(self):
- person = self.factory.makePerson()
+ user_to_delete = self.factory.makePerson(name=u'delete-me')
+ self.factory.makeProduct(owner=user_to_delete)
+ person = removeSecurityProxy(
+ getUtility(IPersonSet).getByName(user_to_delete.name))
person_id = person.id
account_id = person.account.id
- self.factory.makeProduct(owner=person)
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.assertRaisesWithContent(
- LaunchpadScriptFailure,
- 'User %s is still referenced' % person.name,
- self.runScript, script)
- self.assertIn(
- 'ERROR User %s is still referenced by 1 product.owner values' % (
- person.name),
- script.logger.getLogBuffer())
- self.assertNotRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(u'delete-me')
+ logger = BufferLogger()
+ with log.use(logger),\
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ error_message = (
+ {u'ERROR User delete-me is still '
+ u'referenced by 1 product.owner values',
+ u'ERROR User delete-me is still '
+ u'referenced by 1 productseries.owner values',
+ u'ERROR PersonCloseAccountJob User delete-me is still referenced'
+ })
+ self.assertTrue(
+ error_message.issubset(logger.getLogBuffer().splitlines()))
- def test_dry_run(self):
- person, person_id, account_id = self.makePopulatedUser()
- script = self.makeScript(['--dry-run', six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertIn(
- 'Dry run, so not committing changes', script.logger.getLogBuffer())
self.assertNotRemoved(account_id, person_id)
- def test_single_by_name(self):
- person, person_id, account_id = self.makePopulatedUser()
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
-
- def test_single_by_email(self):
- person, person_id, account_id = self.makePopulatedUser()
- script = self.makeScript([six.ensure_str(person.preferredemail.email)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
-
- def test_multiple(self):
- persons = [self.factory.makePerson() for _ in range(3)]
- person_ids = [person.id for person in persons]
- account_ids = [person.account.id for person in persons]
- script = self.makeScript([persons[0].name, persons[1].name])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_ids[0], person_ids[0])
- self.assertRemoved(account_ids[1], person_ids[1])
- self.assertNotRemoved(account_ids[2], person_ids[2])
-
- def test_multiple_email_addresses(self):
- person, person_id, account_id = self.makePopulatedUser()
- self.factory.makeEmail('%s@xxxxxxxxxxxxxxxxxxx' % person.name, person)
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
-
def test_unactivated(self):
person = self.factory.makePerson(
account_status=AccountStatus.NOACCOUNT)
person_id = person.id
account_id = person.account.id
- script = self.makeScript([person.guessedemails[0].email])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(person.guessedemails[0].email)
+ logger = BufferLogger()
+ with log.use(logger),\
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+
+ self.assertAccountRemoved(account_id, person_id)
def test_retains_audit_trail(self):
person = self.factory.makePerson()
@@ -255,10 +165,12 @@ class TestCloseAccount(TestCaseWithFactory):
snap = self.factory.makeSnap()
snap_build = self.factory.makeSnapBuild(requester=person, snap=snap)
specification = self.factory.makeSpecification(drafter=person)
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger),\
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
self.assertEqual(person, branch_subscription.subscribed_by)
self.assertEqual(person, snap_build.requester)
self.assertEqual(person, specification.drafter)
@@ -275,10 +187,12 @@ class TestCloseAccount(TestCaseWithFactory):
question.addComment(person, "comment")
removeSecurityProxy(question).status = status
questions.append(question)
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger),\
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
for question in questions:
self.assertEqual(QuestionStatus.SOLVED, question.status)
self.assertEqual(
@@ -297,10 +211,12 @@ class TestCloseAccount(TestCaseWithFactory):
question.addComment(person, "comment")
removeSecurityProxy(question).status = status
questions[status] = question
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger),\
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
for question_status, question in questions.items():
self.assertEqual(question_status, question.status)
self.assertIsNone(question.whiteboard)
@@ -323,10 +239,12 @@ class TestCloseAccount(TestCaseWithFactory):
while not job.isDone():
job(chunk_size=100)
self.assertTrue(person.hasMaintainedPackages())
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger),\
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
self.assertEqual(person, spph.package_maintainer)
self.assertEqual(person, spph.package_creator)
self.assertFalse(person.hasMaintainedPackages())
@@ -337,10 +255,12 @@ class TestCloseAccount(TestCaseWithFactory):
bugtask = self.factory.makeBugTask(bug=bug, owner=person)
person_id = person.id
account_id = person.account.id
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger),\
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
self.assertEqual(person, bug.owner)
self.assertEqual(person, bugtask.owner)
@@ -362,10 +282,12 @@ class TestCloseAccount(TestCaseWithFactory):
self.assertTrue(bug.isUserAffected(person))
person_id = person.id
account_id = person.account.id
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger),\
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
self.assertFalse(bug.isUserAffected(person))
def test_skips_translation_relicensing_agreements(self):
@@ -374,10 +296,12 @@ class TestCloseAccount(TestCaseWithFactory):
translations_person.translations_relicensing_agreement = True
person_id = person.id
account_id = person.account.id
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger),\
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
self.assertTrue(translations_person.translations_relicensing_agreement)
def test_skips_po_file_translators(self):
@@ -391,10 +315,12 @@ class TestCloseAccount(TestCaseWithFactory):
person, pofile))
person_id = person.id
account_id = person.account.id
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger),\
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
self.assertIsNotNone(
getUtility(IPOFileTranslatorSet).getForPersonPOFile(
person, pofile))
@@ -410,11 +336,13 @@ class TestCloseAccount(TestCaseWithFactory):
self.assertIsNotNone(ppa.getAuthToken(person))
person_id = person.id
account_id = person.account.id
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger),\
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
now = get_transaction_timestamp(Store.of(person))
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
self.assertEqual(
ArchiveSubscriberStatus.CANCELLED, subscription.status)
self.assertEqual(now, subscription.date_cancelled)
@@ -538,10 +466,12 @@ class TestCloseAccount(TestCaseWithFactory):
submission_ids[0], get_submission_by_submission_key(keys[0]))
person_id = person.id
account_id = person.account.id
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger),\
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
self.assertEqual([], get_submissions_by_owner(person))
self.assertIsNone(get_submission_by_submission_key(keys[0]))
self.assertEqual(
@@ -565,10 +495,12 @@ class TestCloseAccount(TestCaseWithFactory):
MatchesStructure.byEquality(count=1, viewed_by=other_person)))
person_id = person.id
account_id = person.account.id
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger),\
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
# BugSummaryJournal has been updated, but BugSummary hasn't yet.
summaries = list(store.find(
BugSummary,
@@ -587,6 +519,20 @@ class TestCloseAccount(TestCaseWithFactory):
self.assertThat(summaries, MatchesSetwise(
MatchesStructure.byEquality(viewed_by=other_person)))
+ def test_skips_inactive_product_owner(self):
+ person = self.factory.makePerson()
+ product = self.factory.makeProduct(owner=person)
+ product.active = False
+ person_id = person.id
+ account_id = person.account.id
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger),\
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
+ self.assertEqual(person, product.owner)
+
def test_skips_bug_nomination(self):
person = self.factory.makePerson()
other_person = self.factory.makePerson()
@@ -601,26 +547,16 @@ class TestCloseAccount(TestCaseWithFactory):
MatchesStructure.byEquality(owner=other_person)))
person_id = person.id
account_id = person.account.id
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger),\
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
self.assertThat(bug.getNominations(), MatchesSetwise(
MatchesStructure.byEquality(owner=person),
MatchesStructure.byEquality(owner=other_person)))
- def test_skips_inactive_product_owner(self):
- person = self.factory.makePerson()
- product = self.factory.makeProduct(owner=person)
- product.active = False
- person_id = person.id
- account_id = person.account.id
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
- self.assertEqual(person, product.owner)
-
def test_skips_code_import(self):
self.useFixture(GitHostingFixture())
person = self.factory.makePerson()
@@ -633,10 +569,12 @@ class TestCloseAccount(TestCaseWithFactory):
TargetRevisionControlSystems.GIT)]
person_id = person.id
account_id = person.account.id
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger),\
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
self.assertEqual(person, code_imports[0].registrant)
self.assertEqual(person, code_imports[1].registrant)
@@ -661,10 +599,12 @@ class TestCloseAccount(TestCaseWithFactory):
result_status=CodeImportResultStatus.SUCCESS)
person_id = person.id
account_id = person.account.id
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger), \
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
self.assertEqual(person, code_import.registrant)
self.assertEqual(person, result.requesting_user)
self.assertEqual(person, code_import.import_job.requesting_user)
@@ -689,10 +629,12 @@ class TestCloseAccount(TestCaseWithFactory):
removeSecurityProxy(ppa).owner = other_person
person_id = person.id
account_id = person.account.id
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ closeAccountjob = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger), \
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ closeAccountjob.run()
+ self.assertAccountRemoved(account_id, person_id)
self.assertEqual(person, job.requester)
def test_skips_specification_owner(self):
@@ -700,10 +642,12 @@ class TestCloseAccount(TestCaseWithFactory):
person_id = person.id
account_id = person.account.id
specification = self.factory.makeSpecification(owner=person)
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger), \
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
self.assertEqual(person, specification.owner)
def test_skips_teammembership_last_changed_by(self):
@@ -719,10 +663,12 @@ class TestCloseAccount(TestCaseWithFactory):
person_id = member.id
account_id = member.account.id
- script = self.makeScript([six.ensure_str(member.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(member.name)
+ logger = BufferLogger()
+ with log.use(logger), \
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
def test_skips_teamowner_merged(self):
person = self.factory.makePerson()
@@ -732,22 +678,27 @@ class TestCloseAccount(TestCaseWithFactory):
owned_team2 = self.factory.makeTeam(name='target2', owner=person)
person_id = person.id
account_id = person.account.id
- script = self.makeScript([six.ensure_str(person.name)])
# Closing account fails as the user still owns team2
- with dbuser('launchpad'):
- self.assertRaises(
- LaunchpadScriptFailure, self.runScript, script)
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger), \
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ # self.assertRaises(
+ # LaunchpadScriptFailure, job.run)
+ self.assertNotRemoved(account_id, person_id)
# Account will now close as the user doesn't own
# any other teams at this point
removeSecurityProxy(owned_team2).merged = merged_person
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ with log.use(logger), \
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
def test_handles_login_token(self):
- person = self.factory.makePerson()
+ person = self.factory.makePerson(name=u'delete-me')
email = '%s@xxxxxxxxxxxxxxxxxxx' % person.name
login_token_set = getUtility(ILoginTokenSet)
token = login_token_set.new(
@@ -757,10 +708,10 @@ class TestCloseAccount(TestCaseWithFactory):
self.assertEqual(token, login_token_set[plaintext_token])
person_id = person.id
account_id = person.account.id
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ getUtility(
+ IPersonCloseAccountJobSource).create(u'delete-me')
+ self.assertJobCompletes()
+ self.assertAccountRemoved(account_id, person_id)
self.assertRaises(
KeyError, login_token_set.__getitem__, plaintext_token)
@@ -775,10 +726,12 @@ class TestCloseAccount(TestCaseWithFactory):
[other_request_token], other_person.oauth_request_tokens)
person_id = person.id
account_id = person.account.id
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger), \
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
self.assertContentEqual([], person.oauth_request_tokens)
self.assertContentEqual(
[other_request_token], other_person.oauth_request_tokens)
@@ -794,10 +747,10 @@ class TestCloseAccount(TestCaseWithFactory):
[other_access_token], other_person.oauth_access_tokens)
person_id = person.id
account_id = person.account.id
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(person.name)
+ with dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
self.assertContentEqual([], person.oauth_access_tokens)
self.assertContentEqual(
[other_access_token], other_person.oauth_access_tokens)
@@ -809,16 +762,19 @@ class TestCloseAccount(TestCaseWithFactory):
ppa.setProcessors(procs)
person_id = person.id
account_id = person.account.id
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.assertRaisesWithContent(
- LaunchpadScriptFailure,
- 'User %s is still referenced' % person.name,
- self.runScript, script)
- self.assertIn(
- 'ERROR User %s is still referenced by 1 archive.owner values' % (
- person.name),
- script.logger.getLogBuffer())
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger), \
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ error_message = (
+ {u'ERROR User %s is still referenced by 1 '
+ u'archive.owner values' % person.name,
+ u'ERROR PersonCloseAccountJob User %s is still referenced'
+ % person.name
+ })
+ self.assertTrue(
+ error_message.issubset(logger.getLogBuffer().splitlines()))
self.assertNotRemoved(account_id, person_id)
def test_fails_on_deleted_ppa_with_builds(self):
@@ -832,12 +788,16 @@ class TestCloseAccount(TestCaseWithFactory):
DevNullLogger(), getPubConfig(ppa), None, ppa).deleteArchive()
person_id = person.id
account_id = person.account.id
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.assertRaisesWithContent(
- LaunchpadScriptFailure,
- "Can't delete non-trivial PPAs for user %s" % person.name,
- self.runScript, script)
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger), \
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ error_message = (
+ {u"ERROR PersonCloseAccountJob Can\'t delete non-trivial "
+ u"PPAs for user %s" % person.name})
+ self.assertTrue(
+ error_message.issubset(logger.getLogBuffer().splitlines()))
self.assertNotRemoved(account_id, person_id)
def test_handles_empty_deleted_ppa(self):
@@ -854,9 +814,78 @@ class TestCloseAccount(TestCaseWithFactory):
store = Store.of(ppa)
person_id = person.id
account_id = person.account.id
- script = self.makeScript([six.ensure_str(person.name)])
- with dbuser('launchpad'):
- self.runScript(script)
- self.assertRemoved(account_id, person_id)
+ job = PersonCloseAccountJob.create(person.name)
+ logger = BufferLogger()
+ with log.use(logger), \
+ dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ job.run()
+ self.assertAccountRemoved(account_id, person_id)
self.assertIsNone(store.get(Archive, ppa_id))
self.assertEqual(other_ppa, store.get(Archive, other_ppa_id))
+
+ def assertJobCompletes(self):
+ job_source = getUtility(IPersonCloseAccountJobSource)
+ jobs = list(job_source.iterReady())
+ jobs[0] = removeSecurityProxy(jobs[0])
+ with dbuser(config.IPersonCloseAccountJobSource.dbuser):
+ JobRunner(jobs).runAll()
+ self.assertEqual(JobStatus.COMPLETED, jobs[0].status)
+
+ def assertAccountRemoved(self, account_id, person_id):
+ # The Account row still exists, but has been anonymised, leaving
+ # only a minimal audit trail.
+ account = getUtility(IAccountSet).get(account_id)
+ self.assertEqual('Removed by request', account.displayname)
+ self.assertEqual(AccountStatus.CLOSED, account.status)
+ self.assertIn('Closed using close-account.', account.status_history)
+
+ # The Person row still exists to maintain links with information
+ # that won't be removed, such as bug comments, but has been
+ # anonymised.
+ person = getUtility(IPersonSet).get(person_id)
+ self.assertThat(person.name, StartsWith('removed'))
+ self.assertEqual('Removed by request', person.display_name)
+ self.assertEqual(account, person.account)
+
+ # The corresponding PersonSettings row has been reset to the
+ # defaults.
+ self.assertFalse(person.selfgenerated_bugnotifications)
+ self.assertFalse(person.expanded_notification_footers)
+ self.assertFalse(person.require_strong_email_authentication)
+
+ # EmailAddress and OpenIdIdentifier rows have been removed.
+ self.assertEqual(
+ [], list(getUtility(IEmailAddressSet).getByPerson(person)))
+ self.assertEqual([], list(account.openid_identifiers))
+
+ def assertNotRemoved(self, account_id, person_id):
+ account = getUtility(IAccountSet).get(account_id)
+ self.assertNotEqual('Removed by request', account.displayname)
+ self.assertEqual(AccountStatus.ACTIVE, account.status)
+ person = getUtility(IPersonSet).get(person_id)
+ self.assertEqual(account, person.account)
+ self.assertNotEqual('Removed by request', person.display_name)
+ self.assertThat(person.name, Not(StartsWith('removed')))
+ self.assertNotEqual(
+ [], list(getUtility(IEmailAddressSet).getByPerson(person)))
+ self.assertNotEqual([], list(account.openid_identifiers))
+
+
+class TestPersonCloseAccountJobViaCelery(TestCaseWithFactory):
+
+ layer = CeleryJobLayer
+
+ def test_PersonCloseAccountJob(self):
+ """PersonCloseAccountJob runs under Celery."""
+ self.useFixture(FeatureFixture(
+ {'jobs.celery.enabled_classes':
+ 'PersonCloseAccountJob'}))
+ user_to_delete = self.factory.makePerson(name=u'delete-me')
+
+ with block_on_job():
+ job = PersonCloseAccountJob.create(u'delete-me')
+ transaction.commit()
+ person = removeSecurityProxy(
+ getUtility(IPersonSet).getByName(user_to_delete.name))
+ self.assertEqual(person.name, u'removed%d' % user_to_delete.id)
+ self.assertEqual(JobStatus.COMPLETED, job.status)
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index db22192..50ad59d 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
+[IPersonCloseAccountJobSource]
+module: lp.registry.interfaces.persontransferjob
+dbuser: launchpad
+crontab_group: MAIN
+
[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/scripts/close-account.py b/scripts/close-account.py
deleted file mode 100755
index a66c679..0000000
--- a/scripts/close-account.py
+++ /dev/null
@@ -1,15 +0,0 @@
-#!/usr/bin/python2 -S
-#
-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
-# GNU Affero General Public License version 3 (see the file LICENSE).
-
-"""Remove personal details of a user from the database, leaving a stub."""
-
-import _pythonpath
-
-from lp.registry.scripts.closeaccount import CloseAccountScript
-
-
-if __name__ == '__main__':
- script = CloseAccountScript('close-account', dbuser='launchpad')
- script.run()
Follow ups