← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~rvb/launchpad/multi-parent-diff-pages2 into lp:launchpad/db-devel

 

Raphaël Victor Badin has proposed merging lp:~rvb/launchpad/multi-parent-diff-pages2 into lp:launchpad/db-devel.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #778371 in Launchpad itself: "The differences pages (i.e. pages showing DSDs) should be updated to account for the case where a series has multiple parents."
  https://bugs.launchpad.net/launchpad/+bug/778371

For more details, see:
https://code.launchpad.net/~rvb/launchpad/multi-parent-diff-pages2/+merge/60799

This branch fixes the differences pages (+localpackagediff, etc.) to account for the case where a series has multiple parents.

This involves:
- fixing DSD pages to:
  - modify the button, explanatory paragraph.
  - add a column to the table to display the DSD's parent name.
- fixing the uri for a DSD (from https://.../distro/series/+difference/sourcename to https://.../distro/series/+source/sourcename/+difference/parentdistro/parentseries)

= Tests =
./bin/test -cvv test_distroseries test_differences_portlet_all_differences_multiple_parents
./bin/test -cvv test_distroseries test_queries_single_parent
./bin/test -cvv test_distroseries test_queries_multiple_parents
./bin/test -cvv test_distroseries test_label_multiple_parents
./bin/test -cvv test_distroseries test_multiple_parents_display
./bin/test -cvv test_distroseries test_higher_radio_mentions_parents

= Q/A =
On DF: create a series with multiple parents:
- check the series homepage displays the right number of parents: https://dogfood.launchpad.net/distro/series/
- check the differences pages:
https://dogfood.launchpad.net/distro/series/+localpackagediffs
https://dogfood.launchpad.net/distro/series/+uniquepackages
https://dogfood.launchpad.net/distro/series/+missingpackages
Check:
 - sync button name
 - page title
 - page explanation paragraph
 - package filtering options
Check having 2 packages with the same source package name from 2 difference parents.
- check opening up a row on
https://dogfood.launchpad.net/distro/series/+localpackagediffs
make sure the url for the html ragment is:
https://dogfood.launchpad.net/distro/series/+source/sourcename/+difference/parentdistro/parentseries
-- 
https://code.launchpad.net/~rvb/launchpad/multi-parent-diff-pages2/+merge/60799
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~rvb/launchpad/multi-parent-diff-pages2 into lp:launchpad/db-devel.
=== modified file 'lib/canonical/launchpad/interfaces/_schema_circular_imports.py'
--- lib/canonical/launchpad/interfaces/_schema_circular_imports.py	2011-04-27 15:12:08 +0000
+++ lib/canonical/launchpad/interfaces/_schema_circular_imports.py	2011-05-12 14:59:29 +0000
@@ -490,6 +490,8 @@
     IDistroSeries, 'deriveDistroSeries', 'distribution', IDistribution)
 patch_collection_return_type(
     IDistroSeries, 'getDerivedSeries', IDistroSeries)
+patch_collection_return_type(
+    IDistroSeries, 'getParentSeries', IDistroSeries)
 patch_plain_parameter_type(
     IDistroSeries, 'getDifferencesTo', 'parent_series', IDistroSeries)
 patch_choice_parameter_type(
@@ -856,8 +858,8 @@
 patch_entry_explicit_version(IDistroSeries, 'beta')
 patch_operations_explicit_version(
     IDistroSeries, 'beta', "deriveDistroSeries", "getDerivedSeries",
-    "getDistroArchSeries", "getPackageUploads", "getSourcePackage",
-    "newMilestone")
+    "getParentSeries", "getDistroArchSeries", "getPackageUploads",
+    "getSourcePackage", "newMilestone")
 
 # IDistroSeriesDifference
 patch_entry_explicit_version(IDistroSeriesDifference, 'beta')

=== modified file 'lib/lp/registry/browser/configure.zcml'
--- lib/lp/registry/browser/configure.zcml	2011-04-17 18:00:45 +0000
+++ lib/lp/registry/browser/configure.zcml	2011-05-12 14:59:29 +0000
@@ -177,7 +177,7 @@
         permission="zope.Public"/>
     <browser:url
         for="lp.registry.interfaces.distroseriesdifference.IDistroSeriesDifference"
-        path_expression="string:+difference/${source_package_name/name}"
+        path_expression="string:+source/${source_package_name/name}/+difference/${parent_series/parent/name}/${parent_series/name}"
         rootsite="mainsite"
         attribute_to_parent="derived_series"/>
     <browser:page

=== modified file 'lib/lp/registry/browser/distroseries.py'
--- lib/lp/registry/browser/distroseries.py	2011-05-12 00:09:30 +0000
+++ lib/lp/registry/browser/distroseries.py	2011-05-12 14:59:29 +0000
@@ -95,7 +95,6 @@
 from lp.registry.interfaces.distroseriesdifference import (
     IDistroSeriesDifferenceSource,
     )
-from lp.registry.interfaces.distroseriesparent import IDistroSeriesParentSet
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.registry.interfaces.series import SeriesStatus
 from lp.services.features import getFeatureFlag
@@ -181,11 +180,6 @@
     def traverse_queue(self, id):
         return getUtility(IPackageUploadSet).get(id)
 
-    @stepthrough('+difference')
-    def traverse_difference(self, name):
-        dsd_source = getUtility(IDistroSeriesDifferenceSource)
-        return dsd_source.getByDistroSeriesAndName(self.context, name)
-
 
 class DistroSeriesBreadcrumb(Breadcrumb):
     """Builds a breadcrumb for an `IDistroSeries`."""
@@ -359,7 +353,35 @@
             self.context.datereleased = UTC_NOW
 
 
-class DistroSeriesView(LaunchpadView, MilestoneOverlayMixin):
+class DerivedDistroSeriesMixin():
+
+    @cachedproperty
+    def has_unique_parent(self):
+        return len(self.context.getParentSeries()) == 1
+
+    @cachedproperty
+    def unique_parent(self):
+        if self.has_unique_parent:
+            return self.context.getParentSeries()[0]
+        else:
+            None
+
+    @cachedproperty
+    def number_of_parents(self):
+        return len(self.context.getParentSeries())
+
+    @cachedproperty
+    def parent_name(self):
+        if self.has_unique_parent:
+            parent_name = ("parent series '%s'" %
+                self.unique_parent.displayname)
+        else:
+            parent_name = 'a parent series'
+        return parent_name
+
+
+class DistroSeriesView(LaunchpadView, MilestoneOverlayMixin,
+                       DerivedDistroSeriesMixin):
 
     def initialize(self):
         super(DistroSeriesView, self).initialize()
@@ -444,15 +466,6 @@
         return self._num_differences(
             DistroSeriesDifferenceType.UNIQUE_TO_DERIVED_SERIES)
 
