← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wgrant/launchpad/retarget-bugtask-model into lp:launchpad

 

William Grant has proposed merging lp:~wgrant/launchpad/retarget-bugtask-model into lp:launchpad with lp:~wgrant/launchpad/destroy-bugtask-markers as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~wgrant/launchpad/retarget-bugtask-model/+merge/68761

This branch extends BugTask.transitionToTarget to permit most changes. It was previously restricted to not changing the type of a task (eg. product->product, distribution->distributionsourcepackage) because of the IUpstreamBugTask, IDistroBugTask, etc. marker interfaces. But the prerequisite branches remove those, so changing task types is fine now.

transitionToTarget now uses a new bug_target_to_key function, which converts an IBugTarget to a dict of column values. This is the inverse of the existing determine_target, which has been renamed to bug_target_from_key.

I've added tests for the various cases. There are only three that are disallowed now: transition to or from a productseries; transition to or from a distroseries; and transition to or from a sourcepackage, except where it is to or from another sourcepackage in the same distroseries. Transitioning between series is forbidden, as that's what nominations are for.
-- 
https://code.launchpad.net/~wgrant/launchpad/retarget-bugtask-model/+merge/68761
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wgrant/launchpad/retarget-bugtask-model into lp:launchpad.
=== modified file 'lib/lp/bugs/model/bugtask.py'
--- lib/lp/bugs/model/bugtask.py	2011-07-22 00:17:11 +0000
+++ lib/lp/bugs/model/bugtask.py	2011-07-22 00:17:15 +0000
@@ -14,7 +14,8 @@
     'BugTask',
     'BugTaskSet',
     'bugtask_sort_key',
-    'determine_target',
+    'bug_target_from_key',
+    'bug_target_to_key',
     'get_bug_privacy_filter',
     'get_related_bugtasks_search_params',
     'search_value_to_where_condition',
@@ -267,9 +268,9 @@
     return search_params
 
 
-def determine_target(product, productseries, distribution, distroseries,
-                     sourcepackagename):
-    """Returns the IBugTarget defined by the given arguments."""
+def bug_target_from_key(product, productseries, distribution, distroseries,
+                        sourcepackagename):
+    """Returns the IBugTarget defined by the given DB column values."""
     if product:
         return product
     elif productseries:
@@ -290,6 +291,34 @@
         raise AssertionError("Unable to determine bugtask target.")
 
 
+def bug_target_to_key(target):
+    """Returns the DB column values for an IBugTarget."""
+    values = dict(
+                product=None,
+                productseries=None,
+                distribution=None,
+                distroseries=None,
+                sourcepackagename=None,
+                )
+    if IProduct.providedBy(target):
+        values['product'] = target
+    elif IProductSeries.providedBy(target):
+        values['productseries'] = target
+    elif IDistribution.providedBy(target):
+        values['distribution'] = target
+    elif IDistroSeries.providedBy(target):
+        values['distroseries'] = target
+    elif IDistributionSourcePackage.providedBy(target):
+        values['distribution'] = target.distribution
+        values['sourcepackagename'] = target.sourcepackagename
+    elif ISourcePackage.providedBy(target):
+        values['distroseries'] = target.distroseries
+        values['sourcepackagename'] = target.sourcepackagename
+    else:
+        raise AssertionError("Not an IBugTarget.")
+    return values
+
+
 class BugTaskDelta:
     """See `IBugTaskDelta`."""
 
@@ -332,7 +361,7 @@
     @property
     def target(self):
         """See `IBugTask`."""
