← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~sinzui/launchpad/person-merge-job-0 into lp:launchpad

 

Curtis Hovey has proposed merging lp:~sinzui/launchpad/person-merge-job-0 into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #728471 in Launchpad itself: "Person:+delete timeouts : Add sanity checks to mergeAsync"
  https://bugs.launchpad.net/launchpad/+bug/728471

For more details, see:
https://code.launchpad.net/~sinzui/launchpad/person-merge-job-0/+merge/53706

Add sanity checks to mergeAsync.

    Launchpad bug: https://bugs.launchpad.net/bugs/728471
    Pre-implementation: allenap, jcsackett
    Test command: ./bin/test -vv \
      -t webservice/xx-person.txt -t registry.tests.person.TestPerson.test \
      -t TestPersonMergeJob

This issue is a continuation of bug 162510. The infrastructure is in place to
merge people an team async.

- Export is_merge_pending to the API.

- No check is performed to see if either merge user is already the
  subject of a scheduled merge job.

--------------------------------------------------------------------

RULES

    * Export is_merge_pending()
    * Add a guard to mergeAsync() to ensure a merge is not already scheduled.
      * Use is_merge_pending()
      * revise is_merge_pending() to check *both* 'from' and 'to' because
        users can be working with several merges.
    * ADDENDUM: The test setup is incorrect. Fixing it reveals that DB
      permissions are wrong.


QA

    * Verify that is_merge_pending is exported:
        lp = Launchpad.login_with(
            'testing', 'https://api.launchpad.net/', version='devel')
        sinzui = lp.people['sinzui']
        print sinzui.is_merge_pending

LINT

    database/schema/security.cfg
    lib/lp/registry/interfaces/person.py
    lib/lp/registry/interfaces/persontransferjob.py
    lib/lp/registry/model/person.py
    lib/lp/registry/model/persontransferjob.py
    lib/lp/registry/stories/webservice/xx-person.txt
    lib/lp/registry/tests/test_person.py
    lib/lp/registry/tests/test_person_merge_job.py


IMPLEMENTATION

Exported is_merge_pending() and updated the docstring to apply to both
profiles.
    lp/registry/stories/webservice/xx-person.txt
    lp/registry/interfaces/person.py

Updated is_merge_pending() to meet the needs of the UI and added a guard to
mergeAsync(). Updated the tests and the DB permissions to ensure merge works
for teams and users.
    database/schema/security.cfg
    lp/registry/interfaces/person.py
    lib/lp/registry/interfaces/persontransferjob.py
    lib/lp/registry/model/person.py
    lib/lp/registry/model/persontransferjob.py
    lib/lp/registry/tests/test_person.py
    lib/lp/registry/tests/test_person_merge_job.py
-- 
https://code.launchpad.net/~sinzui/launchpad/person-merge-job-0/+merge/53706
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~sinzui/launchpad/person-merge-job-0 into lp:launchpad.
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2011-03-14 21:07:09 +0000
+++ database/schema/security.cfg	2011-03-16 21:18:36 +0000
@@ -2128,6 +2128,7 @@
 public.nameblacklist                    = SELECT, UPDATE
 public.oauthaccesstoken                 = SELECT, UPDATE
 public.oauthrequesttoken                = SELECT, UPDATE
+public.openididentifier                 = SELECT, UPDATE
 public.packagebugsupervisor             = SELECT, UPDATE
 public.packagecopyrequest               = SELECT, UPDATE
 public.packagediff                      = SELECT, UPDATE
@@ -2137,7 +2138,7 @@
 public.person                           = SELECT, UPDATE
 public.personlanguage                   = SELECT, UPDATE
 public.personlocation                   = SELECT, UPDATE
-public.personnotification               = SELECT, UPDATE
+public.personnotification               = SELECT, INSERT, UPDATE
 public.personsettings                   = SELECT, UPDATE
 public.persontransferjob                = SELECT, UPDATE
 public.pocomment                        = SELECT, UPDATE

=== modified file 'lib/lp/registry/interfaces/person.py'
--- lib/lp/registry/interfaces/person.py	2011-03-01 09:41:39 +0000
+++ lib/lp/registry/interfaces/person.py	2011-03-16 21:18:36 +0000
@@ -974,9 +974,9 @@
             description=_("Private teams are visible only to "
                           "their members.")))
 