-    @cachedproperty
-    def parent_series(self):
-        # XXX StevenK: This will currently throw NotOneError up the
-        # callstack if the child has more than one parent. This is okay
-        # *for now*, so we can get away with no UI changes.
-        dsp = getUtility(IDistroSeriesParentSet).getByDerivedSeries(
-            self.context).one()
-        return dsp.parent_series
-
 
 class DistroSeriesEditView(LaunchpadEditFormView, SeriesStatusMixin):
     """View class that lets you edit a DistroSeries object.
@@ -682,7 +695,7 @@
         higher_term = SimpleTerm(
             HIGHER_VERSION_THAN_PARENT,
             HIGHER_VERSION_THAN_PARENT,
-            "Blacklisted packages with a higher version than in '%s'"
+            "Blacklisted packages with a higher version than in %s"
                 % parent_name)
         voc.insert(2, higher_term)
     return SimpleVocabulary(tuple(voc))
@@ -715,7 +728,8 @@
 
 
 class DistroSeriesDifferenceBaseView(LaunchpadFormView,
-                                     PackageCopyingMixin):
+                                     PackageCopyingMixin,
+                                     DerivedDistroSeriesMixin):
     """Base class for all pages presenting differences between
     a derived series and its parent."""
     schema = IDifferencesFormSchema
@@ -750,10 +764,14 @@
         return NotImplementedError()
 
     def setupPackageFilterRadio(self):
+        if self.has_unique_parent:
+            parent_name = "'%s'" % self.unique_parent.displayname
+        else:
+            parent_name = 'parent'
         return form.Fields(Choice(
             __name__='package_type',
             vocabulary=make_package_type_vocabulary(
-                self.parent_series.displayname,
+                parent_name,
                 self.search_higher_parent_option),
             default=DEFAULT_PACKAGE_TYPE,
             required=True))
@@ -770,9 +788,8 @@
             self.form_fields)
         check_permission('launchpad.Edit', self.context)
         terms = [
-            SimpleTerm(diff, diff.source_package_name.name,
-                diff.source_package_name.name)
-                for diff in self.cached_differences.batch]
+            SimpleTerm(diff, diff.id)
+                    for diff in self.cached_differences.batch]
         diffs_vocabulary = SimpleVocabulary(terms)
         choice = self.form_fields['selected_differences'].field.value_type
         choice.vocabulary = diffs_vocabulary
@@ -905,15 +922,6 @@
                         DistroSeriesDifferenceStatus.BLACKLISTED_CURRENT))
             return not differences.is_empty()
 
-    @cachedproperty
-    def parent_series(self):
-        # XXX StevenK: This will currently throw NotOneError up the
-        # callstack if the child has more than one parent. This is okay
-        # *for now*, so we can get away with no UI changes.
-        dsp = getUtility(IDistroSeriesParentSet).getByDerivedSeries(
-            self.context).one()
-        return dsp.parent_series
-
 
 class DistroSeriesLocalDifferencesView(DistroSeriesDifferenceBaseView,
                                        LaunchpadFormView):
@@ -927,9 +935,13 @@
 
     def initialize(self):
         # Update the label for sync action.
+        if self.has_unique_parent:
+            parent_name = "'%s'" % self.unique_parent.displayname
+        else:
+            parent_name = 'Parent'
         self.initialize_sync_label(
             "Sync Selected %s Versions into %s" % (
-                self.parent_series.displayname,
+                parent_name,
                 self.context.displayname,
                 ))
         super(DistroSeriesLocalDifferencesView, self).initialize()
@@ -938,23 +950,26 @@
     def explanation(self):
         return structured(
             "Source packages shown here are present in both %s "
-            "and the parent series, %s, but are different somehow. "
+            "and %s, but are different somehow. "
             "Changes could be in either or both series so check the "
-            "versions (and the diff if necessary) before syncing the %s "
+            "versions (and the diff if necessary) before syncing the parent "
             'version (<a href="/+help/soyuz/derived-series-syncing.html" '
-            'target="help">Read more about syncing from the parent series'
+            'target="help">Read more about syncing from a parent series'
             '</a>).',
             self.context.displayname,
-            self.parent_series.fullseriesname,
-            self.parent_series.displayname)
+            self.parent_name)
 
     @property
     def label(self):
+        if self.has_unique_parent:
+            parent_name = self.parent_name
+        else:
+            parent_name = 'parent series'
         return (
-            "Source package differences between '%s' and "
-            "parent series '%s'" % (
+            "Source package differences between '%s' and"
+            " %s" % (
                 self.context.displayname,
-                self.parent_series.displayname,
+                parent_name,
                 ))
 
     @action(_("Update"), name="update")
@@ -1034,7 +1049,7 @@
     def initialize(self):
         # Update the label for sync action.
         self.initialize_sync_label(
-            "Include Selected packages into into %s" % (
+            "Include Selected packages into %s" % (
                 self.context.displayname,
                 ))
         super(DistroSeriesMissingPackagesView, self).initialize()
@@ -1043,17 +1058,17 @@
     def explanation(self):
         return structured(
             "Packages that are listed here are those that have been added to "
-            "the specific packages %s that were used to create %s. They are "
-            "listed here so you can consider including them in %s.",
-            self.parent_series.displayname,
+            "the specific set of packages in %s that were used to create %s. "
+            "They are listed here so you can consider including them in %s.",
+            self.parent_name,
             self.context.displayname,
             self.context.displayname)
 
     @property
     def label(self):
         return (
-            "Packages in parent series '%s' but not in '%s'" % (
-                self.parent_series.displayname,
+            "Packages in %s but not in '%s'" % (
+                self.parent_name,
                 self.context.displayname,
                 ))
 
@@ -1086,16 +1101,16 @@
     def explanation(self):
         return structured(
             "Packages that are listed here are those that have been added to "
-            "%s but are not yet part of the parent series %s.",
+            "%s but are not yet part of %s.",
             self.context.displayname,
-            self.parent_series.displayname)
+            self.parent_name)
 
     @property
     def label(self):
         return (
-            "Packages in '%s' but not in parent series '%s'" % (
+            "Packages in '%s' but not in %s" % (
                 self.context.displayname,
-                self.parent_series.displayname,
+                self.parent_name,
                 ))
 
     @action(_("Update"), name="update")

=== modified file 'lib/lp/registry/browser/tests/test_distroseries.py'
--- lib/lp/registry/browser/tests/test_distroseries.py	2011-05-12 00:09:30 +0000
+++ lib/lp/registry/browser/tests/test_distroseries.py	2011-05-12 14:59:29 +0000
@@ -142,29 +142,42 @@
 
     layer = DatabaseFunctionalLayer
 
-    def _setupDifferences(self, name, parent_name, nb_diff_versions,
+    def _setupDifferences(self, name, parent_names, nb_diff_versions,
                           nb_diff_child, nb_diff_parent):
-        # Helper to create DSD of the different types.
+        # Helper to create DSDs of the different types.
         derived_series = self.factory.makeDistroSeries(name=name)
-        parent_series = self.factory.makeDistroSeries(name=parent_name)
-        self.factory.makeDistroSeriesParent(
-            derived_series=derived_series, parent_series=parent_series)
         self.simple_user = self.factory.makePerson()
+        # parent_names can be a list of parent names or a single name
+        # for a single parent (e.g. ['parent1_name', 'parent2_name'] or
+        # 'parent_name').
+        # If multiple parents are created, the DSDs will be created with
+        # the first one.
+        if type(parent_names) == str:
+            parent_names = [parent_names]
+        dsps = []
+        for parent_name in parent_names:
+            parent_series = self.factory.makeDistroSeries(name=parent_name)
+            dsps.append(self.factory.makeDistroSeriesParent(
+                derived_series=derived_series, parent_series=parent_series))
+        first_parent_series = dsps[0].parent_series
         for i in range(nb_diff_versions):
             diff_type = DistroSeriesDifferenceType.DIFFERENT_VERSIONS
             self.factory.makeDistroSeriesDifference(
                 derived_series=derived_series,
-                difference_type=diff_type)
+                difference_type=diff_type,
+                parent_series=first_parent_series)
         for i in range(nb_diff_child):
             diff_type = DistroSeriesDifferenceType.MISSING_FROM_DERIVED_SERIES
             self.factory.makeDistroSeriesDifference(
                 derived_series=derived_series,
-                difference_type=diff_type)
+                difference_type=diff_type,
+                parent_series=first_parent_series)
         for i in range(nb_diff_parent):
             diff_type = DistroSeriesDifferenceType.UNIQUE_TO_DERIVED_SERIES
             self.factory.makeDistroSeriesDifference(
                 derived_series=derived_series,
-                difference_type=diff_type)
+                difference_type=diff_type,
+                parent_series=first_parent_series)
         return derived_series
 
     def test_differences_no_flag_no_portlet(self):
@@ -221,6 +234,33 @@
 
         self.assertThat(html_content, portlet_display)
 
+    def test_differences_portlet_all_differences_multiple_parents(self):
+        # The difference portlet shows the differences with the multiple
+        # parent series.
+        set_derived_series_ui_feature_flag(self)
+        derived_series = self._setupDifferences(
+            'deri', ['sid1', 'sid2'], 0, 1, 0)
+        portlet_display = soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                'Derivation portlet header', 'h2',
+                text='Derived from 2 parents'),
+            soupmatchers.Tag(
+                'Parent diffs link', 'a',
+                text=re.compile('\s*1 package only in a parent series\s*'),
+                attrs={'href': re.compile('.*/\+missingpackages')}))
+
+        with person_logged_in(self.simple_user):
+            view = create_initialized_view(
+                derived_series,
+                '+index',
+                principal=self.simple_user)
+            # XXX rvb 2011-04-12 bug=758649: LaunchpadTestRequest overrides
+            # self.features to NullFeatureController.
+            view.request.features = get_relevant_feature_controller()
+            html_text = view()
+
+        self.assertThat(html_text, portlet_display)
+
     def test_differences_portlet_no_differences(self):
         # The difference portlet displays 'No differences' if there is no
         # differences with the parent.
@@ -419,13 +459,19 @@
 
         self.assertThat(html_content, parent_packagesets)
 
-    def _create_child_and_parent(self):
+    def _createChildAndParent(self):
         derived_series = self.factory.makeDistroSeries(name='derilucid')
         parent_series = self.factory.makeDistroSeries(name='lucid')
         self.factory.makeDistroSeriesParent(
             derived_series=derived_series, parent_series=parent_series)
         return (derived_series, parent_series)
 
+    def _createChildAndParents(self, other_parent_series=None):
+        derived_series, parent_series = self._createChildAndParent()
+        self.factory.makeDistroSeriesParent(
+            derived_series=derived_series, parent_series=other_parent_series)
+        return (derived_series, parent_series)
+
 
 class TestDistroSeriesLocalDifferences(
     DistroSeriesDifferenceMixin, TestCaseWithFactory):
@@ -443,7 +489,7 @@
         # Test that the page includes the filter form if differences
         # are present
         login_person(self.simple_user)
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
         self.factory.makeDistroSeriesDifference(
             derived_series=derived_series)
 
@@ -459,7 +505,7 @@
         # Test that the page doesn't includes the filter form if no
         # differences are present
         login_person(self.simple_user)
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
 
         view = create_initialized_view(
             derived_series, '+localpackagediffs', principal=self.simple_user)
@@ -513,13 +559,24 @@
             html_content, packageset_text, 'parent-packagesets',
             'Parent packagesets')
 
-    def test_queries(self):
+
+class TestDistroSeriesLocalDifferencesPerformance(DistroSeriesDifferenceMixin,
+                                                  TestCaseWithFactory):
+    """Test the distroseries +localpackagediffs page's performance."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestDistroSeriesLocalDifferencesPerformance,
+             self).setUp('foo.bar@xxxxxxxxxxxxx')
+        set_derived_series_ui_feature_flag(self)
+        self.simple_user = self.factory.makePerson()
+
+    def _assertQueryCount(self, derived_series):
         # With no DistroSeriesDifferences the query count should be low and
         # fairly static. However, with some DistroSeriesDifferences the query
         # count will be higher, but it should remain the same no matter how
         # many differences there are.
