← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~rvb/launchpad/sync-bug-827608-add-synchronized-packages into lp:launchpad

 

Raphaël Victor Badin has proposed merging lp:~rvb/launchpad/sync-bug-827608-add-synchronized-packages into lp:launchpad with lp:~rvb/launchpad/sync-bug-827608-populate-ancestor as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #827608 in Launchpad itself: "Sync requester isn't credited with upload"
  https://bugs.launchpad.net/launchpad/+bug/827608

For more details, see:
https://code.launchpad.net/~rvb/launchpad/sync-bug-827608-add-synchronized-packages/+merge/76569

This branch adds a new page (+synchronised-packages) and a new slot on +related-packages to show Synchronised packages for a person.

= Tests =

./bin/test -vvc test_person_view test_latest_synchronised_publishings_with_stats
./bin/test -vvc test_person_view test_view_helper_attributes
./bin/test -vvc test_person_view test_verify_bugs_and_answers_links
./bin/test -vvc test_person_view test_related_software_no_link_synchronised_packages
./bin/test -vvc test_person_view test_related_software_link_synchronised_packages
./bin/test -vvc test_person_view test_related_software_displays_synchronised_packages

./bin/test -vvc test_person test_getLatestSynchronisedPublishings_most_recent_first
./bin/test -vvc test_person test_getLatestSynchronisedPublishings_other_creator
./bin/test -vvc test_person test_getLatestSynchronisedPublishings_latest
./bin/test -vvc test_person test_getLatestSynchronisedPublishings_cross_archive_copies
./bin/test -vvc test_person test_getLatestSynchronisedPublishings_main_archive

= QA =

Sync a source (cross distro, destination archive should be PRIMARY) and make sure the synced source is displayed on https://launchpad.net/~syncer/+related-software and on https://launchpad.net/~synced/+synchronised-packages.
-- 
https://code.launchpad.net/~rvb/launchpad/sync-bug-827608-add-synchronized-packages/+merge/76569
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~rvb/launchpad/sync-bug-827608-add-synchronized-packages into lp:launchpad.
=== modified file 'lib/lp/registry/browser/person.py'
--- lib/lp/registry/browser/person.py	2011-09-21 01:41:08 +0000
+++ lib/lp/registry/browser/person.py	2011-09-22 12:54:34 +0000
@@ -326,6 +326,7 @@
 from lp.soyuz.interfaces.archive import IArchiveSet
 from lp.soyuz.interfaces.archivesubscriber import IArchiveSubscriberSet
 from lp.soyuz.interfaces.binarypackagebuild import IBinaryPackageBuildSet
+from lp.soyuz.interfaces.publishing import ISourcePackagePublishingHistory
 from lp.soyuz.interfaces.sourcepackagerelease import ISourcePackageRelease
 
 
@@ -990,6 +991,13 @@
         enabled = bool(self.person.getLatestUploadedPPAPackages())
         return Link(target, text, enabled=enabled, icon='info')
 
+    def synchronised(self):
+        target = '+synchronised-packages'
+        text = 'Synchronised packages'
+        enabled = bool(
+            self.person.getLatestSynchronisedPublishings())
+        return Link(target, text, enabled=enabled, icon='info')
+
     def projects(self):
         target = '+related-projects'
         text = 'Related projects'
@@ -1058,6 +1066,7 @@
         'projects',
         'activate_ppa',
         'maintained',
+        'synchronised',
         'view_ppa_subscriptions',
         'ppa',
         'oauth_tokens',
@@ -1193,7 +1202,7 @@
     usedfor = IPersonRelatedSoftwareMenu
     facet = 'overview'
     links = ('related_software_summary', 'maintained', 'uploaded', 'ppa',
-             'projects')
+             'synchronised', 'projects')
 
     @property
     def person(self):
@@ -5160,23 +5169,38 @@
         return Link('+subscribedquestions', text, summary, icon='question')
 
 
-class SourcePackageReleaseWithStats:
-    """An ISourcePackageRelease, with extra stats added."""
-
-    implements(ISourcePackageRelease)
-    delegates(ISourcePackageRelease)
+class BaseWithStats:
+    """An ISourcePackageRelease or a ISourcePackagePublishingHistory,
+    with extra stats added.
+
+    """
+
     failed_builds = None
     needs_building = None
 