-    is_merge_pending = Bool(
-        title=_("Is this person due to be merged into another?"),
-        required=False, default=False)
+    is_merge_pending = exported(Bool(
+        title=_("Is this person due to be merged with another?"),
+        required=False, default=False))
 
     @invariant
     def personCannotHaveIcon(person):
@@ -2160,9 +2160,10 @@
         This schedules a call to `merge()` to happen outside of the current
         context/request. The intention is that it is called soon after this
         method is called but there is no guarantee of that, nor is that call
-        guaranteed to succeed.
+        guaranteed to succeed. If either user is in a pending person merge
+        job, None is returned.
 
-        :return: A `PersonMergeJob`.
+        :return: A `PersonMergeJob` or None.
         """
 
     def merge(from_person, to_person):

=== modified file 'lib/lp/registry/interfaces/persontransferjob.py'
--- lib/lp/registry/interfaces/persontransferjob.py	2011-02-23 16:33:55 +0000
+++ lib/lp/registry/interfaces/persontransferjob.py	2011-03-16 21:18:36 +0000
@@ -100,17 +100,25 @@
     """An interface for acquiring IMembershipNotificationJobs."""
 
     def create(from_person, to_person):
-        """Create a new IMembershipNotificationJob."""
-
-    def find(from_person=None, to_person=None):
+        """Create a new IMembershipNotificationJob.
+
+        None is returned if either the from_person or to_person are already
+        in a pending merge.
+        """
+
+    def find(from_person=None, to_person=None, any_person=False):
         """Finds pending merge jobs.
 
         :param from_person: Match jobs on `from_person`, or `None` to ignore
             `from_person`.
         :param to_person: Match jobs on `to_person`, or `None` to ignore
             `from_person`.
+        :param any_person: Match jobs on the `to_person` or the `from_person`
+            when both `to_person` and `from_person` are not None.
         :return: A `ResultSet` yielding `IPersonMergeJob`.
 
         If both `from_person` and `to_person` is supplied, only jobs where
-        both match are returned.
+        both match are returned by default. When any_person is True and
+        `from_person` and `to_person` are supplied, jobs with either person
+        specified are returned.
         """

=== modified file 'lib/lp/registry/model/person.py'
--- lib/lp/registry/model/person.py	2011-03-09 18:18:02 +0000
+++ lib/lp/registry/model/person.py	2011-03-16 21:18:36 +0000
@@ -2138,7 +2138,8 @@
     def is_merge_pending(self):
         """See `IPublicPerson`."""
         return not getUtility(
-            IPersonMergeJobSource).find(from_person=self).is_empty()
+            IPersonMergeJobSource).find(
+                from_person=self, to_person=self, any_person=True).is_empty()
 
     def visibilityConsistencyWarning(self, new_value):
         """Warning used when changing the team's visibility.

=== modified file 'lib/lp/registry/model/persontransferjob.py'
--- lib/lp/registry/model/persontransferjob.py	2011-03-10 19:11:04 +0000
+++ lib/lp/registry/model/persontransferjob.py	2011-03-16 21:18:36 +0000
@@ -11,7 +11,10 @@
 
 from lazr.delegates import delegates
 import simplejson