-        dsp = self.factory.makeDistroSeriesParent()
-        derived_series = dsp.derived_series
         ArchivePermission(
             archive=derived_series.main_archive, person=self.simple_user,
             component=getUtility(IComponentSet)["main"],
@@ -627,8 +684,28 @@
             recorder3, HasQueryCount(
                 LessThan(compromise_statement_count + 1)))
 
-
-class TestDistroSeriesLocalDifferencesZopeless(TestCaseWithFactory):
+    def test_queries_single_parent(self):
+        dsp = self.factory.makeDistroSeriesParent()
+        derived_series = dsp.derived_series
+        self._assertQueryCount(derived_series)
+
+    def test_queries_multiple_parents(self):
+        dsp = self.factory.makeDistroSeriesParent()
+        derived_series = dsp.derived_series
+        self.factory.makeDistroSeriesParent(
+            derived_series=derived_series)
+        self._assertQueryCount(derived_series)
+
+    def test_queries_multiple_parents(self):
+        dsp = self.factory.makeDistroSeriesParent()
+        derived_series = dsp.derived_series
+        self.factory.makeDistroSeriesParent(
+            derived_series=derived_series)
+        self._assertQueryCount(derived_series)
+
+
+class TestDistroSeriesLocalDifferencesZopeless(DistroSeriesDifferenceMixin,
+                                               TestCaseWithFactory):
     """Test the distroseries +localpackagediffs view."""
 
     layer = LaunchpadFunctionalLayer
@@ -658,7 +735,7 @@
             principal=get_current_principal(),
             current_request=True)
 
-    def _create_child_and_parent(self):
+    def _createChildAndParent(self):
         parent_series = self.factory.makeDistroSeries(name='lucid')
         derived_series = self.factory.makeDistroSeries(name='derilucid')
         self.factory.makeDistroSeriesParent(
@@ -668,7 +745,7 @@
     def test_view_redirects_without_feature_flag(self):
         # If the feature flag soyuz.derived-series-ui.enabled is not set the
         # view simply redirects to the derived series.
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
 
         self.assertIs(
             None, getFeatureFlag('soyuz.derived-series-ui.enabled'))
@@ -681,7 +758,7 @@
 
     def test_label(self):
         # The view label includes the names of both series.
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
 
         view = self.makeView(derived_series)
 
@@ -690,10 +767,36 @@
             "parent series 'Lucid'",
             view.label)
 