-    def __init__(self, sourcepackage_release, open_bugs, open_questions,
+    def __init__(self, object, open_bugs, open_questions,
                  failed_builds, needs_building):
-        self.context = sourcepackage_release
+        self.context = object
         self.open_bugs = open_bugs
         self.open_questions = open_questions
         self.failed_builds = failed_builds
         self.needs_building = needs_building
 
 
+class SourcePackageReleaseWithStats(BaseWithStats):
+    """An ISourcePackageRelease, with extra stats added."""
+
+    implements(ISourcePackageRelease)
+    delegates(ISourcePackageRelease)
+
+
+class SourcePackagePublishingHistoryWithStats(BaseWithStats):
+    """An ISourcePackagePublishingHistory, with extra stats added."""
+
+    implements(ISourcePackagePublishingHistory)
+    delegates(ISourcePackagePublishingHistory)
+
+
 class PersonRelatedSoftwareView(LaunchpadView):
     """View for +related-software."""
     implements(IPersonRelatedSoftwareMenu)
@@ -5302,6 +5326,23 @@
         header_message = self._tableHeaderMessage(packages.count())
         return results, header_message
 
+    def _getDecoratedPublishingsSummary(self, publishings):
+        """Helper returning decorated publishings for the summary page.
+
+        :param publishings: A SelectResults that contains the query
+        :return: A tuple of (publishings, header_message).
+
+        The publishings returned are limited to self.max_results_to_display
+        and decorated with the stats required in the page template.
+        The header_message is the text to be displayed at the top of the
+        results table in the template.
+        """
+        # This code causes two SQL queries to be generated.
+        results = self._addStatsToPublishings(
+            publishings[:self.max_results_to_display])
+        header_message = self._tableHeaderMessage(publishings.count())
+        return results, header_message
+
     @property
     def latest_uploaded_ppa_packages_with_stats(self):
         """Return the sourcepackagereleases uploaded to PPAs by this person.
@@ -5333,6 +5374,17 @@
         self.uploaded_packages_header_message = header_message
         return results
 
+    @property
+    def latest_synchronised_publishings_with_stats(self):
+        """Return the latest synchronised publishings, including stats.
+
+        """
+        publishings = self.context.getLatestSynchronisedPublishings()
+        results, header_message = self._getDecoratedPublishingsSummary(
+            publishings)
+        self.synchronised_packages_header_message = header_message
+        return results
+
     def _calculateBuildStats(self, package_releases):
         """Calculate failed builds and needs_build state.
 
@@ -5394,6 +5446,38 @@
                 needs_build_by_package[package])
             for package in package_releases]
 