-        return determine_target(
+        return bug_target_from_key(
             self.product, self.productseries, self.distribution,
             self.distroseries, self.sourcepackagename)
 
@@ -377,7 +406,7 @@
 def validate_target_attribute(self, attr, value):
     """Update the targetnamecache."""
     # Don't update targetnamecache during _init().
-    if self._SO_creating:
+    if self._SO_creating or self._inhibit_target_check:
         return value
     # Determine the new target attributes.
     target_params = dict(
@@ -402,7 +431,7 @@
     # Update the target name cache with the potential new target. The
     # attribute changes haven't been made yet, so we need to calculate the
     # target manually.
-    self.updateTargetNameCache(determine_target(**target_params))
+    self.updateTargetNameCache(bug_target_from_key(**target_params))
 
     return value
 
@@ -484,6 +513,8 @@
         "date_left_closed")
     _NON_CONJOINED_STATUSES = (BugTaskStatus.WONTFIX, )
 
+    _inhibit_target_check = False
+
     bug = ForeignKey(dbName='bug', foreignKey='Bug', notNull=True)
     product = ForeignKey(
         dbName='product', foreignKey='Product',
@@ -1078,22 +1109,29 @@
             # current target, or reset it to None
             self.milestone = None
 
-        if self.product:
-            if IProduct.providedBy(target):
-                self.product = target
-            else:
-                raise IllegalTarget(
-                    "Upstream bug tasks may only be re-targeted "
-                    "to another project.")
-        else:
-            if (IDistributionSourcePackage.providedBy(target) and
-                (target.distribution == self.target or
-                 target.distribution == self.target.distribution)):
-                self.sourcepackagename = target.sourcepackagename
-            else:
-                raise IllegalTarget(
-                    "Distribution bug tasks may only be re-targeted "
-                    "to a package in the same distribution.")
+        # Check if any series are involved. You can't retarget series
+        # tasks. Except for SourcePackage tasks, which can only be
+        # retargetted to another SourcePackage in the same DistroSeries.
+        interfaces = set(providedBy(target))
+        interfaces.update(providedBy(self.target))
+        if interfaces.intersection((IProductSeries, IDistroSeries)):
+            raise IllegalTarget(
+                "Series tasks may only be created by approving nominations.")
+        elif ISourcePackage in interfaces:
+            if (not ISourcePackage.providedBy(target) or
+                not ISourcePackage.providedBy(self.target) or
+                target.distroseries != self.target.distroseries):
+                raise IllegalTarget(
+                    "Series source package tasks may only be retargetted "
+                    "to another source package in the same series.")
+
+        # Inhibit validate_target_attribute, as we can't set them all
+        # atomically, but we know the final result is correct.
+        self._inhibit_target_check = True
+        for name, value in bug_target_to_key(target).iteritems():
+            setattr(self, name, value)
+        self._inhibit_target_check = False
+        self.updateTargetNameCache()
 
         # After the target has changed, we need to recalculate the maximum bug
         # heat for the new and old targets.

=== modified file 'lib/lp/bugs/model/tests/test_bugtask.py'
--- lib/lp/bugs/model/tests/test_bugtask.py	2011-07-22 00:17:11 +0000
+++ lib/lp/bugs/model/tests/test_bugtask.py	2011-07-22 00:17:15 +0000
@@ -6,9 +6,11 @@
 from datetime import timedelta
 import unittest
 
+from lazr.lifecycle.event import ObjectModifiedEvent
 from lazr.lifecycle.snapshot import Snapshot
 from testtools.matchers import Equals
 from zope.component import getUtility
+from zope.event import notify
 from zope.interface import providedBy
 from zope.security.proxy import removeSecurityProxy
 
@@ -35,7 +37,12 @@
     UNRESOLVED_BUGTASK_STATUSES,
     )
 from lp.bugs.interfaces.bugwatch import IBugWatchSet
-from lp.bugs.model.bugtask import build_tag_search_clause
+from lp.bugs.model.bugtask import (
+    bug_target_from_key,
+    bug_target_to_key,
+    build_tag_search_clause,
+    IllegalTarget,
+    )
 from lp.bugs.tests.bug import (
     create_old_bug,
     )
@@ -1650,3 +1657,189 @@
                 bug_task.maybeConfirm()
                 self.assertEqual(BugTaskStatus.CONFIRMED, bug_task.status)
                 self.assertEqual(0, len(recorder.events))
+
+
+class TestTransitionToTarget(TestCaseWithFactory):
+    """Tests for BugTask.transitionToTarget."""
+
+    layer = DatabaseFunctionalLayer
+
+    def makeAndTransition(self, old, new):
+        task = self.factory.makeBugTask(target=old)
+        p = self.factory.makePerson()
+        self.assertEqual(old, task.target)
+        old_state = Snapshot(task, providing=providedBy(task))
+        with person_logged_in(task.owner):
+            task.bug.subscribe(p, p)
+            task.transitionToTarget(new)
+            notify(ObjectModifiedEvent(task, old_state, ["target"]))
+        return task
+
+    def assertTransitionWorks(self, a, b):
+        """Check that a transition between two targets works both ways."""
+        self.assertEqual(b, self.makeAndTransition(a, b).target)
+        self.assertEqual(a, self.makeAndTransition(b, a).target)
+
+    def assertTransitionForbidden(self, a, b):
+        """Check that a transition between two targets fails both ways."""
+        self.assertRaises(IllegalTarget, self.makeAndTransition, a, b)
+        self.assertRaises(IllegalTarget, self.makeAndTransition, b, a)
+
+    def test_product_to_product_works(self):
+        self.assertTransitionWorks(
+            self.factory.makeProduct(),
+            self.factory.makeProduct())
+
+    def test_product_to_distribution_works(self):
+        self.assertTransitionWorks(
+            self.factory.makeProduct(),
+            self.factory.makeDistributionSourcePackage())
+
+    def test_product_to_package_works(self):
+        self.assertTransitionWorks(
+            self.factory.makeProduct(),
+            self.factory.makeDistributionSourcePackage())
+
+    def test_distribution_to_distribution_works(self):
+        self.assertTransitionWorks(
+            self.factory.makeDistribution(),
+            self.factory.makeDistribution())
+
+    def test_distribution_to_package_works(self):
+        distro = self.factory.makeDistribution()
+        dsp = self.factory.makeDistributionSourcePackage(distribution=distro)
+        self.assertEquals(dsp.distribution, distro)
+        self.assertTransitionWorks(distro, dsp)
+
+    def test_package_to_package_works(self):
+        distro = self.factory.makeDistribution()
+        self.assertTransitionWorks(
+            self.factory.makeDistributionSourcePackage(distribution=distro),
+            self.factory.makeDistributionSourcePackage(distribution=distro))
+
+    def test_sourcepackage_to_sourcepackage_in_same_series_works(self):
+        sp1 = self.factory.makeSourcePackage()
+        sp2 = self.factory.makeSourcePackage(distroseries=sp1.distroseries)
+        self.assertTransitionWorks(sp1, sp2)
+
+    def test_different_distros_works(self):
+        self.assertTransitionWorks(
+            self.factory.makeDistributionSourcePackage(),
+            self.factory.makeDistributionSourcePackage())
+
+    def test_cannot_transition_to_productseries(self):
+        product = self.factory.makeProduct()
+        self.assertTransitionForbidden(
+            product,
+            self.factory.makeProductSeries(product=product))
+
+    def test_cannot_transition_to_distroseries(self):
+        distro = self.factory.makeDistribution()
+        series = self.factory.makeDistroSeries(distribution=distro)
+        self.assertTransitionForbidden(distro, series)
+
+    def test_cannot_transition_to_sourcepackage(self):
+        dsp = self.factory.makeDistributionSourcePackage()
+        series = self.factory.makeDistroSeries(distribution=dsp.distribution)
+        sp = self.factory.makeSourcePackage(
+            distroseries=series, sourcepackagename=dsp.sourcepackagename)
+        self.assertTransitionForbidden(dsp, sp)
+
+    def test_cannot_transition_to_sourcepackage_in_different_series(self):
+        distro = self.factory.makeDistribution()
+        ds1 = self.factory.makeDistroSeries(distribution=distro)
+        sp1 = self.factory.makeSourcePackage(distroseries=ds1)
+        ds2 = self.factory.makeDistroSeries(distribution=distro)
+        sp2 = self.factory.makeSourcePackage(distroseries=ds2)
+        self.assertTransitionForbidden(sp1, sp2)
+
+
+class TestBugTargetKeys(TestCaseWithFactory):
+    """Tests for bug_target_to_key and bug_target_from_key."""
+
+    layer = DatabaseFunctionalLayer
+
+    def assertTargetKeyWorks(self, target, flat):
+        """Check that a target flattens to the dict and back."""
+        self.assertEqual(flat, bug_target_to_key(target))
+        self.assertEqual(target, bug_target_from_key(**flat))
+
+    def test_product(self):
+        product = self.factory.makeProduct()
+        self.assertTargetKeyWorks(
+            product,
+            dict(
+                product=product,
+                productseries=None,
+                distribution=None,
+                distroseries=None,
+                sourcepackagename=None,
+                ))
+
+    def test_productseries(self):
+        series = self.factory.makeProductSeries()
+        self.assertTargetKeyWorks(
+            series,
+            dict(
+                product=None,
+                productseries=series,
+                distribution=None,
+                distroseries=None,
+                sourcepackagename=None,
+                ))
+
+    def test_distribution(self):
+        distro = self.factory.makeDistribution()
+        self.assertTargetKeyWorks(
+            distro,
+            dict(
+                product=None,
+                productseries=None,
+                distribution=distro,
+                distroseries=None,
+                sourcepackagename=None,
+                ))
+
+    def test_distroseries(self):
+        distroseries = self.factory.makeDistroSeries()
+        self.assertTargetKeyWorks(
+            distroseries,
+            dict(
+                product=None,
+                productseries=None,
+                distribution=None,
+                distroseries=distroseries,
+                sourcepackagename=None,
+                ))
+
+    def test_distributionsourcepackage(self):
+        dsp = self.factory.makeDistributionSourcePackage()
+        self.assertTargetKeyWorks(
+            dsp,
+            dict(
+                product=None,
+                productseries=None,
+                distribution=dsp.distribution,
+                distroseries=None,
+                sourcepackagename=dsp.sourcepackagename,
+                ))
+
+    def test_sourcepackage(self):
+        sp = self.factory.makeSourcePackage()
+        self.assertTargetKeyWorks(
+            sp,
+            dict(
+                product=None,
+                productseries=None,
+                distribution=None,
+                distroseries=sp.distroseries,
+                sourcepackagename=sp.sourcepackagename,
+                ))
+
+    def test_no_key_for_non_targets(self):
+        self.assertRaises(
+            AssertionError, bug_target_to_key, self.factory.makePerson())
+
+    def test_no_target_for_bad_keys(self):
+        self.assertRaises(
+            AssertionError, bug_target_from_key, None, None, None, None, None)

=== modified file 'lib/lp/bugs/scripts/bugtasktargetnamecaches.py'
--- lib/lp/bugs/scripts/bugtasktargetnamecaches.py	2011-02-14 22:26:18 +0000
+++ lib/lp/bugs/scripts/bugtasktargetnamecaches.py	2011-07-22 00:17:15 +0000
@@ -18,7 +18,7 @@
 from canonical.launchpad.utilities.looptuner import LoopTuner
 from lp.bugs.model.bugtask import (
     BugTask,
-    determine_target,
+    bug_target_from_key,
     )
 from lp.registry.model.distribution import Distribution
 from lp.registry.model.distroseries import DistroSeries
@@ -104,7 +104,7 @@
             target_objects = (
                 (store.get(cls, id) if id is not None else None)
                 for cls, id in zip(target_classes, target_bits))
-            target = determine_target(*target_objects)
+            target = bug_target_from_key(*target_objects)
             new_name = target.bugtargetdisplayname
             cached_names.discard(new_name)
             # If there are any outdated names cached, update them all in

=== modified file 'lib/lp/bugs/stories/webservice/xx-bug.txt'
--- lib/lp/bugs/stories/webservice/xx-bug.txt	2011-07-13 06:08:16 +0000
+++ lib/lp/bugs/stories/webservice/xx-bug.txt	2011-07-22 00:17:15 +0000
@@ -477,27 +477,6 @@
     >>> print task['target_link']
     http://api.../debian/+source/alsa-utils
 
-Just like in the web UI, we can't change a distribution task
-into an upstream task.
-
-    >>> print webservice.named_post(
-    ...     task['self_link'], 'transitionToTarget',
-    ...     target=webservice.getAbsoluteUrl('/bzr'))
-    HTTP/1.1 400 Bad Request
-    ...
-    Distribution bug tasks may only be re-targeted to
-    a package in the same distribution.
-
-We can't move a task from one distribution to another.
-
-    >>> print webservice.named_post(
-    ...     task['self_link'], 'transitionToTarget',
-    ...     target=webservice.getAbsoluteUrl('/ubuntu/+source/alsa-utils'))
-    HTTP/1.1 400 Bad Request
-    ...
-    Distribution bug tasks may only be re-targeted to
-    a package in the same distribution.
-
 We can change an upstream task to target a different project.
 
     >>> product_bugtask = webservice.get(
@@ -508,16 +487,6 @@
     HTTP/1.1 200 Ok
     ...
 
-But we can't make it into a distro task.
-
-    >>> print webservice.named_post(
-    ...     product_bugtask['self_link'].replace('jokosher', 'bzr'),
-    ...     'transitionToTarget',
-    ...     target=webservice.getAbsoluteUrl('/ubuntu/+source/alsa-utils'))
-    HTTP/1.1 400 Bad Request
-    ...
-    Upstream bug tasks may only be re-targeted to another project.
-
 If the milestone of a task is on a target other than the new
 target, we reset it in order to avoid data inconsistencies.
 

=== modified file 'lib/lp/bugs/tests/test_bugnotification.py'
--- lib/lp/bugs/tests/test_bugnotification.py	2011-07-22 00:17:11 +0000
+++ lib/lp/bugs/tests/test_bugnotification.py	2011-07-22 00:17:15 +0000
@@ -13,7 +13,6 @@
 from lazr.lifecycle.snapshot import Snapshot
 from storm.store import Store
 from testtools.matchers import Not
-from zope.component import getUtility
 from zope.event import notify
 from zope.interface import providedBy