+    def test_label_multiple_parents(self):
+        # If the series has multiple parents, the view label mentions
+        # the generic term 'parent series'.
+        derived_series, parent_series = self._createChildAndParents()
+
+        view = create_initialized_view(
+            derived_series, '+localpackagediffs')
+
+        self.assertEqual(
+            "Source package differences between 'Derilucid' and "
+            "parent series",
+            view.label)
+
+    def test_label_multiple_parents(self):
+        # If the series has multiple parents, the view label mentions
+        # the generic term 'parent series'.
+        derived_series, parent_series = self._createChildAndParents()
+
+        view = create_initialized_view(
+            derived_series, '+localpackagediffs')
+
+        self.assertEqual(
+            "Source package differences between 'Derilucid' and "
+            "parent series",
+            view.label)
+
     def test_batch_includes_needing_attention_only(self):
         # The differences attribute includes differences needing
         # attention only.
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
         current_difference = self.factory.makeDistroSeriesDifference(
             derived_series=derived_series)
         self.factory.makeDistroSeriesDifference(
@@ -707,7 +810,7 @@
 
     def test_batch_includes_different_versions_only(self):
         # The view contains differences of type DIFFERENT_VERSIONS only.
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
         different_versions_diff = self.factory.makeDistroSeriesDifference(
             derived_series=derived_series)
         self.factory.makeDistroSeriesDifference(
@@ -722,7 +825,7 @@
 
     def test_template_includes_help_link(self):
         # The help link for popup help is included.
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
         set_derived_series_ui_feature_flag(self)
         view = self.makeView(derived_series)
 
@@ -733,7 +836,7 @@
 
     def test_diff_row_includes_last_comment_only(self):
         # The most recent comment is rendered for each difference.
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
         difference = self.factory.makeDistroSeriesDifference(
             derived_series=derived_series)
         with person_logged_in(derived_series.owner):
@@ -755,7 +858,7 @@
 
     def test_diff_row_links_to_extra_details(self):
         # The source package name links to the difference details.
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
         difference = self.factory.makeDistroSeriesDifference(
             derived_series=derived_series)
 
@@ -769,11 +872,49 @@
         self.assertEqual(1, len(links))
         self.assertEqual(difference.source_package_name.name, links[0].string)
 
+    def test_multiple_parents_display(self):
+        package_name = 'package-1'
+        other_parent_series = self.factory.makeDistroSeries(name='other')
+        derived_series, parent_series = self._createChildAndParents(
+            other_parent_series=other_parent_series)
+        versions = {
+            'base': u'1.0',
+            'derived': u'1.0derived1',
+            'parent': u'1.0-1',
+        }
+
+        self.factory.makeDistroSeriesDifference(
+            versions=versions,
+            parent_series=other_parent_series,
+            source_package_name_str=package_name,
+            derived_series=derived_series)
+        self.factory.makeDistroSeriesDifference(
+            versions=versions,
+            parent_series=parent_series,
+            source_package_name_str=package_name,
+            derived_series=derived_series)
+        set_derived_series_ui_feature_flag(self)
+        view = create_initialized_view(
+            derived_series, '+localpackagediffs')
+        multiple_parents_matches = soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                "Parent table header", 'th',
+                text=re.compile("\s*Parent\s")),
+            soupmatchers.Tag(
+                "Parent version table header", 'th',
+                text=re.compile("\s*Parent version\s*")),
+            soupmatchers.Tag(
+                "Parent name", 'a',
+                attrs={'class': 'parent-name'},
+                text=re.compile("\s*Other\s*")),
+             )
+        self.assertThat(view.render(), multiple_parents_matches)
+
     def test_diff_row_shows_version_attached(self):
         # The +localpackagediffs page shows the version attached to the
         # DSD and not the last published version (bug=745776).
         package_name = 'package-1'
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
         versions = {
             'base': u'1.0',
             'derived': u'1.0derived1',
@@ -817,7 +958,7 @@
         # The +localpackagediffs page shows only the version (no link)
         # if we fail to fetch the published version.
         package_name = 'package-1'
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
         versions = {
             'base': u'1.0',
             'derived': u'1.0derived1',
@@ -965,7 +1106,7 @@
         # A single web request may need to schedule large numbers of
         # package upgrades.  It must do so without issuing large numbers
         # of database queries.
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
         # Take a baseline measure of queries.
         self.makePackageUpgrade(derived_series=derived_series)
         flush_database_caches()
@@ -981,11 +1122,12 @@
         self.assertThat(recorder2, HasQueryCount(Equals(recorder1.count)))
 
 
-class TestDistroSeriesLocalDifferencesFunctional(TestCaseWithFactory):
+class TestDistroSeriesLocalDifferencesFunctional(DistroSeriesDifferenceMixin,
+                                                 TestCaseWithFactory):
 
     layer = LaunchpadFunctionalLayer
 
-    def _create_child_and_parent(self):
+    def _createChildAndParent(self):
         parent_series = self.factory.makeDistroSeries(name='lucid')
         derived_series = self.factory.makeDistroSeries(name='derilucid')
         self.factory.makeDistroSeriesParent(
@@ -993,8 +1135,10 @@
         return (derived_series, parent_series)
 
     def test_higher_radio_mentions_parent(self):
+        # The user is shown an option to display only the blacklisted
+        # package with a higer version than in the parent.
         set_derived_series_ui_feature_flag(self)
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
         self.factory.makeDistroSeriesDifference(
             derived_series=derived_series,
             source_package_name_str="my-src-package")
@@ -1011,6 +1155,25 @@
             )
         self.assertThat(view.render(), radio_option_matches)
 
+    def test_higher_radio_mentions_parents(self):
+        set_derived_series_ui_feature_flag(self)
+        derived_series, parent_series = self._createChildAndParents()
+        self.factory.makeDistroSeriesDifference(
+            derived_series=derived_series,
+            source_package_name_str="my-src-package")
+        view = create_initialized_view(
+            derived_series,
+            '+localpackagediffs')
+
+        radio_title = \
+            "&nbsp;Blacklisted packages with a higher version than in parent"
+        radio_option_matches = soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                "radio displays parent's name", 'label',
+                text=radio_title),
+            )
+        self.assertThat(view.render(), radio_option_matches)
+
     def _set_source_selection(self, series):
         # Set up source package format selection so that copying will
         # work with the default dsc_format used in
@@ -1021,7 +1184,7 @@
     def test_batch_filtered(self):
         # The name_filter parameter allows filtering of packages by name.
         set_derived_series_ui_feature_flag(self)
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
         diff1 = self.factory.makeDistroSeriesDifference(
             derived_series=derived_series,
             source_package_name_str="my-src-package")
@@ -1045,7 +1208,7 @@
     def test_batch_non_blacklisted(self):
         # The default filter is all non blacklisted differences.
         set_derived_series_ui_feature_flag(self)
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
         diff1 = self.factory.makeDistroSeriesDifference(
             derived_series=derived_series,
             source_package_name_str="my-src-package")
@@ -1073,7 +1236,7 @@
         # field.package_type parameter allows to list only
         # blacklisted differences.
         set_derived_series_ui_feature_flag(self)
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
         blacklisted_diff = self.factory.makeDistroSeriesDifference(
             derived_series=derived_series,
             status=DistroSeriesDifferenceStatus.BLACKLISTED_CURRENT)
@@ -1095,7 +1258,7 @@
         # field.package_type parameter allows to list only
         # blacklisted differences with a child's version higher than parent's.
         set_derived_series_ui_feature_flag(self)
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
         blacklisted_diff_higher = self.factory.makeDistroSeriesDifference(
             derived_series=derived_series,
             status=DistroSeriesDifferenceStatus.BLACKLISTED_CURRENT,
@@ -1123,7 +1286,7 @@
         # Test that we can search for differences that we marked
         # resolved.
         set_derived_series_ui_feature_flag(self)
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
 
         self.factory.makeDistroSeriesDifference(
             derived_series=derived_series,
@@ -1156,14 +1319,14 @@
         self.factory.makeDistroSeriesParent(
             derived_series=derived_series, parent_series=parent_series)
         self._set_source_selection(derived_series)
-        self.factory.makeDistroSeriesDifference(
+        diff = self.factory.makeDistroSeriesDifference(
             source_package_name_str=src_name,
             derived_series=derived_series, versions=versions,
             difference_type=difference_type)
         sourcepackagename = self.factory.getOrMakeSourcePackageName(
             src_name)
         set_derived_series_ui_feature_flag(self)
-        return derived_series, parent_series, sourcepackagename
+        return derived_series, parent_series, sourcepackagename, str(diff.id)
 
     def test_canPerformSync_anon(self):
         # Anonymous users cannot sync packages.
@@ -1246,11 +1409,13 @@
 
     def test_sync_error_invalid_selection(self):
         # An error is raised when an invalid difference is selected.
-        derived_series = self._setUpDSD('my-src-name')[0]
+        derived_series, unused, unused2, diff_id = self._setUpDSD(
+            'my-src-name')
         person = self._setUpPersonWithPerm(derived_series)
+        another_id = str(int(diff_id) + 1)
         set_derived_series_sync_feature_flag(self)
         view = self._syncAndGetView(
-            derived_series, person, ['some-other-name'])
+            derived_series, person, [another_id])
 
         self.assertEqual(2, len(view.errors))
         self.assertEqual(
@@ -1261,11 +1426,12 @@
     def test_sync_error_no_perm_dest_archive(self):
         # A user without upload rights on the destination archive cannot
         # sync packages.
-        derived_series = self._setUpDSD('my-src-name')[0]
+        derived_series, unused, unused2, diff_id = self._setUpDSD(
+            'my-src-name')
         person = self._setUpPersonWithPerm(derived_series)
         set_derived_series_sync_feature_flag(self)
         view = self._syncAndGetView(
-            derived_series, person, ['my-src-name'])
+            derived_series, person, [diff_id])
 
         self.assertEqual(1, len(view.errors))
         self.assertTrue(
@@ -1283,27 +1449,27 @@
     def test_sync_success_perm_component(self):
         # A user with upload rights on the destination component
         # can sync packages.
-        derived_series, parent_series, sourcepackagename = self._setUpDSD(
+        derived_series, parent_series, sp_name, diff_id = self._setUpDSD(
             'my-src-name')
         person, _ = self.makePersonWithComponentPermission(
             derived_series.main_archive,
             derived_series.getSourcePackage(
-                sourcepackagename).latest_published_component)
+                sp_name).latest_published_component)
         view = self._syncAndGetView(
-            derived_series, person, ['my-src-name'])
+            derived_series, person, [diff_id])
 
         self.assertEqual(0, len(view.errors))
 
     def test_sync_error_no_perm_component(self):
         # A user without upload rights on the destination component
         # will get an error when he syncs packages to this component.
-        derived_series, parent_series, sourcepackagename = self._setUpDSD(
+        derived_series, parent_series, unused, diff_id = self._setUpDSD(
             'my-src-name')
         person, another_component = self.makePersonWithComponentPermission(
             derived_series.main_archive)
         set_derived_series_sync_feature_flag(self)
         view = self._syncAndGetView(
-            derived_series, person, ['my-src-name'])
+            derived_series, person, [diff_id])
 
         self.assertEqual(1, len(view.errors))
         self.assertTrue(
@@ -1343,13 +1509,13 @@
             'derived': '1.0derived1',
             'parent': '1.0-1',
         }
-        derived_series, parent_series, sourcepackagename = self._setUpDSD(
+        derived_series, parent_series, sp_name, diff_id = self._setUpDSD(
             'my-src-name', versions=versions)
 
         # Setup a user with upload rights.
         person = self.factory.makePerson()
         removeSecurityProxy(derived_series.main_archive).newPackageUploader(
-            person, sourcepackagename)
+            person, sp_name)
 
         # The inital state is that 1.0-1 is not in the derived series.
         pubs = derived_series.main_archive.getPublishedSources(
@@ -1360,7 +1526,7 @@
         # Now, sync the source from the parent using the form.
         set_derived_series_sync_feature_flag(self)
         view = self._syncAndGetView(
-            derived_series, person, ['my-src-name'])
+            derived_series, person, [diff_id])
 
         # The parent's version should now be in the derived series and
         # the notifications displayed:
@@ -1375,13 +1541,13 @@
             'parent': '1.0-1',
         }
         missing = DistroSeriesDifferenceType.MISSING_FROM_DERIVED_SERIES
-        derived_series, parent_series, sourcepackagename = self._setUpDSD(
+        derived_series, parent_series, unused, diff_id = self._setUpDSD(
             'my-src-name', difference_type=missing, versions=versions)
         person, another_component = self.makePersonWithComponentPermission(
             derived_series.main_archive)
         set_derived_series_sync_feature_flag(self)
         view = self._syncAndGetView(
-            derived_series, person, ['my-src-name'],
+            derived_series, person, [diff_id],
             view_name='+missingpackages')
 
         self.assertPackageCopied(
@@ -1393,7 +1559,7 @@
         versions = {
             'parent': '1.0-1',
             }
-        derived_series, parent_series, sourcepackagename = self._setUpDSD(
+        derived_series, parent_series, sp_name, diff_id = self._setUpDSD(
             'my-src-name', versions=versions)
         # Update destination series status to current and update
         # daterelease.
@@ -1404,9 +1570,9 @@
         set_derived_series_sync_feature_flag(self)
         person = self.factory.makePerson()
         removeSecurityProxy(derived_series.main_archive).newPackageUploader(
-            person, sourcepackagename)
+            person, sp_name)
         self._syncAndGetView(
-            derived_series, person, ['my-src-name'])
+            derived_series, person, [diff_id])
         parent_pub = parent_series.main_archive.getPublishedSources(
             name='my-src-name', version=versions['parent'],
             distroseries=parent_series).one()
@@ -1519,12 +1685,13 @@
             'Parent packagesets')
 
 
-class DistroSerieUniquePackageDiffsTestCase(TestCaseWithFactory):
+class DistroSerieUniquePackageDiffsTestCase(DistroSeriesDifferenceMixin,
+                                            TestCaseWithFactory):
     """Test the distroseries +uniquepackages view."""
 
     layer = LaunchpadZopelessLayer
 
-    def _create_child_and_parent(self):
+    def _createChildAndParent(self):
         derived_series = self.factory.makeDistroSeries(name='derilucid')
         parent_series = self.factory.makeDistroSeries(name='lucid')
         self.factory.makeDistroSeriesParent(
@@ -1534,7 +1701,7 @@
     def test_uniquepackages_differences(self):
         # The view fetches the differences with type
         # UNIQUE_TO_DERIVED_SERIES.
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
 
         missing_type = DistroSeriesDifferenceType.UNIQUE_TO_DERIVED_SERIES
         # Missing blacklisted diff.
@@ -1557,7 +1724,7 @@
     def test_uniquepackages_differences_empty(self):
         # The view is empty if there is no differences with type
         # UNIQUE_TO_DERIVED_SERIES.
-        derived_series, parent_series = self._create_child_and_parent()
+        derived_series, parent_series = self._createChildAndParent()
 
         not_missing_type = DistroSeriesDifferenceType.DIFFERENT_VERSIONS
 

=== modified file 'lib/lp/registry/browser/tests/test_distroseriesdifference_views.py'
--- lib/lp/registry/browser/tests/test_distroseriesdifference_views.py	2011-04-18 14:47:14 +0000
+++ lib/lp/registry/browser/tests/test_distroseriesdifference_views.py	2011-05-12 14:59:29 +0000
@@ -74,6 +74,7 @@
         # Avoid circular import.
         from lp.soyuz.tests.test_publishing import SoyuzTestPublisher
         distro_series = distro_series_difference.derived_series
+        parent_series = distro_series_difference.parent_series
         source_package_name_str = (
             distro_series_difference.source_package_name.name)
         stp = SoyuzTestPublisher()
@@ -90,8 +91,8 @@
         # updateDistroSeriesPackageCache reconnects the db, so the
         # objects need to be reloaded.
         dsd_source = getUtility(IDistroSeriesDifferenceSource)
-        ds_diff = dsd_source.getByDistroSeriesAndName(
-            distro_series, source_package_name_str)
+        ds_diff = dsd_source.getByDistroSeriesNameAndParentSeries(
+            distro_series, source_package_name_str, parent_series)
         return ds_diff
 
     def test_binary_summaries_for_source_pub(self):
@@ -187,7 +188,8 @@
 
         self.assertEqual('0.1-1', ds_diff.base_version)
         with person_logged_in(self.factory.makePerson()):
-            view = create_initialized_view(ds_diff, '+listing-distroseries-extra')
+            view = create_initialized_view(
+                ds_diff, '+listing-distroseries-extra')
             soup = BeautifulSoup(view())
         tags = soup.find('ul', 'package-diff-status').findAll('span')
         self.assertEqual(2, len(tags))
@@ -208,7 +210,8 @@
 
         self.assertEqual('0.30-1', ds_diff.base_version)
         with person_logged_in(self.factory.makePerson()):
-            view = create_initialized_view(ds_diff, '+listing-distroseries-extra')
+            view = create_initialized_view(
+                ds_diff, '+listing-distroseries-extra')
             soup = BeautifulSoup(view())
         tags = soup.find('ul', 'package-diff-status').findAll('span')
         self.assertEqual(1, len(tags))
@@ -229,7 +232,8 @@
 
         self.assertEqual('0.30-1', ds_diff.base_version)
         with person_logged_in(self.factory.makePerson()):
-            view = create_initialized_view(ds_diff, '+listing-distroseries-extra')
+            view = create_initialized_view(
+                ds_diff, '+listing-distroseries-extra')
             soup = BeautifulSoup(view())
         tags = soup.find('ul', 'package-diff-status').findAll('span')
         self.assertEqual(1, len(tags))

=== modified file 'lib/lp/registry/browser/tests/test_distroseriesdifference_webservice.py'
--- lib/lp/registry/browser/tests/test_distroseriesdifference_webservice.py	2011-04-20 13:07:24 +0000
+++ lib/lp/registry/browser/tests/test_distroseriesdifference_webservice.py	2011-05-12 14:59:29 +0000
@@ -46,8 +46,9 @@
         transaction.commit()
 
         utility = getUtility(IDistroSeriesDifferenceSource)
-        ds_diff = utility.getByDistroSeriesAndName(
-            ds_diff.derived_series, ds_diff.source_package_name.name)
+        ds_diff = utility.getByDistroSeriesNameAndParentSeries(
+            ds_diff.derived_series, ds_diff.source_package_name.name,
+            ds_diff.parent_series)
         self.assertEqual(
             DistroSeriesDifferenceStatus.BLACKLISTED_CURRENT,
             ds_diff.status)
@@ -63,8 +64,9 @@
         transaction.commit()
 
         utility = getUtility(IDistroSeriesDifferenceSource)
-        ds_diff = utility.getByDistroSeriesAndName(
-            ds_diff.derived_series, ds_diff.source_package_name.name)
+        ds_diff = utility.getByDistroSeriesNameAndParentSeries(
+            ds_diff.derived_series, ds_diff.source_package_name.name,
+            ds_diff.parent_series)
         self.assertEqual(
             DistroSeriesDifferenceStatus.NEEDS_ATTENTION,
             ds_diff.status)
@@ -106,8 +108,9 @@
 
         # Reload and check that the package diffs are there.
         utility = getUtility(IDistroSeriesDifferenceSource)
-        ds_diff = utility.getByDistroSeriesAndName(
-            ds_diff.derived_series, ds_diff.source_package_name.name)
+        ds_diff = utility.getByDistroSeriesNameAndParentSeries(
+            ds_diff.derived_series, ds_diff.source_package_name.name,
+            ds_diff.parent_series)
         self.assertIsNot(None, ds_diff.package_diff)
         self.assertIsNot(None, ds_diff.parent_package_diff)
 

=== modified file 'lib/lp/registry/interfaces/distroseries.py'
--- lib/lp/registry/interfaces/distroseries.py	2011-05-11 13:11:13 +0000
+++ lib/lp/registry/interfaces/distroseries.py	2011-05-12 14:59:29 +0000
@@ -173,6 +173,16 @@
                 "'%s': %s" % (version, error))
 
 
+class IDistroSeriesEditRestricted(Interface):
+    """IDistroSeries properties which require launchpad.Edit."""
+
+    @rename_parameters_as(dateexpected='date_targeted')
+    @export_factory_operation(
+        IMilestone, ['name', 'dateexpected', 'summary', 'code_name'])
+    def newMilestone(name, dateexpected=None, summary=None, code_name=None):
+        """Create a new milestone for this DistroSeries."""
+
+
 class IDistroSeriesPublic(
     ISeriesMixin, IHasAppointedDriver, IHasOwner, IBugTarget,
     ISpecificationGoal, IHasMilestones, IHasOfficialBugTags,
@@ -828,6 +838,11 @@
     def getDerivedSeries():
         """Get all `DistroSeries` derived from this one."""
 
+    @operation_returns_collection_of(Interface)
+    @export_read_operation()
+    def getParentSeries():
+        """Get all parent `DistroSeries`."""
+
     @operation_parameters(
         parent_series=Reference(
             schema=Interface, # IDistroSeries

=== modified file 'lib/lp/registry/interfaces/distroseriesdifference.py'
--- lib/lp/registry/interfaces/distroseriesdifference.py	2011-05-06 15:15:23 +0000
+++ lib/lp/registry/interfaces/distroseriesdifference.py	2011-05-12 14:59:29 +0000
@@ -308,14 +308,20 @@
         :return: A result set of `IDistroSeriesDifference`.
         """
 
-    def getByDistroSeriesAndName(distro_series, source_package_name):
-        """Returns a single difference matching the series and name.
+    def getByDistroSeriesNameAndParentSeries(distro_series,
+                                             source_package_name,
+                                             parent_series):
+        """Returns a single difference matching the series, name and parent
+        series.
 
         :param distro_series: The derived distribution series which is to be
             searched for differences.
         :type distro_series: `IDistroSeries`.
         :param source_package_name: The name of the package difference.
         :type source_package_name: unicode.
+        :param parent_series: The parent distribution series of the package
+        difference.
+        :type distro_series: `IDistroSeries`.
         """
 
     def getSimpleUpgrades(distro_series):

=== modified file 'lib/lp/registry/javascript/distroseriesdifferences_details.js'
--- lib/lp/registry/javascript/distroseriesdifferences_details.js	2011-04-08 15:11:19 +0000
+++ lib/lp/registry/javascript/distroseriesdifferences_details.js	2011-05-12 14:59:29 +0000
@@ -215,7 +215,9 @@
      *                  class 'diff-extra-container' into which the results
      *                  are inserted.
      */
-    var get_extra_diff_info = function(uri, container, source_name) {
+    var get_extra_diff_info = function(uri, container, source_name,
+                                       parent_distro_name,
+                                       parent_series_name) {
         var in_progress_message = Y.lp.soyuz.base.makeInProgressNode(
             'Fetching difference details ...');
         container.one('div.diff-extra-container').insert(
@@ -225,8 +227,11 @@
                 response.responseText, 'replace');
             var api_uri = [
                 LP.cache.context.self_link,
+                '+source',
+                source_name,
                 '+difference',
-                source_name
+                parent_distro_name,
+                parent_series_name
                 ].join('/');
             blacklist_slot = args.container.one('div.blacklist-options');
             // The blacklist slot can be null if the user's not allowed
@@ -244,7 +249,8 @@
             var retry_handler = function(e) {
                 e.preventDefault();
                 get_extra_diff_info(
-                    args.uri, args.container, args.source_name);
+                    args.uri, args.container, args.source_name,
+                    args.parent_distro_name, args.parent_series_name);
             };
             var failure_message = Y.lp.soyuz.base.makeFailureNode(
                 'Failed to fetch difference details.', retry_handler);
@@ -283,6 +289,10 @@
         var details_row;
         if (next_row == null || !next_row.hasClass('diff-extra')) {
             var source_name = row.one('a.toggle-extra').get('text');
+            var rev_link = row
+                .one('a.toggle-extra').get('href').split('/').reverse();
+            var parent_series_name = rev_link[0];
+            var parent_distro_name = rev_link[1];
             var nb_columns = row.all('td').size();
             details_row = Y.Node.create([
                 '<table><tr class="diff-extra unseen ' + source_name + '">',
@@ -292,7 +302,9 @@
                 ].join('')).one('tr');
             row.insert(details_row, 'after');
             var uri = toggle.get('href');
-            get_extra_diff_info(uri, details_row.one('td'), source_name);
+            get_extra_diff_info(
+                uri, details_row.one('td'), source_name, parent_distro_name,
+                parent_series_name);
         } else {
             details_row = next_row;
         }

=== modified file 'lib/lp/registry/javascript/tests/test_distroseriesdifferences_details.html'
--- lib/lp/registry/javascript/tests/test_distroseriesdifferences_details.html	2011-03-31 14:41:30 +0000
+++ lib/lp/registry/javascript/tests/test_distroseriesdifferences_details.html	2011-05-12 14:59:29 +0000
@@ -38,7 +38,7 @@
         <div class="diff-extra-container">
           <input name="field.selected_differences" type="checkbox" />
           <a class="toggle-extra"
-            href="/deribuntu/deriwarty/+difference/evolution">
+            href="/deribuntu/deriwarty/+source/evolution/+difference/ubuntu/warty">
             evolution</a>
           <span class="package-diff-button"></span>
           <dt class="package-diff-placeholder">

=== modified file 'lib/lp/registry/javascript/tests/test_distroseriesdifferences_details.js'
--- lib/lp/registry/javascript/tests/test_distroseriesdifferences_details.js	2011-04-05 12:10:01 +0000
+++ lib/lp/registry/javascript/tests/test_distroseriesdifferences_details.js	2011-05-12 14:59:29 +0000
@@ -16,7 +16,7 @@
 var ArrayAssert = Y.ArrayAssert;
 var suite = new Y.Test.Suite("Distroseries differences Tests");
 var dsd_details = Y.lp.registry.distroseriesdifferences_details;
-var dsd_uri = '/deribuntu/deriwarty/+difference/evolution';
+var dsd_uri = '/duntu/dwarty/+source/evolution/+difference/ubuntu/warty';
 
 var placeholder_content = Y.one('#placeholder_base').get('innerHTML');
 
@@ -46,7 +46,7 @@
         placeholder.set('innerHTML', placeholder_content);
         var msg_txt = 'Exemple text';
         var msg_node = Y.Node.create(msg_txt);
-        var placeholder = Y.one('#placeholder');
+        placeholder = Y.one('#placeholder');
         dsd_details.add_msg_node(placeholder, msg_node);
         Assert.areEqual(
             placeholder.one('.package-diff-placeholder').get('innerHTML'),

=== modified file 'lib/lp/registry/model/distroseries.py'
--- lib/lp/registry/model/distroseries.py	2011-05-11 05:39:36 +0000
+++ lib/lp/registry/model/distroseries.py	2011-05-12 14:59:29 +0000
@@ -754,7 +754,6 @@
             clauseTables=['SourcePackageRelease',
                           'SourcePackagePublishingHistory']).count()
 
-
         # next update the binary count
         clauseTables = ['DistroArchSeries', 'BinaryPackagePublishingHistory',
                         'BinaryPackageRelease']
@@ -791,12 +790,7 @@
     @property
     def is_derived_series(self):
         """See `IDistroSeries`."""
-        # Circular imports.
-        from lp.registry.interfaces.distroseriesparent import (
-            IDistroSeriesParentSet,
-            )
-        dsps = getUtility(IDistroSeriesParentSet).getByDerivedSeries(self)
-        return not dsps.is_empty()
+        return not self.getParentSeries() == []
 
     @property
     def is_initialising(self):
@@ -2001,6 +1995,15 @@
         getUtility(IInitialiseDistroSeriesJobSource).create(
             self, child, architectures, packagesets, rebuild)
 
+    def getParentSeries(self):
+        """See `IDistroSeriesPublic`."""
+        # Circular imports.
+        from lp.registry.interfaces.distroseriesparent import (
+            IDistroSeriesParentSet,
+            )
+        dsps = getUtility(IDistroSeriesParentSet).getByDerivedSeries(self)
+        return [dsp.parent_series for dsp in dsps]
+
     def getDerivedSeries(self):
         """See `IDistroSeriesPublic`."""
         # Circular imports.

=== modified file 'lib/lp/registry/model/distroseriesdifference.py'
--- lib/lp/registry/model/distroseriesdifference.py	2011-05-06 18:10:36 +0000
+++ lib/lp/registry/model/distroseriesdifference.py	2011-05-12 14:59:29 +0000
@@ -385,11 +385,15 @@
             differences, pre_iter_hook=eager_load)
 
     @staticmethod
-    def getByDistroSeriesAndName(distro_series, source_package_name):
+    def getByDistroSeriesNameAndParentSeries(distro_series,
+                                             source_package_name,
+                                             parent_series):
         """See `IDistroSeriesDifferenceSource`."""
+
         return IStore(DistroSeriesDifference).find(
             DistroSeriesDifference,
             DistroSeriesDifference.derived_series == distro_series,
+            DistroSeriesDifference.parent_series == parent_series,
             DistroSeriesDifference.source_package_name == (
                 SourcePackageName.id),
             SourcePackageName.name == source_package_name).one()

=== modified file 'lib/lp/registry/templates/distroseries-index.pt'
--- lib/lp/registry/templates/distroseries-index.pt	2011-04-15 15:08:20 +0000
+++ lib/lp/registry/templates/distroseries-index.pt	2011-05-12 14:59:29 +0000
@@ -65,10 +65,13 @@
           <div
             tal:replace="structure context/@@+portlet-details"/>
         </div>
-        <div class="yui-u"
-             tal:condition="request/features/soyuz.derived-series-ui.enabled">
-          <div tal:replace="structure context/@@+portlet-derivation" />
-        </div>
+        <tal:derivation
+          tal:condition="request/features/soyuz.derived-series-ui.enabled">
+          <div class="yui-u"
+               tal:condition="python:context.is_derived_series or context.is_initialising">
+            <div tal:replace="structure context/@@+portlet-derivation" />
+          </div>
+        </tal:derivation>
 
         <div class="yui-u">
           <div tal:replace="structure context/@@+portlet-package-summary" />

=== modified file 'lib/lp/registry/templates/distroseries-localdifferences.pt'
--- lib/lp/registry/templates/distroseries-localdifferences.pt	2011-05-06 10:45:50 +0000
+++ lib/lp/registry/templates/distroseries-localdifferences.pt	2011-05-12 14:59:29 +0000
@@ -23,7 +23,6 @@
     <div class="top-portlet" metal:fill-slot="main"
       tal:define="differences view/cached_differences;
                   series_name context/displayname;
-                  parent_name view/parent_series/displayname;
                   can_perform_sync view/canPerformSync;">
       <p><tal:replace replace="structure view/explanation/escapedtext" /></p>
 
@@ -43,8 +42,17 @@
           <thead>
             <tr>
               <th>Source</th>
+              <th tal:condition="python: not(view.has_unique_parent) and view.show_parent_version">
+                Parent
+              </th>
               <th tal:condition="view/show_parent_version">
-                <tal:replace replace="parent_name" /> version</th>
+                <tal:one_parent condition="view/has_unique_parent">
+                  <tal:replace replace="view/unique_parent/displayname" /> version
+                </tal:one_parent>
+                <tal:multiple_parents condition="not: view/has_unique_parent">
+                  Parent version
+                </tal:multiple_parents>
+              </th>
               <th tal:condition="view/show_derived_version">
                 <tal:replace replace="series_name" /> version</th>
               <th tal:condition="view/show_parent_packagesets">
@@ -61,6 +69,7 @@
             <tal:difference repeat="difference differences/batch">
             <tr tal:define="parent_source_pub difference/parent_source_pub;
                             source_pub difference/source_pub;
+                            diff_id difference/id;
                             src_name difference/source_package_name/name;"
                 tal:attributes="class src_name">
 
@@ -68,13 +77,17 @@
                 <input tal:condition="can_perform_sync"
                   name="field.selected_differences" type="checkbox"
                   tal:attributes="
-                    value src_name;
-                    id string:field.selected_differences.${src_name}"/>
+                    value diff_id;
+                    id string:field.selected_differences.${diff_id}"/>
 
                 <a tal:attributes="href difference/fmt:url" class="toggle-extra"
                    tal:content="src_name">Foo</a>
               </td>
-
+              <td tal:condition="python: not(view.has_unique_parent) and view.show_parent_version">
+                <a tal:attributes="href difference/parent_series/fmt:url"
+                   tal:content="difference/parent_series/displayname"
+                   class="parent-name">Warty</a>
+              </td>
               <td tal:condition="view/show_parent_version">
                 <a tal:condition="difference/parent_source_package_release"
                    tal:attributes="href difference/parent_source_package_release/fmt:url"

=== modified file 'lib/lp/registry/templates/distroseries-portlet-derivation.pt'
--- lib/lp/registry/templates/distroseries-portlet-derivation.pt	2011-05-05 04:52:36 +0000
+++ lib/lp/registry/templates/distroseries-portlet-derivation.pt	2011-05-12 14:59:29 +0000
@@ -6,7 +6,12 @@
   tal:define="overview_menu context/menu:overview">
   <tal:is_derived condition="context/is_derived_series">
     <tal:is_initialised condition="not: context/is_initialising">
-    <h2>Derived from <tal:name replace="view/parent_series/displayname"/></h2>
+    <tal:one_parent condition="view/has_unique_parent">
+      <h2>Derived from <tal:name replace="view/unique_parent/displayname"/></h2>
+    </tal:one_parent>
+    <tal:multiple_parents condition="not: view/has_unique_parent">
+      <h2>Derived from <tal:name replace="view/number_of_parents"/> parents</h2>
+    </tal:multiple_parents>
 
       <tal:diffs define="nb_diffs view/num_differences;
                          nb_diffs_in_parent view/num_differences_in_parent;
@@ -15,7 +20,7 @@
           <li tal:condition="nb_diffs">
             <a tal:attributes="href string:${context/fmt:url}/+localpackagediffs"
                class="sprite info"
-               title="Source package differences between this series and his parent">
+               title="Source package differences between this series and his parent(s)">
               <tal:nb_diffs replace="nb_diffs"/> package<tal:plural
                 content="string:s"
                 condition="python:nb_diffs!=1"/> with differences
@@ -25,10 +30,17 @@
             <a tal:attributes="href string:${context/fmt:url}/+missingpackages"
                class="sprite info"
                title="Source packages only in parent series">
-              <tal:nb_diffs replace="nb_diffs_in_parent"/> package<tal:plural
-                content="string:s"
-                condition="python:nb_diffs_in_parent!=1"/> only in <tal:replace
-                  replace="view/parent_series/displayname">Sid</tal:replace>
+              <tal:one_parent condition="view/has_unique_parent">
+                <tal:nb_diffs replace="nb_diffs_in_parent"/> package<tal:plural
+                  content="string:s"
+                  condition="python:nb_diffs_in_parent!=1"/> only in <tal:replace
+                    replace="view/unique_parent/displayname">Sid</tal:replace>
+              </tal:one_parent>
+              <tal:multiple_parents condition="not: view/has_unique_parent">
+                <tal:nb_diffs replace="nb_diffs_in_parent"/> package<tal:plural
+                  content="string:s"
+                  condition="python:nb_diffs_in_parent!=1"/> only in a parent series
+               </tal:multiple_parents>
             </a>
           </li>
           <li tal:condition="nb_diffs_in_child">

=== modified file 'lib/lp/registry/tests/test_distroseriesdifference.py'
--- lib/lp/registry/tests/test_distroseriesdifference.py	2011-05-11 16:15:35 +0000
+++ lib/lp/registry/tests/test_distroseriesdifference.py	2011-05-12 14:59:29 +0000
@@ -1027,14 +1027,14 @@
         self.assertContentEqual(diffs['normal'], results)
         self.assertContentEqual(diffs2['normal'], results2)
 
-    def test_getByDistroSeriesAndName(self):
+    def test_getByDistroSeriesNameAndParentSeries(self):
         # An individual difference is obtained using the name.
         ds_diff = self.factory.makeDistroSeriesDifference(
             source_package_name_str='fooname')
 
         dsd_source = getUtility(IDistroSeriesDifferenceSource)
-        result = dsd_source.getByDistroSeriesAndName(
-            ds_diff.derived_series, 'fooname')
+        result = dsd_source.getByDistroSeriesNameAndParentSeries(
+            ds_diff.derived_series, 'fooname', ds_diff.parent_series)
 
         self.assertEqual(ds_diff, result)
 

=== modified file 'lib/lp/soyuz/browser/configure.zcml'
--- lib/lp/soyuz/browser/configure.zcml	2011-01-28 04:08:32 +0000
+++ lib/lp/soyuz/browser/configure.zcml	2011-05-12 14:59:29 +0000
@@ -723,6 +723,12 @@
         <browser:page
             for="lp.registry.interfaces.sourcepackage.ISourcePackage"
             class=
+                "lp.soyuz.browser.sourcepackage.SourcePackageDifferenceView"
+            permission="zope.Public"
+            name="+difference"/>
+        <browser:page
+            for="lp.registry.interfaces.sourcepackage.ISourcePackage"
+            class=
                 "lp.soyuz.browser.sourcepackage.SourcePackageChangelogView"
             permission="zope.Public"
             name="+changelog"

=== modified file 'lib/lp/soyuz/browser/sourcepackage.py'
--- lib/lp/soyuz/browser/sourcepackage.py	2010-07-02 20:32:58 +0000
+++ lib/lp/soyuz/browser/sourcepackage.py	2011-05-12 14:59:29 +0000
@@ -10,7 +10,15 @@
     'SourcePackageCopyrightView',
     ]
 
+from zope.component import getUtility
+
+from canonical.launchpad.webapp import Navigation
 from canonical.lazr.utils import smartquote
+from lp.registry.interfaces.distribution import IDistributionSet
+from lp.registry.interfaces.distroseries import IDistroSeriesSet
+from lp.registry.interfaces.distroseriesdifference import (
+    IDistroSeriesDifferenceSource,
+    )
 
 
 class SourcePackageChangelogView:
@@ -33,3 +41,18 @@
     def label(self):
         """Page heading."""
         return smartquote("Copyright for " + self.context.title)
+
+
+class SourcePackageDifferenceView(Navigation):
+    """A view to traverse to a DistroSeriesDifference.
+    """
+
+    def traverse(self, parent_distro_name):
+        parent_distro = getUtility(
+            IDistributionSet).getByName(parent_distro_name)
+        parent_series = getUtility(
+            IDistroSeriesSet).queryByName(
+                parent_distro, self.request.stepstogo.consume())
+        dsd_source = getUtility(IDistroSeriesDifferenceSource)
+        return dsd_source.getByDistroSeriesNameAndParentSeries(
+            self.context.distroseries, self.context.name, parent_series)