+    def _addStatsToPublishings(self, publishings):
+        """Add stats to the given publishings, and return them."""
+        filtered_spphs = [
+            spph for spph in publishings if
+            check_permission('launchpad.View', spph)]
+        distro_packages = [
+            spph.meta_sourcepackage.distribution_sourcepackage
+            for spph in filtered_spphs]
+        package_bug_counts = getUtility(IBugTaskSet).getBugCountsForPackages(
+            self.user, distro_packages)
+        open_bugs = {}
+        for bug_count in package_bug_counts:
+            distro_package = bug_count['package']
+            open_bugs[distro_package] = bug_count['open']
+
+        question_set = getUtility(IQuestionSet)
+        package_question_counts = question_set.getOpenQuestionCountByPackages(
+            distro_packages)
+
+        builds_by_package, needs_build_by_package = self._calculateBuildStats(
+            [spph.sourcepackagerelease for spph in filtered_spphs])
+
+        return [
+            SourcePackagePublishingHistoryWithStats(
+                spph,
+                open_bugs[spph.meta_sourcepackage.distribution_sourcepackage],
+                package_question_counts[
+                    spph.meta_sourcepackage.distribution_sourcepackage],
+                builds_by_package[spph.sourcepackagerelease],
+                needs_build_by_package[spph.sourcepackagerelease])
+            for spph in filtered_spphs]
+
     def setUpBatch(self, packages):
         """Set up the batch navigation for the page being viewed.
 
@@ -5454,6 +5538,30 @@
         return "PPA packages"
 
 
+class PersonSynchronisedPackagesView(PersonRelatedSoftwareView):
+    """View for +synchronised-packages."""
+    _max_results_key = 'default_batch_size'
+
+    def initialize(self):
+        """Set up the batch navigation."""
+        publishings = self.context.getLatestSynchronisedPublishings()
+        self.setUpBatch(publishings)
+
+    def setUpBatch(self, publishings):
+        """Set up the batch navigation for the page being viewed.
+
+        This method creates the BatchNavigator and converts its
+        results batch into a list of decorated sourcepackagepublishinghistory.
+        """
+        self.batchnav = BatchNavigator(publishings, self.request)
+        publishings_batch = list(self.batchnav.currentBatch())
+        self.batch = self._addStatsToPublishings(publishings_batch)
+
+    @property
+    def page_title(self):
+        return "Synchronised packages"
+
+
 class PersonRelatedProjectsView(PersonRelatedSoftwareView):
     """View for +related-projects."""
     _max_results_key = 'default_batch_size'

=== modified file 'lib/lp/registry/browser/tests/test_person_view.py'
--- lib/lp/registry/browser/tests/test_person_view.py	2011-09-21 01:41:08 +0000
+++ lib/lp/registry/browser/tests/test_person_view.py	2011-09-22 12:54:34 +0000
@@ -1,15 +1,17 @@
-# Copyright 2009-2010 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
 
 import doctest
 
+import soupmatchers
 from storm.expr import LeftJoin
 from storm.store import Store
 from testtools.matchers import (
     DocTestMatches,
     LessThan,
+    Not,
     )
 import transaction
 from zope.component import getUtility
@@ -23,6 +25,7 @@
 from canonical.launchpad.interfaces.authtoken import LoginTokenType
 from canonical.launchpad.interfaces.logintoken import ILoginTokenSet
 from canonical.launchpad.testing.pages import extract_text
+from canonical.launchpad.webapp import canonical_url
 from canonical.launchpad.webapp.interfaces import ILaunchBag
 from canonical.launchpad.webapp.servers import LaunchpadTestRequest
 from canonical.testing.layers import (
@@ -45,6 +48,7 @@
     PersonVisibility,
     )
 from lp.registry.interfaces.persontransferjob import IPersonMergeJobSource
+from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.registry.interfaces.teammembership import (
     ITeamMembershipSet,
     TeamMembershipStatus,
@@ -528,20 +532,31 @@
         self.warty = self.ubuntu.getSeries('warty')
         self.view = create_initialized_view(self.user, '+related-software')
 
-    def publishSource(self, archive, maintainer):
+    def publishSources(self, archive, maintainer):
         publisher = SoyuzTestPublisher()
         publisher.person = self.user
         login('foo.bar@xxxxxxxxxxxxx')
+        spphs = []
         for count in range(0, self.view.max_results_to_display + 3):
             source_name = "foo" + str(count)
-            publisher.getPubSource(
+            spph = publisher.getPubSource(
                 sourcename=source_name,
                 status=PackagePublishingStatus.PUBLISHED,
                 archive=archive,
                 maintainer=maintainer,
                 creator=self.user,
                 distroseries=self.warty)
+            spphs.append(spph)
         login(ANONYMOUS)
+        return spphs
+
+    def copySources(self, spphs, copier, dest_distroseries):
+        self.copier = self.factory.makePerson()
+        for spph in spphs:
+            spph.copyTo(
+                dest_distroseries, creator=copier,
+                pocket=PackagePublishingPocket.UPDATES,
+                archive=dest_distroseries.main_archive)
 
     def test_view_helper_attributes(self):
         # Verify view helper attributes.
@@ -563,24 +578,34 @@
     def test_latest_uploaded_ppa_packages_with_stats(self):
         # Verify number of PPA packages to display.
         ppa = self.factory.makeArchive(owner=self.user)
-        self.publishSource(ppa, self.user)
+        self.publishSources(ppa, self.user)
         count = len(self.view.latest_uploaded_ppa_packages_with_stats)
         self.assertEqual(self.view.max_results_to_display, count)
 
     def test_latest_maintained_packages_with_stats(self):
         # Verify number of maintained packages to display.
-        self.publishSource(self.warty.main_archive, self.user)
+        self.publishSources(self.warty.main_archive, self.user)
         count = len(self.view.latest_maintained_packages_with_stats)
         self.assertEqual(self.view.max_results_to_display, count)
 
     def test_latest_uploaded_nonmaintained_packages_with_stats(self):
         # Verify number of non maintained packages to display.
         maintainer = self.factory.makePerson()
-        self.publishSource(self.warty.main_archive, maintainer)
+        self.publishSources(self.warty.main_archive, maintainer)
         count = len(
             self.view.latest_uploaded_but_not_maintained_packages_with_stats)
         self.assertEqual(self.view.max_results_to_display, count)
 
+    def test_latest_synchronised_publishings_with_stats(self):
+        # Verify number of non synchronised publishings to display.
+        creator = self.factory.makePerson()
+        spphs = self.publishSources(self.warty.main_archive, creator)
+        dest_distroseries = self.factory.makeDistroSeries()
+        self.copySources(spphs, self.user, dest_distroseries)
+        count = len(
+            self.view.latest_synchronised_publishings_with_stats)
+        self.assertEqual(self.view.max_results_to_display, count)
+
 
 class TestPersonMaintainedPackagesView(TestCaseWithFactory):
     """Test the maintained packages view."""
@@ -654,6 +679,55 @@
             self.view.max_results_to_display)
 
 
+class TestPersonSynchronisedPackagesView(TestCaseWithFactory):
+    """Test the synchronised packages view."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestPersonSynchronisedPackagesView, self).setUp()
+        user = self.factory.makePerson()
+        archive = self.factory.makeArchive(purpose=ArchivePurpose.PRIMARY)
+        spr = self.factory.makeSourcePackageRelease(
+            creator=user, archive=archive)
+        spph = self.factory.makeSourcePackagePublishingHistory(
+            sourcepackagerelease=spr, archive=archive)
+        self.copier = self.factory.makePerson()
+        dest_distroseries = self.factory.makeDistroSeries()
+        self.copied_spph = spph.copyTo(
+            dest_distroseries, creator=self.copier,
+            pocket=PackagePublishingPocket.UPDATES,
+            archive=dest_distroseries.main_archive)
+        self.view = create_initialized_view(
+            self.copier, '+synchronised-packages')
+
+    def test_view_helper_attributes(self):
+        # Verify view helper attributes.
+        self.assertEqual('Synchronised packages', self.view.page_title)
+        self.assertEqual('default_batch_size', self.view._max_results_key)
+        self.assertEqual(
+            config.launchpad.default_batch_size,
+            self.view.max_results_to_display)
+
+    def test_verify_bugs_and_answers_links(self):
+        # Verify the links for bugs and answers point to locations that
+        # exist.
+        html = self.view()
+        expected_base = '/%s/+source/%s' % (
+            self.copied_spph.distroseries.distribution.name,
+            self.copied_spph.source_package_name)
+        bug_matcher = soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                'Bugs link', 'a',
+                attrs={'href': expected_base + '/+bugs'}))
+        question_matcher = soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                'Questions link', 'a',
+                attrs={'href': expected_base + '/+questions'}))
+        self.assertThat(html, bug_matcher)
+        self.assertThat(html, question_matcher)
+
+
 class TestPersonRelatedProjectsView(TestCaseWithFactory):
     """Test the maintained packages view."""
 
@@ -718,6 +792,75 @@
                 self.build.id) in html)
 
 
+class TestPersonRelatedSoftwareSynchronisedPackages(TestCaseWithFactory):
+    """The related software views display links to synchronised packages."""
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super(TestPersonRelatedSoftwareSynchronisedPackages, self).setUp()
+        self.user = self.factory.makePerson()
+        self.spph = self.factory.makeSourcePackagePublishingHistory()
+
+    def createCopiedSource(self, copier, spph):
+        self.copier = self.factory.makePerson()
+        dest_distroseries = self.factory.makeDistroSeries()
+        return spph.copyTo(
+            dest_distroseries, creator=copier,
+            pocket=PackagePublishingPocket.UPDATES,
+            archive=dest_distroseries.main_archive)
+
+    def getLinkToSynchronisedMatcher(self):
+        person_url = canonical_url(self.user)
+        return soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                'Synchronised packages link', 'a',
+                attrs={'href': person_url + '/+synchronised-packages'},
+                text='Synchronised packages'))
+
+    def test_related_software_no_link_synchronised_packages(self):
+        # No link to the synchronised packages page if no synchronised
+        # packages.
+        view = create_view(self.user, name='+related-software')
+        synced_package_link_matcher = self.getLinkToSynchronisedMatcher()
+        self.assertThat(view(), Not(synced_package_link_matcher))
+
+    def test_related_software_link_synchronised_packages(self):
+        # If this person has synced packages, the link to the synchronised
+        # packages page is present.
+        self.createCopiedSource(self.user, self.spph)
+        view = create_view(self.user, name='+related-software')
+        synced_package_link_matcher = self.getLinkToSynchronisedMatcher()
+        self.assertThat(view(), synced_package_link_matcher)
+
+    def test_related_software_displays_synchronised_packages(self):
+        copied_spph = self.createCopiedSource(self.user, self.spph)
+        view = create_view(self.user, name='+related-software')
+        synced_packages_title = soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                'Synchronised packages title', 'h2',
+                text='Synchronised packages'))
+        expected_base = '/%s/+source/%s' % (
+            copied_spph.distroseries.distribution.name,
+            copied_spph.source_package_name)
+        source_link = soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                'Source package link', 'a',
+                text=copied_spph.sourcepackagerelease.name,
+                attrs={'href': expected_base}))
+        version_url = (expected_base + '/%s' %
+            copied_spph.sourcepackagerelease.version)
+        version_link = soupmatchers.HTMLContains(
+            soupmatchers.Tag(
+                'Source package version link', 'a',
+                text=copied_spph.sourcepackagerelease.version,
+                attrs={'href': version_url}))
+
+        self.assertThat(view(), synced_packages_title)
+        self.assertThat(view(), source_link)
+        self.assertThat(view(), version_link)
+
+
 class TestPersonDeactivateAccountView(TestCaseWithFactory):
     """Tests for the PersonDeactivateAccountView."""
 

=== modified file 'lib/lp/registry/interfaces/person.py'
--- lib/lp/registry/interfaces/person.py	2011-09-21 01:41:08 +0000
+++ lib/lp/registry/interfaces/person.py	2011-09-22 12:54:34 +0000
@@ -1240,6 +1240,14 @@
         for each source package name, distribution series combination.
         """
 
+    def getLatestSynchronisedPublishings(self):
+        """Return `SourcePackagePublishingHistory`s synchronised by this
+        person.
+
+        This method will only include the latest publishings for each source
+        package name, distribution series combination.
+        """
+
     def getLatestUploadedButNotMaintainedPackages():
         """Return `SourcePackageRelease`s created by this person but
         not maintained by him.

=== modified file 'lib/lp/registry/model/person.py'
--- lib/lp/registry/model/person.py	2011-09-21 01:41:08 +0000
+++ lib/lp/registry/model/person.py	2011-09-22 12:54:34 +0000
@@ -297,6 +297,7 @@
 from lp.soyuz.interfaces.archivepermission import IArchivePermissionSet
 from lp.soyuz.interfaces.archivesubscriber import IArchiveSubscriberSet
 from lp.soyuz.model.archive import Archive
+from lp.soyuz.model.publishing import SourcePackagePublishingHistory
 from lp.soyuz.model.sourcepackagerelease import SourcePackageRelease
 from lp.translations.model.hastranslationimports import (
     HasTranslationImportsMixin,
@@ -2586,6 +2587,40 @@
         """See `IPerson`."""
         return self._latestSeriesQuery()
 
+    def getLatestSynchronisedPublishings(self):
+        """See `IPerson`."""
+        query = """
+            SourcePackagePublishingHistory.id IN (
+                SELECT DISTINCT ON (spph.distroseries,
+                                    spr.sourcepackagename)
+                    spph.id
+                FROM
+                    SourcePackagePublishingHistory as spph, archive,
+                    SourcePackagePublishingHistory as ancestor_spph,
+                    SourcePackageRelease as spr
+                WHERE
+                    spph.sourcepackagerelease = spr.id AND
+                    spph.creator = %(creator)s AND
+                    spph.ancestor = ancestor_spph.id AND
+                    spph.archive = archive.id AND
+                    ancestor_spph.archive != spph.archive AND
+                    archive.purpose = %(archive_purpose)s
+                ORDER BY spph.distroseries,
+                    spr.sourcepackagename,
+                    spph.datecreated DESC,
+                    spph.id DESC
+            )
+            """ % dict(
+                   creator=quote(self.id),
+                   archive_purpose=quote(ArchivePurpose.PRIMARY),
+                   )
+
+        return SourcePackagePublishingHistory.select(
+            query,
+            orderBy=['-SourcePackagePublishingHistory.datecreated',
+                     '-SourcePackagePublishingHistory.id'],
+            prejoins=['sourcepackagerelease', 'archive'])
+
     def getLatestUploadedButNotMaintainedPackages(self):
         """See `IPerson`."""
         return self._latestSeriesQuery(uploader_only=True)

=== modified file 'lib/lp/registry/templates/person-macros.pt'
--- lib/lp/registry/templates/person-macros.pt	2011-09-21 01:41:08 +0000
+++ lib/lp/registry/templates/person-macros.pt	2011-09-22 12:54:34 +0000
@@ -181,6 +181,68 @@
   </tr>
 </metal:macro>
 
+<metal:macro define-macro="spphs-rows">
+
+  <tal:comment replace="nothing">
+    This macro expects the following variables defined:
+    :spphs: A list of SourcePackagePublishingHistory objects
+  </tal:comment>
+
+  <tr tal:repeat="spph spphs">
+  <tal:define define="spr spph/sourcepackagerelease;
+                     distroseries spph/distroseries">
+    <td>
+      <a tal:attributes="href string:${distroseries/distribution/fmt:url}/+source/${spr/name}"
+         class="distrosrcpackage"
+         tal:content="spr/sourcepackagename/name">
+      </a>
+    </td>
+    <td>
+      <a tal:attributes="href string:${distroseries/fmt:url}/+source/${spr/name}"
+         class="distroseriessrcpackage"
+         tal:content="distroseries/fullseriesname">
+      </a>
+    </td>
+    <td>
+      <a tal:attributes="href string:${distroseries/distribution/fmt:url}/+source/${spr/name}/${spr/version}"
+         class="distrosrcpackagerelease"
+         tal:content="spr/version">
+      </a>
+    </td>
+    <td
+      tal:attributes="title spph/datecreated/fmt:datetime"
+      tal:content="spph/datecreated/fmt:approximatedate">
+      2005-10-24
+    </td>
+    <td>
+      <tal:needs_building condition="spph/needs_building">
+          Not yet built
+      </tal:needs_building>
+      <tal:built condition="not: spph/needs_building">
+          <tal:failed repeat="build spph/failed_builds">
+             <a tal:attributes="href build/fmt:url"
+                tal:content="build/distro_arch_series/architecturetag" />
+          </tal:failed>
+          <tal:not_failed condition="not: spph/failed_builds">
+             None
+          </tal:not_failed>
+      </tal:built>
+    </td>
+    <td style="text-align: right">
+      <a tal:attributes="href string:${spph/meta_sourcepackage/distribution_sourcepackage/fmt:url}/+bugs"
+         tal:content="spph/open_bugs">
+      </a>
+    </td>
+    <td style="text-align: right">
+      <a tal:attributes="href string:${spph/meta_sourcepackage/distribution_sourcepackage/fmt:url}/+questions"
+         tal:content="spph/open_questions">
+      </a>
+    </td>
+  </tal:define>
+  </tr>
+</metal:macro>
+
+
 <metal:macro define-macro="private-team-js">
   <tal:comment replace="nothing">
     This macro inserts the javascript necessary to automatically insert the

=== modified file 'lib/lp/registry/templates/person-related-software-navlinks.pt'
--- lib/lp/registry/templates/person-related-software-navlinks.pt	2009-10-16 00:47:43 +0000
+++ lib/lp/registry/templates/person-related-software-navlinks.pt	2011-09-22 12:54:34 +0000
@@ -22,6 +22,10 @@
         tal:condition="link/enabled"
         tal:content="structure link/fmt:link" />
       <li
+        tal:define="link view/menu:navigation/synchronised"
+        tal:condition="link/enabled"
+        tal:content="structure link/fmt:link" />
+       <li
         tal:define="link view/menu:navigation/projects"
         tal:condition="link/enabled"
         tal:content="structure link/fmt:link" />

=== modified file 'lib/lp/registry/templates/person-related-software.pt'
--- lib/lp/registry/templates/person-related-software.pt	2011-09-21 01:41:08 +0000
+++ lib/lp/registry/templates/person-related-software.pt	2011-09-22 12:54:34 +0000
@@ -99,6 +99,32 @@
   </div>
   </tal:ppa-packages>
 
+  <tal:synchronised-packages
+    define="spphs view/latest_synchronised_publishings_with_stats"
+    condition="spphs">
+
+  <div class="top-portlet">
+  <h2>Synchronised packages</h2>
+
+  <tal:message replace="view/synchronised_packages_header_message"/>
+  <table class="listing">
+    <thead>
+      <tr>
+        <th>Name</th>
+        <th>Uploaded to</th>
+        <th>Version</th>
+        <th>When</th>
+        <th>Failures</th>
+        <th>Bugs</th>
+        <th>Questions</th>
+      </tr>
+    </thead>
+
+    <div metal:use-macro="context/@@+person-macros/spphs-rows" />
+  </table>
+  </div>
+  </tal:synchronised-packages>
+
   </div><!--id packages-->
 
   <div id="projects" class="top-portlet">

=== modified file 'lib/lp/registry/tests/test_person.py'
--- lib/lp/registry/tests/test_person.py	2011-09-21 01:41:08 +0000
+++ lib/lp/registry/tests/test_person.py	2011-09-22 12:54:34 +0000
@@ -61,6 +61,7 @@
     PersonVisibility,
     )
 from lp.registry.interfaces.personnotification import IPersonNotificationSet
+from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.registry.interfaces.product import IProductSet
 from lp.registry.model.karma import (
     KarmaCategory,
@@ -437,6 +438,88 @@
             list(user.getBugSubscriberPackages())
         self.assertThat(recorder, HasQueryCount(Equals(1)))
 
+    def createCopiedPackage(self, spph, copier, dest_distroseries=None,
+                            dest_archive=None):
+        if dest_distroseries is None:
+            dest_distroseries = self.factory.makeDistroSeries()
+        if dest_archive is None:
+            dest_archive = dest_distroseries.main_archive
+        return spph.copyTo(
+            dest_distroseries, creator=copier,
+            pocket=PackagePublishingPocket.UPDATES,
+            archive=dest_archive)
+
+    def test_getLatestSynchronisedPublishings_most_recent_first(self):
+        # getLatestSynchronisedPublishings returns the latest copies sorted
+        # by most recent first.
+        spph = self.factory.makeSourcePackagePublishingHistory()
+        copier = self.factory.makePerson()
+        copied_spph1 = self.createCopiedPackage(spph, copier)
+        copied_spph2 = self.createCopiedPackage(spph, copier)
+        synchronised_spphs = copier.getLatestSynchronisedPublishings()
+
+        self.assertContentEqual(
+            [copied_spph2, copied_spph1],
+            synchronised_spphs)
+
+    def test_getLatestSynchronisedPublishings_other_creator(self):
+        spph = self.factory.makeSourcePackagePublishingHistory()
+        copier = self.factory.makePerson()
+        self.createCopiedPackage(spph, copier)
+        someone_else = self.factory.makePerson()
+        synchronised_spphs = someone_else.getLatestSynchronisedPublishings()
+
+        self.assertEqual(
+            0,
+            synchronised_spphs.count())
+
+    def test_getLatestSynchronisedPublishings_latest(self):
+        # getLatestSynchronisedPublishings returns only the latest copy of
+        # a package in a distroseries
+        spph = self.factory.makeSourcePackagePublishingHistory()
+        copier = self.factory.makePerson()
+        dest_distroseries = self.factory.makeDistroSeries()
+        self.createCopiedPackage(
+            spph, copier, dest_distroseries)
+        copied_spph2 = self.createCopiedPackage(
+            spph, copier, dest_distroseries)
+        synchronised_spphs = copier.getLatestSynchronisedPublishings()
+
+        self.assertContentEqual(
+            [copied_spph2],
+            synchronised_spphs)
+
+    def test_getLatestSynchronisedPublishings_cross_archive_copies(self):
+        # getLatestSynchronisedPublishings returns only the copies copied
+        # cross archive.
+        spph = self.factory.makeSourcePackagePublishingHistory()
+        copier = self.factory.makePerson()
+        dest_distroseries2 = self.factory.makeDistroSeries(
+            distribution=spph.distroseries.distribution)
+        self.createCopiedPackage(
+            spph, copier, dest_distroseries2)
+        synchronised_spphs = copier.getLatestSynchronisedPublishings()
+
+        self.assertEqual(
+            0,
+            synchronised_spphs.count())
+
+    def test_getLatestSynchronisedPublishings_main_archive(self):
+        # getLatestSynchronisedPublishings returns only the copies copied in
+        # a primary archive (as opposed to a ppa).
+        spph = self.factory.makeSourcePackagePublishingHistory()
+        copier = self.factory.makePerson()
+        dest_distroseries = self.factory.makeDistroSeries()
+        ppa = self.factory.makeArchive(
+            distribution=dest_distroseries.distribution)
+        self.createCopiedPackage(
+            spph, copier, dest_distroseries, ppa)
+        synchronised_spphs = copier.getLatestSynchronisedPublishings()
+
+        self.assertEqual(
+            0,
+            synchronised_spphs.count())
+
 
 class TestPersonStates(TestCaseWithFactory):
 

=== modified file 'lib/lp/soyuz/browser/configure.zcml'
--- lib/lp/soyuz/browser/configure.zcml	2011-09-19 14:29:47 +0000
+++ lib/lp/soyuz/browser/configure.zcml	2011-09-22 12:54:34 +0000
@@ -686,6 +686,12 @@
             name="+ppa-packages"
             template="../templates/person-ppa-packages.pt"/>
         <browser:page
+            for="lp.registry.interfaces.person.IPerson"
+            permission="zope.Public"
+            class="lp.registry.browser.person.PersonSynchronisedPackagesView"
+            name="+synchronised-packages"
+            template="../templates/person-synchronised-packages.pt"/>
+         <browser:page
             name="+archivesubscriptions"
             for="lp.registry.interfaces.person.IPerson"
             class="lp.soyuz.browser.archivesubscription.PersonArchiveSubscriptionsView"

=== added file 'lib/lp/soyuz/templates/person-synchronised-packages.pt'
--- lib/lp/soyuz/templates/person-synchronised-packages.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/templates/person-synchronised-packages.pt	2011-09-22 12:54:34 +0000
@@ -0,0 +1,59 @@
+
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad"
+>
+
+<body>
+
+<div metal:fill-slot="heading">
+  <h1 tal:content="view/page_title"/>
+</div>
+
+<div metal:fill-slot="main">
+  <div class="top-portlet">
+    <tal:navlinks replace="structure context/@@+related-software-navlinks"/>
+  </div>
+
+  <div id="packages" class="top-portlet">
+
+  <tal:navigation_top
+       replace="structure view/batchnav/@@+navigation-links-upper" />
+
+  <tal:synchronised-packages
+    define="spphs view/batch">
+
+  <table class="listing" tal:condition="spphs">
+    <thead>
+      <tr>
+        <th>Name</th>
+        <th>Uploaded to</th>
+        <th>Version</th>
+        <th>When</th>
+        <th>Failures</th>
+        <th>Bugs</th>
+        <th>Questions</th>
+      </tr>
+    </thead>
+    <tbody>
+      <div metal:use-macro="context/@@+person-macros/spphs-rows" />
+    </tbody>
+  </table>
+
+  <tal:navigation_bottom
+       replace="structure view/batchnav/@@+navigation-links-lower" />
+
+  <tal:no_packages condition="not: spphs">
+    <tal:name replace="context/fmt:displayname"/> has not synchronised any packages.
+  </tal:no_packages>
+
+  </tal:synchronised-packages>
+  </div>
+</div>
+
+</body>
+</html>