-from storm.expr import And
+from storm.expr import (
+    And,
+    Or,
+    )
 from storm.locals import (
     Int,
     Reference,
@@ -338,22 +341,28 @@
     @classmethod
     def create(cls, from_person, to_person):
         """See `IPersonMergeJobSource`."""
+        if from_person.is_merge_pending or to_person.is_merge_pending:
+            return None
         return super(PersonMergeJob, cls).create(
             minor_person=from_person, major_person=to_person, metadata={})
 
     @classmethod
-    def find(cls, from_person=None, to_person=None):
+    def find(cls, from_person=None, to_person=None, any_person=False):
         """See `IPersonMergeJobSource`."""
         conditions = [
             PersonTransferJob.job_type == cls.class_job_type,
             PersonTransferJob.job_id == Job.id,
             Job._status.is_in(Job.PENDING_STATUSES)]
+        arg_conditions = []
         if from_person is not None:
-            conditions.append(
+            arg_conditions.append(
                 PersonTransferJob.minor_person == from_person)
         if to_person is not None:
-            conditions.append(
+            arg_conditions.append(
                 PersonTransferJob.major_person == to_person)
+        if any_person and from_person is not None and to_person is not None:
+            arg_conditions = [Or(*arg_conditions)]
+        conditions.extend(arg_conditions)
         return DecoratedResultSet(
             IStore(PersonTransferJob).find(
                 PersonTransferJob, *conditions), cls)

=== modified file 'lib/lp/registry/stories/webservice/xx-person.txt'
--- lib/lp/registry/stories/webservice/xx-person.txt	2011-02-24 23:41:43 +0000
+++ lib/lp/registry/stories/webservice/xx-person.txt	2011-03-16 21:18:36 +0000
@@ -25,6 +25,7 @@
     homepage_content: None
     invited_members_collection_link: u'http://.../~salgado/invited_members'
     irc_nicknames_collection_link: u'http://.../~salgado/irc_nicknames'
+    is_merge_pending: False
     is_probationary: True
     is_team: False
     is_ubuntu_coc_signer: False
@@ -84,6 +85,7 @@
     invited_members_collection_link:
         u'http://.../~ubuntu-team/invited_members'
     irc_nicknames_collection_link: u'http://.../~ubuntu-team/irc_nicknames'
+    is_merge_pending: False
     is_probationary: False
     is_team: True
     is_ubuntu_coc_signer: False

=== modified file 'lib/lp/registry/tests/test_person.py'
--- lib/lp/registry/tests/test_person.py	2011-03-08 22:31:58 +0000
+++ lib/lp/registry/tests/test_person.py	2011-03-16 21:18:36 +0000
@@ -284,13 +284,21 @@
         self.assertFalse(person.is_merge_pending)
 
     def test_merge_pending(self):
-        # is_merge_pending returns True when this person is the "from" person
-        # of an active merge job.
+        # is_merge_pending returns True when this person is being merged with
+        # another person in an active merge job.
         from_person = self.factory.makePerson()
         to_person = self.factory.makePerson()
         getUtility(IPersonSet).mergeAsync(from_person, to_person)
         self.assertTrue(from_person.is_merge_pending)
-        self.assertFalse(to_person.is_merge_pending)
+        self.assertTrue(to_person.is_merge_pending)
+
+    def test_mergeAsync_success(self):
+        # mergeAsync returns a job with the from and to persons.
+        from_person = self.factory.makePerson()
+        to_person = self.factory.makePerson()
+        job = getUtility(IPersonSet).mergeAsync(from_person, to_person)
+        self.assertEqual(from_person, job.from_person)
+        self.assertEqual(to_person, job.to_person)
 
     def test_selfgenerated_bugnotifications_none_by_default(self):
         # Default for new accounts is to not get any

=== modified file 'lib/lp/registry/tests/test_person_merge_job.py'
--- lib/lp/registry/tests/test_person_merge_job.py	2011-02-24 11:52:51 +0000
+++ lib/lp/registry/tests/test_person_merge_job.py	2011-03-16 21:18:36 +0000
@@ -12,6 +12,11 @@
 from zope.interface.verify import verifyObject
 from zope.security.proxy import removeSecurityProxy
 
+from canonical.launchpad.interfaces.lpstorm import (
+    IMasterObject,
+    IStore,
+    )
+from canonical.launchpad.interfaces.emailaddress import EmailAddressStatus
 from canonical.launchpad.scripts import log
 from canonical.testing import DatabaseFunctionalLayer
 from lp.registry.interfaces.persontransferjob import (
@@ -23,6 +28,7 @@
 from lp.services.log.logger import BufferLogger
 from lp.testing import (
     run_script,
+    person_logged_in,
     TestCaseWithFactory,
     )
 
@@ -33,8 +39,10 @@
 
     def setUp(self):
         super(TestPersonMergeJob, self).setUp()
-        self.from_person = self.factory.makePerson(name='void')
-        self.to_person = self.factory.makeTeam(name='gestalt')
+        self.from_person = self.factory.makePerson(
+            name='void', email='void@xxxxxx')
+        self.to_person = self.factory.makePerson(
+            name='gestalt', email='gestalt@xxxxxx')
         self.job_source = getUtility(IPersonMergeJobSource)
         self.job = self.job_source.create(
             from_person=self.from_person, to_person=self.to_person)
@@ -55,14 +63,28 @@
         # Newly created jobs are enqueued.
         self.assertEqual([self.job], list(self.job_source.iterReady()))
 
+    def test_create_job_already_exists(self):
+        # create returns None if either of the persons are already
+        # in a pending merge job.
+        duplicate_job = self.job_source.create(
+            from_person=self.from_person, to_person=self.to_person)
+        inverted_job = self.job_source.create(
+            from_person=self.to_person, to_person=self.from_person)
+        self.assertEqual(None, duplicate_job)
+        self.assertEqual(None, inverted_job)
+
+    def transfer_email(self):
+        # Reassign from_person's email address over to to_person because
+        # IPersonSet.merge() does not (yet) promise to do that.
+        from_email = IMasterObject(self.from_person.preferredemail)
+        removeSecurityProxy(from_email).personID = self.to_person.id
+        removeSecurityProxy(from_email).accountID = self.to_person.accountID
+        removeSecurityProxy(from_email).status = EmailAddressStatus.NEW
+        IStore(from_email).flush()
+
     def test_run(self):
-        # When run it merges from_person into to_person. First we need to
-        # reassign from_person's email address over to to_person because
-        # IPersonSet.merge() does not (yet) promise to do that.
-        from_email = self.from_person.preferredemail
-        removeSecurityProxy(from_email).personID = self.to_person.id
-        removeSecurityProxy(from_email).accountID = self.to_person.accountID
-
+        # When run it merges from_person into to_person.
+        self.transfer_email()
         logger = BufferLogger()
         with log.use(logger):
             self.job.run()
@@ -72,12 +94,18 @@
             ["DEBUG PersonMergeJob is about to merge ~void into ~gestalt",
              "DEBUG PersonMergeJob has merged ~void into ~gestalt"],
             logger.getLogBuffer().splitlines())
+        self.assertEqual(self.to_person, self.from_person.merged)
 
     def test_smoke(self):
-        # Smoke test, primarily for DB permissions.
-        from_email = self.from_person.preferredemail
-        removeSecurityProxy(from_email).personID = self.to_person.id
-        removeSecurityProxy(from_email).accountID = self.to_person.accountID
+        # Smoke test, primarily for DB permissions need for users and teams.
+        # Check the oopses in /var/tmp/lperr.test if the person.merged
+        # assertion fails.
+        self.transfer_email()
+        to_team = self.factory.makeTeam(name='legion')
+        from_team = self.factory.makeTeam(name='null')
+        with person_logged_in(from_team.teamowner):
+            from_team.teamowner.leave(from_team)
+        self.job_source.create(from_person=from_team, to_person=to_team)
         transaction.commit()
 
         out, err, exit_code = run_script(
@@ -88,7 +116,9 @@
         self.addDetail("stderr", Content(UTF8_TEXT, lambda: err))
 
         self.assertEqual(0, exit_code)
+        IStore(self.from_person).invalidate()
         self.assertEqual(self.to_person, self.from_person.merged)
+        self.assertEqual(to_team, from_team.merged)
 
     def test_repr(self):
         # A useful representation is available for PersonMergeJob instances.
@@ -113,6 +143,18 @@
         self.assertEqual(
             [], self.find(from_person=self.to_person))
 
+    def test_find_any_person(self):
+        # find() any_person looks for merge jobs with either from_person
+        # or to_person is true when both are specified.
+        self.assertEqual(
+            [self.job], self.find(
+                from_person=self.to_person, to_person=self.to_person,
+                any_person=True))
+        self.assertEqual(
+            [self.job], self.find(
+                from_person=self.from_person, to_person=self.from_person,
+                any_person=True))
+
     def test_find_only_pending_or_running(self):
         # find() only returns jobs that are pending.
         for status in JobStatus.items: