← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~jtv/launchpad/resolve-conflict-with-allenap into lp:launchpad/db-devel

 

Jeroen T. Vermeulen has proposed merging lp:~jtv/launchpad/resolve-conflict-with-allenap into lp:launchpad/db-devel.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~jtv/launchpad/resolve-conflict-with-allenap/+merge/60875

Resolving a devel/db-devel conflict between Gavin & me.  This seems a more cost-effective solution than the usual fight to the death.

I switched two parameters in an interface method around.  Don't over-think that; I was just making them consistent with the implementation.


= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/soyuz/browser/tests/test_archive_packages.py
  lib/lp/soyuz/scripts/packagecopier.py
  lib/lp/registry/doc/commercialsubscription.txt
  lib/lp/registry/doc/product.txt
  lib/lp/registry/browser/product.py
  lib/lp/registry/browser/tests/product-views.txt
  lib/lp/registry/configure.zcml
  lib/lp/registry/interfaces/product.py
  lib/lp/soyuz/interfaces/packagecopyjob.py
  lib/lp/registry/doc/launchpadlib/project-registry.txt.disabled
  lib/lp/registry/templates/products-review-licenses.pt
  lib/lp/registry/templates/product-review-license.pt
  lib/lp/registry/stories/webservice/xx-project-registry.txt
  lib/lp/registry/templates/product-listing-for-review.pt
  lib/lp/registry/stories/product/xx-productset.txt
  lib/lp/app/stories/form/xx-form-layout.txt
  lib/lp/registry/browser/tests/test_product.py
  lib/lp/soyuz/browser/tests/test_package_copying_mixin.py
  lib/lp/registry/model/product.py
  lib/canonical/launchpad/database/launchpadstatistic.py
  lib/lp/registry/stories/product/xx-product-index.txt
  lib/lp/soyuz/browser/archive.py

./lib/lp/registry/doc/launchpadlib/project-registry.txt.disabled
      94: Line exceeds 78 characters.
     221: Line exceeds 78 characters.
./lib/lp/registry/templates/product-listing-for-review.pt
      30: Line has trailing whitespace.
-- 
https://code.launchpad.net/~jtv/launchpad/resolve-conflict-with-allenap/+merge/60875
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~jtv/launchpad/resolve-conflict-with-allenap into lp:launchpad/db-devel.
=== modified file 'lib/canonical/launchpad/database/launchpadstatistic.py'
--- lib/canonical/launchpad/database/launchpadstatistic.py	2011-04-26 16:39:33 +0000
+++ lib/canonical/launchpad/database/launchpadstatistic.py	2011-05-13 08:56:09 +0000
@@ -160,7 +160,7 @@
                 distinct=True, clauseTables=['Question']).count())
         self.update(
             'reviewed_products',
-            Product.selectBy(license_reviewed=True, active=True).count())
+            Product.selectBy(project_reviewed=True, active=True).count())
 
     def _updateRosettaStatistics(self, ztm):
         # XXX j.c.sackett 2010-11-19 bug=677532 It's less than ideal that

=== modified file 'lib/lp/app/stories/form/xx-form-layout.txt'
--- lib/lp/app/stories/form/xx-form-layout.txt	2011-02-02 15:37:35 +0000
+++ lib/lp/app/stories/form/xx-form-layout.txt	2011-05-13 08:56:09 +0000
@@ -105,8 +105,8 @@
     <tr>
       <td colspan="2">
        <div>
-          <input ... name="field.license_reviewed" type="checkbox" ...  />
-          <label for="field.license_reviewed">Project reviewed</label>...
+          <input ... name="field.project_reviewed" type="checkbox" ...  />
+          <label for="field.project_reviewed">Project reviewed</label>...
       </td>
     </tr>
     ...

=== modified file 'lib/lp/registry/browser/product.py'
--- lib/lp/registry/browser/product.py	2011-03-29 22:34:04 +0000
+++ lib/lp/registry/browser/product.py	2011-05-13 08:56:09 +0000
@@ -129,7 +129,10 @@
     ReturnToReferrerMixin,
     safe_action,
     )
-from lp.app.browser.lazrjs import TextLineEditorWidget
+from lp.app.browser.lazrjs import (
+    BooleanChoiceWidget,
+    TextLineEditorWidget,
+    )
 from lp.app.browser.tales import MenuAPI
 from lp.app.enums import ServiceUsage
 from lp.app.errors import NotFoundError
@@ -1133,6 +1136,49 @@
             License.OTHER_OPEN_SOURCE in self.context.licenses
             or License.OTHER_PROPRIETARY in self.context.licenses)
 
+    @cachedproperty
+    def is_proprietary(self):
+        """Is the project proprietary."""
+        return License.OTHER_PROPRIETARY in self.context.licenses
+
+    @property
+    def active_widget(self):
+        return BooleanChoiceWidget(
+            self.context, IProduct['active'],
+            content_box_id='%s-edit-active' % self.context.name,
+            edit_view='+review-license',
+            tag='span',
+            false_text='Deactivted',
+            true_text='Active',
+            header='Is this project active and usable by the community?')
+
+    @property
+    def project_reviewed_widget(self):
+        return BooleanChoiceWidget(
+            self.context, IProduct['project_reviewed'],
+            content_box_id='%s-edit-project-reviewed' % self.context.name,
+            edit_view='+review-license',
+            tag='span',
+            false_text='Unreviewed',
+            true_text='Reviewed',
+            header='Have you reviewed the project?')
+
+    @property
+    def license_approved_widget(self):
+        licenses = list(self.context.licenses)
+        if License.OTHER_PROPRIETARY in licenses:
+            return 'Commercial subscription required'
+        elif [License.DONT_KNOW] == licenses or [] == licenses:
+            return 'License required'
+        return BooleanChoiceWidget(
+            self.context, IProduct['license_approved'],
+            content_box_id='%s-edit-license-approved' % self.context.name,
+            edit_view='+review-license',
+            tag='span',
+            false_text='Unapproved',
+            true_text='Approved',
+            header='Does the license qualifiy the project for free hosting?')
+
 
 class ProductPackagesView(LaunchpadView):
     """View for displaying product packaging"""
@@ -1619,7 +1665,7 @@
     """A view to review a project and change project privileges."""
     label = "Review project"
     field_names = [
-        "license_reviewed",
+        "project_reviewed",
         "license_approved",
         "active",
         "private_bugs",
@@ -1804,11 +1850,12 @@
 
     schema = IProductReviewSearch
     label = 'Review projects'
+    page_title = label
 
     full_row_field_names = [
         'search_text',
         'active',
-        'license_reviewed',
+        'project_reviewed',
         'license_approved',
         'license_info_is_empty',
         'licenses',
@@ -1826,7 +1873,7 @@
         orientation='vertical')
     custom_widget('active', LaunchpadRadioWidget,
                   _messageNoValue="(do not filter)")
-    custom_widget('license_reviewed', LaunchpadRadioWidget,
+    custom_widget('project_reviewed', LaunchpadRadioWidget,
                   _messageNoValue="(do not filter)")
     custom_widget('license_approved', LaunchpadRadioWidget,
                   _messageNoValue="(do not filter)")
@@ -1858,21 +1905,25 @@
         """Return all widgets that span all columns."""
         return (self.widgets[name] for name in self.full_row_field_names)
 
+    @property
+    def initial_values(self):
+        """See `ILaunchpadFormView`."""
+        search_params = {}
+        for name in self.schema:
+            search_params[name] = self.schema[name].default
+        return search_params
+
     def forReviewBatched(self):
         """Return a `BatchNavigator` to review the matching projects."""
         # Calling _validate populates the data dictionary as a side-effect
         # of validation.
         data = {}
         self._validate(None, data)
-        # Get default values from the schema since the form defaults
-        # aren't available until the search button is pressed.
-        search_params = {}
-        for name in self.schema:
-            search_params[name] = self.schema[name].default
+        search_params = self.initial_values
         # Override the defaults with the form values if available.
         search_params.update(data)
         return BatchNavigator(self.context.forReview(**search_params),
-                              self.request, size=100)
+                              self.request, size=50)
 
 
 class ProductAddViewBase(ProductLicenseMixin, LaunchpadFormView):

=== modified file 'lib/lp/registry/browser/tests/product-views.txt'
--- lib/lp/registry/browser/tests/product-views.txt	2011-03-07 18:01:11 +0000
+++ lib/lp/registry/browser/tests/product-views.txt	2011-05-13 08:56:09 +0000
@@ -128,7 +128,7 @@
     >>> view = create_initialized_view(firefox, name='+review-license')
     Traceback (most recent call last):
     ...
-    Unauthorized: (<Product..., 'license_reviewed', 'launchpad.Moderate')
+    Unauthorized: (<Product..., 'project_reviewed', 'launchpad.Moderate')
 
 Mark is in the registry admins team and is allowed to access the page.
 
@@ -153,7 +153,7 @@
 judge the licenses.
 
     >>> view.field_names
-    ['license_reviewed', 'license_approved', 'active', 'private_bugs',
+    ['project_reviewed', 'license_approved', 'active', 'private_bugs',
      'reviewer_whiteboard']
 
 The reviewer cannot deactivate a project if it is linked
@@ -291,14 +291,14 @@
     >>> form = {
     ...     'field.active': 'on',
     ...     'field.reviewer_whiteboard': 'This is not a free license',
-    ...     'field.license_reviewed': 'on',
+    ...     'field.project_reviewed': 'on',
     ...     'field.actions.change': 'Change',
     ...     }
     >>> view = create_initialized_view(
     ...     firefox, name='+review-license', form=form)
     >>> view.errors
     []
-    >>> firefox.license_reviewed
+    >>> firefox.project_reviewed
     True
     >>> firefox.license_approved
     False
@@ -326,7 +326,7 @@
     ...     firefox, name='+review-license', form=form)
     >>> view.errors
     []
-    >>> firefox.license_reviewed
+    >>> firefox.project_reviewed
     True
     >>> firefox.license_approved
     True
@@ -354,7 +354,7 @@
     ...     firefox, name='+review-license', form=form)
     >>> view.errors
     []
-    >>> firefox.license_reviewed
+    >>> firefox.project_reviewed
     True
     >>> firefox.license_approved
     True

=== modified file 'lib/lp/registry/browser/tests/test_product.py'
--- lib/lp/registry/browser/tests/test_product.py	2011-03-23 15:42:28 +0000
+++ lib/lp/registry/browser/tests/test_product.py	2011-05-13 08:56:09 +0000
@@ -16,6 +16,7 @@
 from canonical.launchpad.testing.pages import (
     find_tag_by_id,
     )
+from canonical.launchpad.webapp.publisher import canonical_url
 from canonical.testing.layers import DatabaseFunctionalLayer
 from lp.app.enums import ServiceUsage
 from lp.registry.browser.product import (
@@ -26,6 +27,7 @@
     IProductSet,
     )
 from lp.testing import (
+    login_celebrity,
     login_person,
     person_logged_in,
     TestCaseWithFactory,
@@ -183,7 +185,7 @@
 
     def setUp(self):
         super(TestProductView, self).setUp()
-        self.product = self.factory.makeProduct()
+        self.product = self.factory.makeProduct(name='fnord')
 
     def test_show_programming_languages_without_languages(self):
         # show_programming_languages is false when there are no programming
@@ -222,3 +224,158 @@
         with person_logged_in(self.product.owner):
             self.product.licenses = [License.OTHER_PROPRIETARY]
         self.assertTrue(view.show_license_info)
+
+    def test_is_proprietary_with_proprietary_license(self):
+        # is_proprietary is true when the project has a proprietary license.
+        with person_logged_in(self.product.owner):
+            self.product.licenses = [License.OTHER_PROPRIETARY]
+        view = create_initialized_view(self.product, '+index')
+        self.assertTrue(view.is_proprietary)
+
+    def test_is_proprietary_without_proprietary_license(self):
+        # is_proprietary is false when the project has a proprietary license.
+        with person_logged_in(self.product.owner):
+            self.product.licenses = [License.GNU_GPL_V2]
+        view = create_initialized_view(self.product, '+index')
+        self.assertFalse(view.is_proprietary)
+
+    def test_active_widget(self):
+        # The active widget is is unique to the product.
+        view = create_initialized_view(self.product, '+index')
+        widget = view.active_widget
+        self.assertEqual('fnord-edit-active', widget.content_box_id)
+        self.assertEqual(
+            canonical_url(self.product, view_name='+review-license'),
+            widget.edit_url)
+
+    def test_project_reviewed_widget(self):
+        # The license reviewed widget is is unique to the product.
+        login_celebrity('registry_experts')
+        view = create_initialized_view(self.product, '+index')
+        widget = view.project_reviewed_widget
+        self.assertEqual('fnord-edit-project-reviewed', widget.content_box_id)
+        self.assertEqual(
+            canonical_url(self.product, view_name='+review-license'),
+            widget.edit_url)
+
+    def test_license_approved_widget_any_license(self):
+        # The license approved widget is is unique to the product.
+        login_celebrity('registry_experts')
+        view = create_initialized_view(self.product, '+index')
+        widget = view.license_approved_widget
+        self.assertEqual('fnord-edit-license-approved', widget.content_box_id)
+        self.assertEqual(
+            canonical_url(self.product, view_name='+review-license'),
+            widget.edit_url)
+
+    def test_license_approved_widget_prorietary_license(self):
+        # Proprietary projects cannot be approved.
+        with person_logged_in(self.product.owner):
+            self.product.licenses = [License.OTHER_PROPRIETARY]
+        login_celebrity('registry_experts')
+        view = create_initialized_view(self.product, '+index')
+        text = view.license_approved_widget
+        self.assertEqual('Commercial subscription required', text)
+
+    def test_license_approved_widget_no_license(self):
+        # Projects without a license cannot be approved.
+        with person_logged_in(self.product.owner):
+            self.product.licenses = [License.DONT_KNOW]
+        login_celebrity('registry_experts')
+        view = create_initialized_view(self.product, '+index')
+        text = view.license_approved_widget
+        self.assertEqual('License required', text)
+
+
+class ProductSetReviewLicensesViewTestCase(TestCaseWithFactory):
+    """Tests the ProductSetReviewLicensesView."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(ProductSetReviewLicensesViewTestCase, self).setUp()
+        self.product_set = getUtility(IProductSet)
+        self.user = login_celebrity('registry_experts')
+
+    def test_initial_values(self):
+        # The initial values show active, unreviewed, unapproved projects.
+        view = create_initialized_view(self.product_set, '+review-licenses')
+        self.assertContentEqual(
+            {'active': True,
+             'project_reviewed': False,
+             'license_approved': False,
+             'search_text': None,
+             'licenses': set(),
+             'has_zero_licenses': None,
+             'license_info_is_empty': None,
+             'created_after': None,
+             'created_before': None,
+             'subscription_expires_after': None,
+             'subscription_expires_before': None,
+             'subscription_modified_after': None,
+             'subscription_modified_before': None,
+             }.items(),
+            view.initial_values.items())
+
+    def test_forReviewBatched(self):
+        # The projects are batched.
+        view = create_initialized_view(self.product_set, '+review-licenses')
+        batch = view.forReviewBatched()
+        self.assertEqual(50, batch.default_size)
+
+    def test_project_common_data(self):
+        # The each project contains information to complete a review.
+        self.factory.makeProduct(name='fnord')
+        view = create_initialized_view(
+            self.product_set, '+review-licenses', principal=self.user)
+        content = find_tag_by_id(view.render(), 'project-fnord')
+        self.assertTrue(content.find(id='fnord-maintainer') is not None)
+        self.assertTrue(content.find(id='fnord-registrant') is not None)
+        self.assertTrue(content.find(id='fnord-desciption') is not None)
+        self.assertTrue(content.find(id='fnord-packages') is not None)
+        self.assertTrue(content.find(id='fnord-releases') is not None)
+        self.assertTrue(content.find(id='fnord-usage') is not None)
+        self.assertTrue(content.find(id='fnord-licenses') is not None)
+        self.assertTrue(content.find(id='fnord-whiteboard') is not None)
+        self.assertFalse(content.find(
+            id='fnord-commercial-subscription') is not None)
+        self.assertFalse(content.find(id='fnord-license-info') is not None)
+
+    def test_project_license_info_data(self):
+        # The projects with the OTHER_* licenese will show license_info data.
+        product = self.factory.makeProduct(name='fnord')
+        with person_logged_in(product.owner):
+            product.licenses = [License.OTHER_OPEN_SOURCE]
+        view = create_initialized_view(
+            self.product_set, '+review-licenses', principal=self.user)
+        content = find_tag_by_id(view.render(), 'project-fnord')
+        self.assertTrue(content.find(id='fnord-license-info') is not None)
+
+    def test_project_commercial_subscription_data(self):
+        # The projects with the OTHER_Proprietary license show commercial
+        # subscription information.
+        product = self.factory.makeProduct(name='fnord')
+        with person_logged_in(product.owner):
+            product.licenses = [License.OTHER_PROPRIETARY]
+        view = create_initialized_view(
+            self.product_set, '+review-licenses', principal=self.user)
+        content = find_tag_by_id(view.render(), 'project-fnord')
+        self.assertTrue(content.find(
+            id='fnord-commercial-subscription') is not None)
+
+    def test_project_widgets(self):
+        # The active, project_reviewed, and license_approved lazrjs widgets
+        # are used.
+        self.factory.makeProduct(name='fnord')
+        view = create_initialized_view(
+            self.product_set, '+review-licenses', principal=self.user)
+        content = find_tag_by_id(view.render(), 'fnord-statuses')
+        self.assertTrue(
+            'Y.lp.app.choice.addBinaryChoice' in str(
+                content.find(id='fnord-edit-active').parent))
+        self.assertTrue(
+            'Y.lp.app.choice.addBinaryChoice' in str(
+                content.find(id='fnord-edit-project-reviewed').parent))
+        self.assertTrue(
+            'Y.lp.app.choice.addBinaryChoice' in str(
+                content.find(id='fnord-edit-license-approved').parent))

=== modified file 'lib/lp/registry/configure.zcml'
--- lib/lp/registry/configure.zcml	2011-05-05 18:15:23 +0000
+++ lib/lp/registry/configure.zcml	2011-05-13 08:56:09 +0000
@@ -1237,7 +1237,7 @@
             attributes="
                 reviewer_whiteboard
                 is_permitted
-                license_reviewed
+                project_reviewed
                 license_approved"
             set_schema="lp.registry.interfaces.product.IProductModerateRestricted"
             set_attributes="active private_bugs "/>

=== modified file 'lib/lp/registry/doc/commercialsubscription.txt'
--- lib/lp/registry/doc/commercialsubscription.txt	2010-10-17 15:44:08 +0000
+++ lib/lp/registry/doc/commercialsubscription.txt	2011-05-13 08:56:09 +0000
@@ -7,7 +7,8 @@
 
     >>> from zope.component import getUtility
     >>> from canonical.launchpad.webapp.testing import verifyObject
-    >>> from lp.registry.interfaces.commercialsubscription import ICommercialSubscription
+    >>> from lp.registry.interfaces.commercialsubscription import (
+    ...     ICommercialSubscription)
     >>> from lp.registry.interfaces.product import IProductSet
     >>> from canonical.launchpad.ftests import login, ANONYMOUS
     >>> login('no-priv@xxxxxxxxxxxxx')
@@ -268,22 +269,22 @@
 included as one of the licenses.
 
     >>> bzr.license_info = 'bar'
-    >>> bzr.license_reviewed = True
+    >>> bzr.project_reviewed = True
     >>> bzr.license_approved = True
 
     >>> bzr.license_info
     u'bar'
-    >>> bzr.license_reviewed
+    >>> bzr.project_reviewed
     True
     >>> bzr.license_approved
     True
 
 Setting license_approved implies that the license has been reviewed,
-so license_reviewed is set automatically.
+so project_reviewed is set automatically.
 
-    >>> bzr.license_reviewed = False
+    >>> bzr.project_reviewed = False
     >>> bzr.license_approved = True
-    >>> bzr.license_reviewed
+    >>> bzr.project_reviewed
     True
 
 Set the bzr license to Other/Open Source and Other/Proprietary.  It
@@ -291,37 +292,38 @@
 subscription.
 
     >>> bzr.licenses = [License.OTHER_OPEN_SOURCE, License.OTHER_PROPRIETARY]
-    >>> bzr.license_reviewed = True
+    >>> bzr.project_reviewed = True
     >>> bzr.license_approved = True
     Traceback (most recent call last):
     ...
-    AssertionError: Projects with 'Other/Proprietary' licenses may not be...
+    ValueError: Projects without a license or have 'Other/Proprietary'
+    may not be approved.
 
 A project with an Other/Open Source license or additional license info that
 is reviewed, but not approved requires a commercial subscription.
 
     >>> bzr.licenses = [License.OTHER_OPEN_SOURCE]
-    >>> bzr.license_reviewed = True
+    >>> bzr.project_reviewed = True
     >>> bzr.qualifies_for_free_hosting
     False
 
 If the licenses or license_info attributes are changed, the
-license_reviewed and license_approved attributes are reset to false.
+project_reviewed and license_approved attributes are reset to false.
 
     >>> bzr.license_approved = True
-    >>> bzr.license_reviewed = True
+    >>> bzr.project_reviewed = True
     >>> bzr.license_info = 'foo'
     >>> bzr.license_approved
     False
-    >>> bzr.license_reviewed
+    >>> bzr.project_reviewed
     False
 
     >>> bzr.license_approved = True
-    >>> bzr.license_reviewed = True
+    >>> bzr.project_reviewed = True
     >>> bzr.licenses = [License.BSD]
     >>> bzr.license_approved
     False
-    >>> bzr.license_reviewed
+    >>> bzr.project_reviewed
     False
 
 When the products license is OTHER_OPEN_SOURCE or the license_info
@@ -331,7 +333,7 @@
 approval, but were not approved.
 
 However, qualifies_for_free_hosting remains true until
-it has been reviewed (license_reviewed is set to true). The
+it has been reviewed (project_reviewed is set to true). The
 OTHER_PROPRIETARY license does not need to be reviewed as do the
 OTHER_OPEN_SOURCE license or an unknown license in license_info.
 
@@ -341,7 +343,7 @@
     >>> bzr.qualifies_for_free_hosting, bzr.commercial_subscription_is_due
     (True, False)
 
-    >>> bzr.license_reviewed = True
+    >>> bzr.project_reviewed = True
     >>> bzr.qualifies_for_free_hosting, bzr.commercial_subscription_is_due
     (False, True)
 
@@ -350,7 +352,7 @@
     >>> bzr.qualifies_for_free_hosting, bzr.commercial_subscription_is_due
     (True, False)
 
-    >>> bzr.license_reviewed = True
+    >>> bzr.project_reviewed = True
     >>> bzr.qualifies_for_free_hosting, bzr.commercial_subscription_is_due
     (False, True)
 
@@ -391,7 +393,7 @@
 
 You can search for whether the product is marked reviewed or not.
 
-    >>> for product in product_set.forReview(license_reviewed=True):
+    >>> for product in product_set.forReview(project_reviewed=True):
     ...     print product.name
     python-gnome2-dev
     unassigned
@@ -451,7 +453,7 @@
 not approved
 
     >>> for product in product_set.forReview(
-    ...     license_reviewed=True, license_approved=False):
+    ...     project_reviewed=True, license_approved=False):
     ...     print product.name
     python-gnome2-dev
     unassigned

=== modified file 'lib/lp/registry/doc/launchpadlib/project-registry.txt.disabled'
--- lib/lp/registry/doc/launchpadlib/project-registry.txt.disabled	2010-04-23 20:41:06 +0000
+++ lib/lp/registry/doc/launchpadlib/project-registry.txt.disabled	2011-05-13 08:56:09 +0000
@@ -173,7 +173,7 @@
     is_permitted: ...redacted
     license_approved: ...redacted
     license_info: None
-    license_reviewed: ...redacted
+    project_reviewed: ...redacted
     licenses: []
     logo: <lazr.restfulclient.resource.HostedFile object...>
     name: firefox
@@ -204,7 +204,7 @@
     >>> print lp_mark.me.name
     mark
     >>> firefox = lp_mark.projects['firefox']
-    >>> print firefox.license_reviewed
+    >>> print firefox.project_reviewed
     False
 
 In Launchpad project names may not have uppercase letters in their

=== modified file 'lib/lp/registry/doc/product.txt'
--- lib/lp/registry/doc/product.txt	2010-11-04 22:44:46 +0000
+++ lib/lp/registry/doc/product.txt	2011-05-13 08:56:09 +0000
@@ -371,7 +371,7 @@
 
 A product's licenses is now being tracked. Previously registered products
 may not have a license set, so an empty tuple is valid. If the licenses
-or license_info attributes are changed, the license_reviewed is reset
+or license_info attributes are changed, the project_reviewed is reset
 to false.
 
     >>> login('admin@xxxxxxxxxxxxx')
@@ -379,15 +379,15 @@
     True
     >>> product.licenses
     (<...License.GNU_GPL_V2...>,)
-    >>> product.license_reviewed = True
+    >>> product.project_reviewed = True
     >>> product.licenses = (License.GNU_GPL_V2, License.GNU_LGPL_V2_1)
     >>> product.licenses
     (<...License.GNU_GPL_V2...>, <...License.GNU_LGPL_V2_1...>)
-    >>> product.license_reviewed
+    >>> product.project_reviewed
     False
-    >>> product.license_reviewed = True
+    >>> product.project_reviewed = True
     >>> product.license_info = 'foo'
-    >>> product.license_reviewed
+    >>> product.project_reviewed
     False
 
 This series is set as the development focus for the product:

=== modified file 'lib/lp/registry/interfaces/product.py'
--- lib/lp/registry/interfaces/product.py	2011-04-12 23:25:45 +0000
+++ lib/lp/registry/interfaces/product.py	2011-05-13 08:56:09 +0000
@@ -389,7 +389,7 @@
                 "hosting or the project has an up-to-date "
                 "subscription.")))
 
-    license_reviewed = exported(
+    project_reviewed = exported(
         Bool(
             title=_('Project reviewed'),
             description=_("Whether or not this project has been reviewed. "
@@ -398,7 +398,7 @@
 
     license_approved = exported(
         Bool(
-            title=_("Project approved"),
+            title=_("License approved"),
             description=_(
                 "The project is legitimate and its license appears valid. "
                 "Not applicable to 'Other/Proprietary'.")))
@@ -845,7 +845,7 @@
 
     def get_all_active(eager_load=True):
         """Get all active products.
-        
+
         :param eager_load: If False do not load related objects such as the
             owner.
         :return: An iterable of IProduct.
@@ -891,7 +891,7 @@
                    'project', 'homepageurl', 'screenshotsurl',
                    'downloadurl', 'freshmeatproject', 'wikiurl',
                    'sourceforgeproject', 'programminglang',
-                   'license_reviewed', 'licenses', 'license_info',
+                   'project_reviewed', 'licenses', 'license_info',
                    'registrant'])
     @export_operation_as('new_project')
     def createProduct(owner, name, displayname, title, summary,
@@ -899,7 +899,7 @@
                       screenshotsurl=None, wikiurl=None,
                       downloadurl=None, freshmeatproject=None,
                       sourceforgeproject=None, programminglang=None,
-                      license_reviewed=False, mugshot=None, logo=None,
+                      project_reviewed=False, mugshot=None, logo=None,
                       icon=None, licenses=None, license_info=None,
                       registrant=None):
         """Create and return a brand new Product.
@@ -910,8 +910,8 @@
     @operation_parameters(
         search_text=TextLine(title=_("Search text")),
         active=Bool(title=_("Is the project active")),
-        license_reviewed=Bool(title=_("Is the project license reviewed")),
-        licenses = Set(title=_('Licenses'),
+        project_reviewed=Bool(title=_("Is the project license reviewed")),
+        licenses=Set(title=_('Licenses'),
                        value_type=Choice(vocabulary=License)),
         license_info_is_empty=Bool(title=_("License info is empty")),
         has_zero_licenses=Bool(title=_("Has zero licenses")),
@@ -930,7 +930,7 @@
     @export_operation_as('licensing_search')
     def forReview(search_text=None,
                   active=None,
-                  license_reviewed=None,
+                  project_reviewed=None,
                   licenses=None,
                   license_info_is_empty=None,
                   has_zero_licenses=None,
@@ -1052,7 +1052,7 @@
         title=_('Active'), values=[True, False],
         required=False, default=True)
 
-    license_reviewed = Choice(
+    project_reviewed = Choice(
         title=_('Project Reviewed'), values=[True, False],
         required=False, default=False)
 

=== modified file 'lib/lp/registry/model/product.py'
--- lib/lp/registry/model/product.py	2011-04-26 16:25:00 +0000
+++ lib/lp/registry/model/product.py	2011-05-13 08:56:09 +0000
@@ -188,7 +188,7 @@
 from lp.translations.model.translationpolicy import TranslationPolicyMixin
 
 
-def get_license_status(license_approved, license_reviewed, licenses):
+def get_license_status(license_approved, project_reviewed, licenses):
     """Decide the license status for an `IProduct`.
 
     :return: A LicenseStatus enum value.
@@ -206,7 +206,7 @@
         # Notice the difference between the License and LicenseStatus.
         return LicenseStatus.PROPRIETARY
     elif License.OTHER_OPEN_SOURCE in licenses:
-        if license_reviewed:
+        if project_reviewed:
             # The OTHER_OPEN_SOURCE license was not manually approved
             # by setting license_approved to true.
             return LicenseStatus.PROPRIETARY
@@ -254,7 +254,7 @@
         """
         naked_product = removeSecurityProxy(self.product)
         return get_license_status(
-            naked_product.license_approved, naked_product.license_reviewed,
+            naked_product.license_approved, naked_product.project_reviewed,
             self.licenses)
 
     @classmethod
@@ -447,7 +447,7 @@
 
     enable_bug_expiration = BoolCol(dbName='enable_bug_expiration',
         notNull=True, default=False)
-    license_reviewed = BoolCol(dbName='reviewed', notNull=True, default=False)
+    project_reviewed = BoolCol(dbName='reviewed', notNull=True, default=False)
     reviewer_whiteboard = StringCol(notNull=False, default=None)
     private_bugs = BoolCol(
         dbName='private_bugs', notNull=True, default=False)
@@ -478,7 +478,7 @@
 
     def _validate_license_info(self, attr, value):
         if not self._SO_creating and value != self.license_info:
-            # Clear the license_reviewed and license_approved flags
+            # Clear the project_reviewed and license_approved flags
             # if the license changes.
             self._resetLicenseReview()
         return value
@@ -489,14 +489,16 @@
     def _validate_license_approved(self, attr, value):
         """Ensure license approved is only applied to the correct licenses."""
         if not self._SO_creating:
-            licenses = self.licenses
+            licenses = list(self.licenses)
             if value:
-                assert License.OTHER_PROPRIETARY not in licenses, (
-                    "Projects with 'Other/Proprietary' licenses may not be "
-                    "marked as license_approved.")
+                if (License.OTHER_PROPRIETARY in licenses
+                    or [License.DONT_KNOW] == licenses):
+                    raise ValueError(
+                        "Projects without a license or have "
+                        "'Other/Proprietary' may not be approved.")
                 # Approving a license implies it has been reviewed.  Force
-                # `license_reviewed` to be True.
-                self.license_reviewed = True
+                # `project_reviewed` to be True.
+                self.project_reviewed = True
         return value
 
     license_approved = BoolCol(dbName='license_approved',
@@ -577,7 +579,7 @@
             # Proprietary licenses need a subscription without
             # waiting for a review.
             return False
-        elif (self.license_reviewed and
+        elif (self.project_reviewed and
               (License.OTHER_OPEN_SOURCE in self.licenses or
                self.license_info not in ('', None))):
             # We only know that an unknown open source license
@@ -635,11 +637,11 @@
         :return: A LicenseStatus enum value.
         """
         return get_license_status(
-            self.license_approved, self.license_reviewed, self.licenses)
+            self.license_approved, self.project_reviewed, self.licenses)
 
     def _resetLicenseReview(self):
         """When the license is modified, it must be reviewed again."""
-        self.license_reviewed = False
+        self.project_reviewed = False
         self.license_approved = False
 
     def _get_answers_usage(self):
@@ -714,7 +716,7 @@
     def _getLicenses(self):
         return self._cached_licenses
 
-    def _setLicenses(self, licenses, reset_license_reviewed=True):
+    def _setLicenses(self, licenses, reset_project_reviewed=True):
         """Set the licenses from a tuple of license enums.
 
         The licenses parameter must not be an empty tuple.
@@ -723,12 +725,12 @@
         old_licenses = set(self.licenses)
         if licenses == old_licenses:
             return
-        # Clear the license_reviewed and license_approved flags
+        # Clear the project_reviewed and license_approved flags
         # if the license changes.
-        # ProductSet.createProduct() passes in reset_license_reviewed=False
+        # ProductSet.createProduct() passes in reset_project_reviewed=False
         # to avoid changing the value when a Launchpad Admin sets
-        # license_reviewed & licenses at the same time.
-        if reset_license_reviewed:
+        # project_reviewed & licenses at the same time.
+        if reset_project_reviewed:
             self._resetLicenseReview()
         # $product/+edit doesn't require a license if a license hasn't
         # already been set, but updateContextFromData() updates all the
@@ -1428,7 +1430,7 @@
                       screenshotsurl=None, wikiurl=None,
                       downloadurl=None, freshmeatproject=None,
                       sourceforgeproject=None, programminglang=None,
-                      license_reviewed=False, mugshot=None, logo=None,
+                      project_reviewed=False, mugshot=None, logo=None,
                       icon=None, licenses=None, license_info=None,
                       registrant=None):
         """See `IProductSet`."""
@@ -1444,11 +1446,11 @@
             downloadurl=downloadurl, freshmeatproject=freshmeatproject,
             sourceforgeproject=sourceforgeproject,
             programminglang=programminglang,
-            license_reviewed=license_reviewed,
+            project_reviewed=project_reviewed,
             icon=icon, logo=logo, mugshot=mugshot, license_info=license_info)
 
         if len(licenses) > 0:
-            product._setLicenses(licenses, reset_license_reviewed=False)
+            product._setLicenses(licenses, reset_project_reviewed=False)
 
         # Create a default trunk series and set it as the development focus
         trunk = product.newSeries(
@@ -1461,7 +1463,7 @@
         return product
 
     def forReview(self, search_text=None, active=None,
-                  license_reviewed=None, license_approved=None, licenses=None,
+                  project_reviewed=None, license_approved=None, licenses=None,
                   license_info_is_empty=None,
                   has_zero_licenses=None,
                   created_after=None, created_before=None,
@@ -1473,8 +1475,8 @@
 
         conditions = []
 
-        if license_reviewed is not None:
-            conditions.append(Product.license_reviewed == license_reviewed)
+        if project_reviewed is not None:
+            conditions.append(Product.project_reviewed == project_reviewed)
 
         if license_approved is not None:
             conditions.append(Product.license_approved == license_approved)

=== modified file 'lib/lp/registry/stories/product/xx-product-index.txt'
--- lib/lp/registry/stories/product/xx-product-index.txt	2011-03-23 16:28:51 +0000
+++ lib/lp/registry/stories/product/xx-product-index.txt	2011-05-13 08:56:09 +0000
@@ -180,7 +180,7 @@
 being shown as proprietary.
 
     >>> admin_browser.open('http://launchpad.dev/thunderbird/+review-license')
-    >>> admin_browser.getControl(name='field.license_reviewed').value = True
+    >>> admin_browser.getControl(name='field.project_reviewed').value = True
     >>> admin_browser.getControl(name='field.license_approved').value = False
     >>> admin_browser.getControl('Change').click()
 

=== modified file 'lib/lp/registry/stories/product/xx-productset.txt'
--- lib/lp/registry/stories/product/xx-productset.txt	2010-09-28 02:39:25 +0000
+++ lib/lp/registry/stories/product/xx-productset.txt	2011-05-13 08:56:09 +0000
@@ -38,79 +38,6 @@
     Review projects...
 
 
-Searching from +review_licenses
-===============================
-
-The search parameters that filter on a boolean value can
-be left unspecified to get results with either value.
-
-    >>> for name in ('active', 'license_reviewed', 'has_zero_licenses',
-    ...              'license_info_is_empty'):
-    ...     control = registry_browser.getControl(name='field.%s' % name)
-    ...     for option in control.displayOptions:
-    ...         print option.decode('utf-8').encode('ascii', 'replace'),
-    ...     print
-    ?(do not filter) ?True ?False
-    ?(do not filter) ?True ?False
-    ?(do not filter) ?True ?False
-    ?(do not filter) ?Empty ?Not Empty
-
-The default form values will search for active projects that have not
-been reviewed.
-
-    >>> registry_browser.getControl(name='field.active').value
-    ['True']
-    >>> registry_browser.getControl(name='field.license_reviewed').value
-    ['False']
-    >>> registry_browser.getControl(name='field.licenses').value
-    []
-
-If there are no products that match the search conditions, a warning
-message will be displayed.
-
-    >>> registry_browser.getControl(name='field.active').value = ['False']
-    >>> registry_browser.getControl(name='search').click()
-    >>> find_tags_by_class(registry_browser.contents, 'warning message')
-    [<div...No items found with these search options...
-
-If enough projects are found, the results are paginated. A link
-is provided for each product pointint to its +review-license page.
-
-    >>> registry_browser.getControl(name='field.active').value = ['True']
-    >>> registry_browser.getControl(name='field.licenses').value = []
-    >>> registry_browser.getControl(
-    ...     name='field.license_info_is_empty').value = ['Empty']
-    >>> registry_browser.getControl(name='field.created_before').value = (
-    ...     '2007-01-01')
-    >>> registry_browser.getControl(name='search').click()
-    >>> pagination = find_tags_by_class(registry_browser.contents,
-    ...                                 'batch-navigation-index')
-    >>> text_in_elements = pagination[0].findAll(text=True)
-    >>> print ' '.join(text.strip() for text in text_in_elements)
-    1 ... 14 of 14 results
-    >>> table = find_tag_by_id(registry_browser.contents, 'product-listing')
-    >>> for tr in table.findAll('tr'):
-    ...     td_list = tr.findAll('td')
-    ...     print td_list[5].a
-    <a href="/tomcat/+review-license">Review</a> ...
-
-Now, we can click on the Review link, which will take us to the
-$project/+review-license page. Then clicking on the Change button
-will take us back to the page we came from.
-
-    >>> projects_link = registry_browser.url
-    >>> review_link = registry_browser.getLink(
-    ...     url='/tomcat/+review-license')
-    >>> review_link.text
-    'Review'
-    >>> review_link.click()
-    >>> print registry_browser.url
-    http://launchpad.dev/tomcat/+review-license
-    >>> registry_browser.getControl(name='field.actions.change').click()
-    >>> print registry_browser.url == projects_link
-    True
-
-
 View all projects
 =================
 

=== modified file 'lib/lp/registry/stories/webservice/xx-project-registry.txt'
--- lib/lp/registry/stories/webservice/xx-project-registry.txt	2011-05-05 05:46:41 +0000
+++ lib/lp/registry/stories/webservice/xx-project-registry.txt	2011-05-13 08:56:09 +0000
@@ -171,7 +171,6 @@
     is_permitted: False
     license_approved: False
     license_info: None
-    license_reviewed: False
     licenses: []
     logo_link: u'http://.../firefox/logo'
     name: u'firefox'
@@ -179,6 +178,7 @@
     owner_link: u'http://.../~name12'
     programming_language: None
     project_group_link: u'http://.../mozilla'
+    project_reviewed: False
     qualifies_for_free_hosting: False
     recipes_collection_link: u'http://.../firefox/recipes'
     registrant_link: u'http://.../~name12'
@@ -236,7 +236,6 @@
     is_permitted:...redacted...
     license_approved:...redacted...
     license_info: None
-    license_reviewed:...redacted...
     licenses: []
     logo_link: u'http://.../firefox/logo'
     name: u'firefox'
@@ -244,6 +243,7 @@
     owner_link: u'http://.../~name12'
     programming_language: None
     project_group_link: u'http://.../mozilla'
+    project_reviewed:...redacted...
     qualifies_for_free_hosting: False
     recipes_collection_link: u'http://.../firefox/recipes'
     registrant_link: u'http://.../~name12'
@@ -623,7 +623,7 @@
 
     >>> unreviewed = webservice.named_get(
     ...     "/projects", "licensing_search",
-    ...     license_reviewed=False).jsonBody()
+    ...     project_reviewed=False).jsonBody()
 
     >>> entries = sorted(
     ...    unreviewed['entries'], key=itemgetter('display_name'))
@@ -636,7 +636,7 @@
 
     >>> reviewed = webservice.named_get(
     ...     "/projects", "licensing_search",
-    ...     license_reviewed=True).jsonBody()
+    ...     project_reviewed=True).jsonBody()
 
     >>> entries = sorted(
     ...    reviewed['entries'], key=itemgetter('display_name'))
@@ -717,7 +717,7 @@
     ...                    download_url=None, freshmeat_project=None,
     ...                    sourceforge_project=None, programming_lang=None,
     ...                    licenses=(), license_info=None,
-    ...                    license_reviewed=False,
+    ...                    project_reviewed=False,
     ...                    registrant=None):
     ...     return webservice.named_post(
     ...         "/projects", "new_project",
@@ -729,7 +729,7 @@
     ...         sourceforge_project=sourceforge_project,
     ...         programming_lang=programming_lang,
     ...         licenses=licenses, license_info=license_info,
-    ...         license_reviewed=license_reviewed,
+    ...         project_reviewed=project_reviewed,
     ...         registrant=registrant)
 
 Verify a project does not exist and then create it.
@@ -764,7 +764,7 @@
     >>> print sorted(new_project['licenses'])
     [u'GNU GPL v2', u'Zope Public License']
 
-    >>> print new_project['license_reviewed']
+    >>> print new_project['project_reviewed']
     False
 
     >>> print new_project['homepage_url']

=== modified file 'lib/lp/registry/templates/product-listing-for-review.pt'
--- lib/lp/registry/templates/product-listing-for-review.pt	2009-07-17 17:59:07 +0000
+++ lib/lp/registry/templates/product-listing-for-review.pt	2011-05-13 08:56:09 +0000
@@ -4,45 +4,107 @@
   xmlns:i18n="http://xml.zope.org/namespaces/i18n";
   omit-tag="">
 
-  <tr>
-    <td style="white-space: nowrap"
-        tal:content="structure context/fmt:link" />
-    <tal:XXX condition="nothing">
-     # XXX: Edwin Grubbs 2008-06-17 bug=240759
-     # The @@/yes and @@/no img tags could be handled by a tales formatter.
-    </tal:XXX>
-    <td style="text-align: center">
-      <img src="/@@/yes" tal:condition="context/active"/>
-      <img src="/@@/no" tal:condition="not: context/active"/>
-    </td>
-    <td style="text-align: center">
-      <img src="/@@/yes" tal:condition="context/license_reviewed"/>
-      <img src="/@@/no" tal:condition="not: context/license_reviewed"/>
-    </td>
-    <td style="text-align: center">
-      <img src="/@@/yes" tal:condition="context/license_approved"/>
-      <img src="/@@/no" tal:condition="not: context/license_approved"/>
-    </td>
-    <td style="white-space: nowrap">
-      <a tal:replace="structure context/owner/fmt:link">Foo Bar</a>
-      <span
-        tal:attributes="title context/datecreated/fmt:datetime"
-        tal:content="context/datecreated/fmt:displaydate">2005-10-12</span>
-    </td>
-    <td style="white-space: nowrap">
-      <span tal:condition="not: context/licenses">None<br/></span>
-      <tal:loop repeat="license context/licenses">
-        <tal:title tal:replace="license/title"/><br/>
-      </tal:loop>
-      (<a tal:attributes="href string:${context/fmt:url}/+review-license"
-        >Review</a>)
-    </td>
-    <td tal:content="structure context/license_info/fmt:shorten/80/fmt:text-to-html">
-        License info goes here
-    </td>
-    <td tal:condition="context/commercial_subscription"
-        tal:content="context/commercial_subscription/date_expires/fmt:date" />
-    <td tal:condition="not: context/commercial_subscription" />
-    <td tal:content="context/reviewer_whiteboard/fmt:shorten/80" />
+  <tr tal:attributes="id string:project-${context/name}">
+    <td>
+      <p>
+        <a tal:replace="structure context/fmt:link" />
+        (<span tal:replace="context/name" />)
+        registered
+        <span
+          tal:attributes="title context/datecreated/fmt:datetime"
+          tal:content="context/datecreated/fmt:displaydate">2005-10-12</span>
+      </p>
+
+      <dl class="horizontal">
+        <dt>Maintained by</dt>
+        <dd tal:attributes="id string:${context/name}-maintainer">
+          <a tal:replace="structure context/owner/fmt:link" />
+        </dd>
+        <dt>Registered by</dt>
+        <dd tal:attributes="id string:${context/name}-registrant">
+          <a tal:replace="structure context/registrant/fmt:link" />
+        </dd>
+      </dl>
+
+      <p
+        title="Beware of these words: test, prueba, personal, sandbox, repo, package, archive" 
+        tal:content="context/summary" />
+
+      <div
+        title="Beware of these words: test, prueba, personal, sandbox, repo, package, archive"
+        tal:condition="context/description"
+        tal:attributes="id string:${context/name}-desciption"
+        tal:content="structure context/description/fmt:text-to-html" />
+
+      <dl class="horizontal"
+        title="Projects with packages and releases cannot be deactivated.">
+        <dt>Has packages</dt>
+        <dd
+          tal:attributes="id string:${context/name}-packages"
+          tal:content="structure context/ubuntu_packages/image:boolean" />
+        <dt>Has releases</dt>
+        <dd
+          tal:attributes="id string:${context/name}-releases"
+          tal:content="structure view/latest_release_with_download_files/image:boolean" />
+      </dl>
+
+      <dl class="horizontal"
+        title="Projects over 6 months old should be using an application."
+        tal:attributes="id string:${context/name}-usage">
+        <dt>Code usage</dt>
+        <dd tal:content="context/codehosting_usage/title" />
+        <dt>Bug usage</dt>
+        <dd tal:content="context/bug_tracking_usage/title" />
+        <dt>Translations usage</dt>
+        <dd tal:content="context/translations_usage/title" />
+      </dl>
+    </td>
+
+    <td>
+      <dl tal:attributes="id string:${context/name}-statuses">
+        <dt>Project enabled</dt>
+        <dd tal:content="structure view/active_widget" />
+        <dt>Project reviewed</dt>
+        <dd tal:content="structure view/project_reviewed_widget" />
+        <dt>License approved</dt>
+        <dd title="Approving a license also marks the project reviewed."
+          tal:content="structure view/license_approved_widget" />
+      </dl>
+
+      <dl class="horizontal"
+        tal:attributes="id string:${context/name}-licenses">
+        <dt>Licenses</dt>
+        <dd>
+          <ul class="horizontal">
+            <li tal:condition="not: context/licenses">None</li>
+            <li tal:repeat="license context/licenses">
+              <tal:title tal:replace="license/title"/>
+            </li>
+          </ul>
+        </dd>
+      </dl>
+
+      <dl class="horizontal"
+        tal:condition="view/is_proprietary"
+        tal:attributes="id string:${context/name}-commercial-subscription">
+        <dt>Commercial subscription expiration</dt>
+        <dd
+          tal:condition="context/commercial_subscription"
+          tal:content="context/commercial_subscription/date_expires/fmt:date" />
+        <dd
+          tal:condition="not: context/commercial_subscription">None</dd>
+      </dl>
+
+      <dl class="horizontal"
+        tal:condition="view/show_license_info"
+        tal:attributes="id string:${context/name}-license-info">
+        <dt>Licenses info</dt>
+        <dd tal:content="structure context/license_info/fmt:text-to-html" />
+      </dl>
+
+      <div
+        tal:attributes="id string:${context/name}-whiteboard"
+        tal:content="context/reviewer_whiteboard/fmt:text-to-html" />
+    </td>
   </tr>
 </tal:root>

=== modified file 'lib/lp/registry/templates/product-review-license.pt'
--- lib/lp/registry/templates/product-review-license.pt	2009-10-08 15:54:09 +0000
+++ lib/lp/registry/templates/product-review-license.pt	2011-05-13 08:56:09 +0000
@@ -13,50 +13,31 @@
   <div class="top-portlet">
     <div metal:use-macro="context/@@launchpad_form/form">
       <div metal:fill-slot="extra_info" class="application-summary" >
-        <h2>Granting approval</h2>
-
-        <ul class="bulleted">
-          <li>Does the project use any Launchpad services, such as code or
-              bugs?  Mark the <strong>project approved</strong>
-              and <strong>reviewed</strong> now.
-          </li>
-          <li>Are the project's licenses simple and obviously open source
-              and/or free software?  Does the project smell legitimate?  Mark
-              the <strong>project approved</strong>
-              and <strong>reviewed</strong> now.
-          </li>
-          <li>Does the project appear legitimate but incomplete?  That's fine,
-              mark the <strong>project approved</strong>
-              and <strong>reviewed</strong> now.  Consider also sending a
-              request to the
-              owner asking them to update the project with more details, so as
-              to help recruit contributors.
-          </li>
-        </ul>
-
-        <h2>Withholding approval</h2>
-        <p>
-          There are a few reasons you may want to withhold approval.  You
-          should always contact the project owner for clarification if you do
-          not grant approval.
-        </p>
-        <ul class="bulleted">
-          <li>The project has no license.</li>
+        <p>
+          If you have read the project's essential information, mark it
+          Approved, or if there are issues, just Reviewed.
+        </p>
+
+        <p>
+          You may need to without approval for these common reasons:
+        </p>
+
+        <ul class="bulleted">
+          <li>The project license is only "I Don't Know".</li>
           <li>The project has only <strong>Other/Open source</strong> selected
-              as a license and the license info field does not contain a URL
-              to a license, or contains the whole license.
+              as a license and the license info field does not contain a link
+              to the license, or contains the whole license.
           </li>
           <li>The <strong>Other/Open source</strong> license appears to
               discriminate against who can use the project, or how the
               project's work can be used.
           </li>
-          <li>The project details are too vague to know if it is legitimate.
-          </li>
+          <li>The project is <strong>Other/Proprietary</strong></li>
         </ul>
 
         <p>
           <strong>If the project looks like a test, a prank, or spam,
-          deactivate it.</strong>
+          deactivate it.</strong>.
         </p>
 
       <input type="hidden" name="next_url"

=== modified file 'lib/lp/registry/templates/products-review-licenses.pt'
--- lib/lp/registry/templates/products-review-licenses.pt	2010-10-10 21:54:16 +0000
+++ lib/lp/registry/templates/products-review-licenses.pt	2011-05-13 08:56:09 +0000
@@ -11,6 +11,22 @@
   <metal:block fill-slot="head_epilogue">
     <metal:yui-dependencies
       use-macro="context/@@launchpad_widget_macros/yui2calendar-dependencies" />
+    <style type="text/css">
+      dl.horizontal dt {
+          display: inline;
+          margin-right: .5em;
+          }
+      dl.horizontal dd {
+           display: inline;
+           margin-right: 2em;
+           }
+      dl.horizontal dd ul.horizontal {
+           display: inline;
+           }
+      dl + p {
+            margin-top: 1em;
+            }
+    </style>
   </metal:block>
 
 <div metal:fill-slot="main"
@@ -23,15 +39,8 @@
     <tal:navigation replace="structure batch/@@+navigation-links-upper" />
     <table id="product-listing" class="listing">
       <thead>
-        <th>Project</th>
-        <th>Active</th>
-        <th>Reviewed</th>
-        <th>License<br />approved</th>
-        <th>Registered by</th>
-        <th>Licenses</th>
-        <th style="width: 40%">License info</th>
-        <th>Commercial<br />Subscription<br />expiration</th>
-        <th style="width: 40%">Reviewer<br />whiteboard</th>
+        <th style="max-width: 45em">Project information</th>
+        <th>Status and Licenses</th>
       </thead>
       <tbody>
         <div tal:repeat="product batch/currentBatch"

=== modified file 'lib/lp/soyuz/browser/archive.py'
--- lib/lp/soyuz/browser/archive.py	2011-05-05 13:01:50 +0000
+++ lib/lp/soyuz/browser/archive.py	2011-05-13 08:56:09 +0000
@@ -108,6 +108,7 @@
 from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet
 from lp.services.browser_helpers import get_user_agent_distroseries
 from lp.services.database.bulk import load
+from lp.services.features import getFeatureFlag
 from lp.services.propertycache import cachedproperty
 from lp.services.worlddata.interfaces.country import ICountrySet
 from lp.soyuz.adapters.archivedependencies import (
@@ -144,6 +145,7 @@
 from lp.soyuz.interfaces.binarypackagebuild import BuildSetStatus
 from lp.soyuz.interfaces.binarypackagename import IBinaryPackageNameSet
 from lp.soyuz.interfaces.component import IComponentSet
+from lp.soyuz.interfaces.packagecopyjob import IPlainPackageCopyJobSource
 from lp.soyuz.interfaces.packagecopyrequest import IPackageCopyRequestSet
 from lp.soyuz.interfaces.packageset import IPackagesetSet
 from lp.soyuz.interfaces.processor import IProcessorFamilySet
@@ -158,7 +160,16 @@
     BinaryPackagePublishingHistory,
     SourcePackagePublishingHistory,
     )
-from lp.soyuz.scripts.packagecopier import do_copy
+from lp.soyuz.scripts.packagecopier import (
+    check_copy_permissions,
+    do_copy,
+    )
+
+# Feature flag: up to how many package sync requests (inclusive) are to be
+# processed synchronously within the web request?
+# Set to -1 to disable synchronous syncs.
+FEATURE_FLAG_MAX_SYNCHRONOUS_SYNCS = (
+    'soyuz.derived_series.max_synchronous_syncs')
 
 
 class ArchiveBadges(HasBadgeBase):
@@ -1214,15 +1225,163 @@
     _messageNoValue = _("vocabulary-copy-to-same-series", "The same series")
 
 
+def preload_binary_package_names(copies):
+    """Preload `BinaryPackageName`s to speed up display-name construction."""
+    bpn_ids = [
+        copy.binarypackagerelease.binarypackagenameID for copy in copies
+        if isinstance(copy, BinaryPackagePublishingHistory)]
+    load(BinaryPackageName, bpn_ids)
+
+
+def compose_synchronous_copy_feedback(copies, dest_archive, dest_url=None,
+                                      dest_display_name=None):
+    """Compose human-readable feedback after a synchronous copy."""
+    if dest_url is None:
+        dest_url = escape(
+            canonical_url(dest_archive) + '/+packages', quote=True)
+
+    if dest_display_name is None:
+        dest_display_name = escape(dest_archive.displayname)
+
+    if len(copies) == 0:
+        return structured(
+            '<p>All packages already copied to <a href="%s">%s</a>.</p>'
+            % (dest_url, dest_display_name))
+    else:
+        messages = []
+        messages.append(
+            '<p>Packages copied to <a href="%s">%s</a>:</p>'
+            % (dest_url, dest_display_name))
+        messages.append('<ul>')
+        messages.append("\n".join([
+            '<li>%s</li>' % escape(copy) for copy in copies]))
+        messages.append('</ul>')
+        return structured("\n".join(messages))
+
+
+def copy_synchronously(source_pubs, dest_archive, dest_series, dest_pocket,
+                       include_binaries, dest_url=None,
+                       dest_display_name=None, person=None,
+                       check_permissions=True):
+    """Copy packages right now.
+
+    :return: A `structured` with human-readable feedback about the
+        operation.
+    :raises CannotCopy: If `check_permissions` is True and the copy is
+        not permitted.
+    """
+    copies = do_copy(
+        source_pubs, dest_archive, dest_series, dest_pocket, include_binaries,
+        allow_delayed_copies=True, person=person,
+        check_permissions=check_permissions)
+
+    preload_binary_package_names(copies)
+
+    return compose_synchronous_copy_feedback(
+        [copy.displayname for copy in copies], dest_archive, dest_url,
+        dest_display_name)
+
+
+def partition_pubs_by_archive(source_pubs):
+    """Group `source_pubs` by archive.
+
+    :param source_pubs: A sequence of `SourcePackagePublishingHistory`.
+    :return: A dict mapping `Archive`s to the list of entries from
+        `source_pubs` that are in that archive.
+    """
+    by_source_archive = {}
+    for spph in source_pubs:
+        by_source_archive.setdefault(spph.archive, []).append(spph)
+    return by_source_archive
+
+
+def name_pubs_with_versions(source_pubs):
+    """Annotate each entry from `source_pubs` with its version.
+
+    :param source_pubs: A sequence of `SourcePackagePublishingHistory`.
+    :return: A list of tuples (name, version), one for each respective
+        entry in `source_pubs`.
+    """
+    sprs = [spph.sourcepackagerelease for spph in source_pubs]
+    return [(spr.sourcepackagename.name, spr.version) for spr in sprs]
+
+
+def copy_asynchronously(source_pubs, dest_archive, dest_series, dest_pocket,
+                        include_binaries, dest_url=None,
+                        dest_display_name=None, person=None,
+                        check_permissions=True):
+    """Schedule jobs to copy packages later.
+
+    :return: A `structured` with human-readable feedback about the
+        operation.
+    :raises CannotCopy: If `check_permissions` is True and the copy is
+        not permitted.
+    """
+    if check_permissions:
+        spns = [
+            spph.sourcepackagerelease.sourcepackagename
+            for spph in source_pubs]
+        check_copy_permissions(
+            person, dest_archive, dest_series, dest_pocket, [spns])
+
+    job_source = getUtility(IPlainPackageCopyJobSource)
+    archive_pubs = partition_pubs_by_archive(source_pubs)
+    for source_archive, spphs in archive_pubs.iteritems():
+        job_source.create(
+            name_pubs_with_versions(spphs), source_archive, dest_archive,
+            dest_series, dest_pocket, include_binaries=include_binaries)
+    return structured("""
+        <p>Requested sync of %s packages.</p>
+        <p>Please allow some time for these to be processed.</p>
+        """, len(source_pubs))
+
+
+def render_cannotcopy_as_html(cannotcopy_exception):
+    """Render `CannotCopy` exception as HTML for display in the page."""
+    error_lines = str(cannotcopy_exception).splitlines()
+
+    if len(error_lines) == 1:
+        intro = "The following source cannot be copied:"
+    else:
+        intro = "The following sources cannot be copied:"
+
+    # Produce structured HTML.  Include <li>%s</li> placeholders for
+    # each error line, but have "structured" interpolate the actual
+    # package names.  It will escape them as needed.
+    html_text = """
+        <p>%s</p>
+        <ul>
+        %s
+        </ul>
+        """ % (intro, "<li>%s</li>" * len(error_lines))
+    return structured(html_text, *error_lines)
+
+
 class PackageCopyingMixin:
     """A mixin class that adds helpers for package copying."""
 
+    def canCopySynchronously(self, source_pubs):
+        """Can we afford to copy `source_pubs` synchronously?"""
+        # Fixed estimate: up to 100 packages can be copied in acceptable
+        # time.  Anything more than that and we go async.
+        limit = getFeatureFlag(FEATURE_FLAG_MAX_SYNCHRONOUS_SYNCS)
+        try:
+            limit = int(limit)
+        except:
+            limit = 100
+
+        return len(source_pubs) <= limit
+
     def do_copy(self, sources_field_name, source_pubs, dest_archive,
                 dest_series, dest_pocket, include_binaries,
                 dest_url=None, dest_display_name=None, person=None,
                 check_permissions=True):
         """Copy packages and add appropriate feedback to the browser page.
 
+        This may either copy synchronously, if there are few enough
+        requests to process right now; or asynchronously in which case
+        it will schedule jobs that will be processed by a script.
+
         :param sources_field_name: The name of the form field to set errors
             on when the copy fails
         :param source_pubs: A list of SourcePackagePublishingHistory to copy
@@ -1244,61 +1403,24 @@
         :return: True if the copying worked, False otherwise.
         """
         try:
-            copies = do_copy(
-                source_pubs, dest_archive, dest_series,
-                dest_pocket, include_binaries, allow_delayed_copies=True,
-                person=person, check_permissions=check_permissions)
+            if self.canCopySynchronously(source_pubs):
+                notification = copy_synchronously(
+                    source_pubs, dest_archive, dest_series, dest_pocket,
+                    include_binaries, dest_url=dest_url,
+                    dest_display_name=dest_display_name, person=person,
+                    check_permissions=check_permissions)
+            else:
+                notification = copy_asynchronously(
+                    source_pubs, dest_archive, dest_series, dest_pocket,
+                    include_binaries, dest_url=dest_url,
+                    dest_display_name=dest_display_name, person=person,
+                    check_permissions=check_permissions)
         except CannotCopy, error:
-            messages = []
-            error_lines = str(error).splitlines()
-            if len(error_lines) == 1:
-                messages.append(
-                    "<p>The following source cannot be copied:</p>")
-            else:
-                messages.append(
-                    "<p>The following sources cannot be copied:</p>")
-            messages.append('<ul>')
-            messages.append(
-                "\n".join('<li>%s</li>' % escape(line)
-                    for line in error_lines))
-            messages.append('</ul>')
-
             self.setFieldError(
-                sources_field_name, structured('\n'.join(messages)))
+                sources_field_name, render_cannotcopy_as_html(error))
             return False
 
-        # Preload BPNs to save queries when calculating display names.
-        load(BinaryPackageName, (
-            copy.binarypackagerelease.binarypackagenameID for copy in copies
-            if isinstance(copy, BinaryPackagePublishingHistory)))
-
-        # Present a page notification describing the action.
-        messages = []
-        if dest_url is None:
-            dest_url = escape(
-                canonical_url(dest_archive) + '/+packages',
-                quote=True)
-        if dest_display_name is None:
-            dest_display_name = escape(dest_archive.displayname)
-        if len(copies) == 0:
-            messages.append(
-                '<p>All packages already copied to '
-                '<a href="%s">%s</a>.</p>' % (
-                    dest_url,
-                    dest_display_name))
-        else:
-            messages.append(
-                '<p>Packages copied to <a href="%s">%s</a>:</p>' % (
-                    dest_url,
-                    dest_display_name))
-            messages.append('<ul>')
-            messages.append(
-                "\n".join(['<li>%s</li>' % escape(copy.displayname)
-                           for copy in copies]))
-            messages.append('</ul>')
-
-        notification = "\n".join(messages)
-        self.request.response.addNotification(structured(notification))
+        self.request.response.addNotification(notification)
         return True
 
 

=== modified file 'lib/lp/soyuz/browser/tests/test_archive_packages.py'
--- lib/lp/soyuz/browser/tests/test_archive_packages.py	2011-03-29 00:11:57 +0000
+++ lib/lp/soyuz/browser/tests/test_archive_packages.py	2011-05-13 08:56:09 +0000
@@ -1,4 +1,4 @@
-# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2011 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 # pylint: disable-msg=F0401
@@ -16,7 +16,6 @@
 from testtools.matchers import (
     Equals,
     LessThan,
-    MatchesAny,
     )
 from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy

=== added file 'lib/lp/soyuz/browser/tests/test_package_copying_mixin.py'
--- lib/lp/soyuz/browser/tests/test_package_copying_mixin.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/browser/tests/test_package_copying_mixin.py	2011-05-13 08:56:09 +0000
@@ -0,0 +1,311 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for `PackageCopyingMixin`."""
+
+__metaclass__ = type
+
+from zope.component import getUtility
+
+from canonical.testing.layers import LaunchpadFunctionalLayer
+from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.services.features.testing import FeatureFixture
+from lp.services.propertycache import cachedproperty
+from lp.soyuz.browser.archive import (
+    compose_synchronous_copy_feedback,
+    copy_asynchronously,
+    copy_synchronously,
+    FEATURE_FLAG_MAX_SYNCHRONOUS_SYNCS,
+    name_pubs_with_versions,
+    PackageCopyingMixin,
+    partition_pubs_by_archive,
+    render_cannotcopy_as_html,
+    )
+from lp.soyuz.interfaces.archive import CannotCopy
+from lp.soyuz.interfaces.packagecopyjob import IPlainPackageCopyJobSource
+from lp.soyuz.enums import SourcePackageFormat
+from lp.soyuz.interfaces.sourcepackageformat import (
+    ISourcePackageFormatSelectionSet,
+    )
+from lp.testing import (
+    TestCase,
+    TestCaseWithFactory,
+    )
+from lp.testing.fakemethod import FakeMethod
+from lp.testing.views import create_initialized_view
+
+
+def find_spph_copy(archive, spph):
+    """Find copy of `spph`'s package as copied into `archive`"""
+    spr = spph.sourcepackagerelease
+    return archive.getPublishedSources(
+        name=spr.sourcepackagename.name, version=spr.version).one()
+
+
+class FakeDistribution:
+    def __init__(self):
+        self.archive = FakeArchive()
+
+
+class FakeDistroSeries:
+    def __init__(self):
+        self.distribution = FakeDistribution()
+
+
+class FakeSPN:
+    name = "spn-name"
+
+
+class FakeSPR:
+    def __init__(self):
+        self.sourcepackagename = FakeSPN()
+        self.version = "1.0"
+
+
+class FakeArchive:
+    def __init__(self, displayname="archive-name"):
+        self.displayname = displayname
+
+
+class FakeSPPH:
+    def __init__(self, archive=None):
+        if archive is None:
+            archive = FakeArchive()
+        self.sourcepackagerelease = FakeSPR()
+        self.displayname = "spph-displayname"
+        self.archive = archive
+
+
+class TestPackageCopyingMixinLight(TestCase):
+    """Test lightweight functions and methods.
+
+    This test does not run in a layer and does not access the database.
+    """
+
+    unique_number = 1
+
+    def getPocket(self):
+        """Return an arbitrary `PackagePublishingPocket`."""
+        return PackagePublishingPocket.SECURITY
+
+    def getUniqueString(self):
+        """Return an arbitrary string."""
+        self.unique_number += 1
+        return "string_%d_" % self.unique_number
+
+    def test_canCopySynchronously_allows_small_synchronous_copies(self):
+        # Small numbers of packages can be copied synchronously.
+        packages = [self.getUniqueString() for counter in range(3)]
+        self.assertTrue(PackageCopyingMixin().canCopySynchronously(packages))
+
+    def test_canCopySynchronously_disallows_large_synchronous_copies(self):
+        # Large numbers of packages must be copied asynchronously.
+        packages = [self.getUniqueString() for counter in range(300)]
+        self.assertFalse(PackageCopyingMixin().canCopySynchronously(packages))
+
+    def test_partition_pubs_by_archive_maps_archives_to_pubs(self):
+        # partition_pubs_by_archive returns a dict mapping each archive
+        # to a list of SPPHs on that archive.
+        spph = FakeSPPH()
+        self.assertEqual(
+            {spph.archive: [spph]}, partition_pubs_by_archive([spph]))
+
+    def test_partition_pubs_by_archive_splits_by_archive(self):
+        # partition_pubs_by_archive keeps SPPHs for different archives
+        # separate.
+        spphs = [FakeSPPH() for counter in xrange(2)]
+        mapping = partition_pubs_by_archive(spphs)
+        self.assertEqual(
+            dict((spph.archive, [spph]) for spph in spphs), mapping)
+
+    def test_partition_pubs_by_archive_clusters_by_archive(self):
+        # partition_pubs_by_archive bundles SPPHs for the same archive
+        # into a single dict entry.
+        archive = FakeArchive()
+        spphs = [FakeSPPH(archive=archive) for counter in xrange(2)]
+        mapping = partition_pubs_by_archive(spphs)
+        self.assertEqual([archive], mapping.keys())
+        self.assertContentEqual(spphs, mapping[archive])
+
+    def test_name_pubs_with_versions_lists_packages_and_versions(self):
+        # name_pubs_with_versions returns a list of tuples of source
+        # package name and source package version, one per SPPH.
+        spph = FakeSPPH()
+        spr = spph.sourcepackagerelease
+        self.assertEqual(
+            [(spr.sourcepackagename.name, spr.version)],
+            name_pubs_with_versions([spph]))
+
+    def test_render_cannotcopy_as_html_lists_errors(self):
+        # render_cannotcopy_as_html includes a CannotCopy error message
+        # into its HTML notice.
+        message = self.getUniqueString()
+        html_text = render_cannotcopy_as_html(CannotCopy(message)).escapedtext
+        self.assertIn(message, html_text)
+
+    def test_render_cannotcopy_as_html_escapes_error(self):
+        # render_cannotcopy_as_html escapes error messages.
+        message = "x<>y"
+        html_text = render_cannotcopy_as_html(CannotCopy(message)).escapedtext
+        self.assertNotIn(message, html_text)
+        self.assertIn("x&lt;&gt;y", html_text)
+
+    def test_compose_synchronous_copy_feedback_escapes_archive_name(self):
+        # compose_synchronous_copy_feedback escapes archive displaynames.
+        archive = FakeArchive(displayname="a&b")
+        notice = compose_synchronous_copy_feedback(
+            ["hi"], archive, dest_url="/")
+        html_text = notice.escapedtext
+        self.assertNotIn("a&b", html_text)
+        self.assertIn("a&amp;b", html_text)
+
+    def test_compose_synchronous_copy_feedback_escapes_package_names(self):
+        # compose_synchronous_copy_feedback escapes package names.
+        archive = FakeArchive()
+        notice = compose_synchronous_copy_feedback(
+            ["x<y"], archive, dest_url="/")
+        html_text = notice.escapedtext
+        self.assertNotIn("x<y", html_text)
+        self.assertIn("x&lt;y", html_text)
+
+    def test_copy_synchronously_checks_permissions(self):
+        # Unless told not to, copy_synchronously does a permissions
+        # check.
+        pocket = self.getPocket()
+        self.assertRaises(
+            CannotCopy,
+            copy_synchronously,
+            [FakeSPPH()], FakeArchive(), FakeDistroSeries(), pocket, False)
+
+    def test_copy_asynchronously_checks_permissions(self):
+        # Unless told not to, copy_asynchronously does a permissions
+        # check.
+        pocket = self.getPocket()
+        self.assertRaises(
+            CannotCopy,
+            copy_asynchronously,
+            [FakeSPPH()], FakeArchive(), FakeDistroSeries(), pocket, False)
+
+
+class TestPackageCopyingMixinIntegration(TestCaseWithFactory):
+    """Integration tests for `PackageCopyingMixin`."""
+
+    layer = LaunchpadFunctionalLayer
+
+    @cachedproperty
+    def person(self):
+        """Create a single person who gets blamed for everything.
+
+        Creating SPPHs, Archives etc. in the factory creates lots of
+        `Person`s, which turns out to be really slow.  Tests that don't
+        care who's who can use this single person for all uninteresting
+        Person fields.
+        """
+        return self.factory.makePerson()
+
+    def makeDistribution(self):
+        """Create a `Distribution`, but quickly by reusing a single Person."""
+        return self.factory.makeDistribution(
+            owner=self.person, registrant=self.person)
+
+    def makeDistroSeries(self, parent_series=None):
+        """Create a `DistroSeries`, but quickly by reusing a single Person."""
+        return self.factory.makeDistroSeries(
+            distribution=self.makeDistribution(), parent_series=parent_series,
+            registrant=self.person)
+
+    def makeSPPH(self):
+        """Create a `SourcePackagePublishingHistory` quickly."""
+        archive = self.factory.makeArchive(
+            owner=self.person, distribution=self.makeDistribution())
+        return self.factory.makeSourcePackagePublishingHistory(
+            maintainer=self.person, creator=self.person, archive=archive)
+
+    def makeDerivedSeries(self):
+        """Create a derived `DistroSeries`, quickly."""
+        series = self.makeDistroSeries(parent_series=self.makeDistroSeries())
+        getUtility(ISourcePackageFormatSelectionSet).add(
+            series, SourcePackageFormat.FORMAT_1_0)
+        return series
+
+    def makeView(self):
+        """Create a `PackageCopyingMixin`-based view."""
+        return create_initialized_view(
+            self.makeDerivedSeries(), "+localpackagediffs")
+
+    def test_canCopySynchronously_obeys_feature_flag(self):
+        packages = [self.getUniqueString() for counter in range(3)]
+        mixin = PackageCopyingMixin()
+        with FeatureFixture({FEATURE_FLAG_MAX_SYNCHRONOUS_SYNCS: 2}):
+            can_copy_synchronously = mixin.canCopySynchronously(packages)
+        self.assertFalse(can_copy_synchronously)
+
+    def test_copy_synchronously_copies_packages(self):
+        # copy_synchronously copies packages into the destination
+        # archive.
+        spph = self.makeSPPH()
+        dest_series = self.makeDerivedSeries()
+        archive = dest_series.distribution.main_archive
+        pocket = self.factory.getAnyPocket()
+        copy_synchronously(
+            [spph], archive, dest_series, pocket, include_binaries=False,
+            check_permissions=False)
+        self.assertNotEqual(None, find_spph_copy(archive, spph))
+
+    def test_copy_asynchronously_does_not_copy_packages(self):
+        # copy_asynchronously does not copy packages into the destination
+        # archive; that happens later, asynchronously.
+        spph = self.makeSPPH()
+        dest_series = self.makeDerivedSeries()
+        archive = dest_series.distribution.main_archive
+        pocket = self.factory.getAnyPocket()
+        copy_asynchronously(
+            [spph], archive, dest_series, pocket, include_binaries=False,
+            check_permissions=False)
+        self.assertEqual(None, find_spph_copy(archive, spph))
+
+    def test_copy_synchronously_lists_packages(self):
+        # copy_synchronously returns feedback that includes the names of
+        # packages it copied.
+        spph = self.makeSPPH()
+        dest_series = self.makeDerivedSeries()
+        pocket = self.factory.getAnyPocket()
+        notice = copy_synchronously(
+            [spph], dest_series.distribution.main_archive, dest_series,
+            pocket, include_binaries=False,
+            check_permissions=False).escapedtext
+        self.assertIn(
+            spph.sourcepackagerelease.sourcepackagename.name, notice)
+
+    def test_copy_asynchronously_creates_copy_jobs(self):
+        # copy_asynchronously creates PackageCopyJobs.
+        spph = self.makeSPPH()
+        dest_series = self.makeDerivedSeries()
+        pocket = self.factory.getAnyPocket()
+        archive = dest_series.distribution.main_archive
+        copy_asynchronously(
+            [spph], archive, dest_series, pocket, include_binaries=False,
+            check_permissions=False)
+        jobs = list(getUtility(IPlainPackageCopyJobSource).getActiveJobs(
+            archive))
+        self.assertEqual(1, len(jobs))
+        spr = spph.sourcepackagerelease
+        self.assertEqual(
+            [[spr.sourcepackagename.name, spr.version]],
+            jobs[0].metadata['source_packages'])
+
+    def test_do_copy_goes_async_if_canCopySynchronously_says_so(self):
+        # The view opts for asynchronous copying if canCopySynchronously
+        # returns False.  This creates PackageCopyJobs.
+        spph = self.makeSPPH()
+        pocket = self.factory.getAnyPocket()
+        view = self.makeView()
+        dest_series = view.context
+        archive = dest_series.distribution.main_archive
+        view.canCopySynchronously = FakeMethod(result=False)
+        view.do_copy(
+            'selected_differences', [spph], archive, dest_series, pocket,
+            False, check_permissions=False)
+        jobs = list(getUtility(IPlainPackageCopyJobSource).getActiveJobs(
+            archive))
+        self.assertNotEqual([], jobs)

=== modified file 'lib/lp/soyuz/interfaces/packagecopyjob.py'
--- lib/lp/soyuz/interfaces/packagecopyjob.py	2011-05-11 16:50:34 +0000
+++ lib/lp/soyuz/interfaces/packagecopyjob.py	2011-05-13 08:56:09 +0000
@@ -1,4 +1,4 @@
-# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2011 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -82,16 +82,16 @@
 class IPlainPackageCopyJobSource(IJobSource):
     """An interface for acquiring `IPackageCopyJobs`."""
 
-    def create(cls, source_archive, source_packages,
+    def create(cls, source_packages, source_archive,
                target_archive, target_distroseries, target_pocket,
                include_binaries=False):
         """Create a new `IPackageCopyJob`.
 
-        :param source_archive: The `IArchive` in which `source_packages` are
-            found.
         :param source_packages: An iterable of `(source_package_name,
             version)` tuples, where both `source_package_name` and `version`
             are strings.
+        :param source_archive: The `IArchive` in which `source_packages` are
+            found.
         :param target_archive: The `IArchive` to which to copy the packages.
         :param target_distroseries: The `IDistroSeries` to which to copy the
             packages.

=== modified file 'lib/lp/soyuz/scripts/packagecopier.py'
--- lib/lp/soyuz/scripts/packagecopier.py	2011-05-10 19:02:47 +0000
+++ lib/lp/soyuz/scripts/packagecopier.py	2011-05-13 08:56:09 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 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).
 
 """PackageCopier utilities."""
@@ -9,6 +9,7 @@
     'PackageCopier',
     'UnembargoSecurityPackage',
     'CopyChecker',
+    'check_copy_permissions',
     'do_copy',
     '_do_delayed_copy',
     '_do_direct_copy',
@@ -51,6 +52,7 @@
     )
 from lp.soyuz.scripts.processaccepted import close_bugs_for_sourcepublication
 
+
 # XXX cprov 2009-06-12: This function could be incorporated in ILFA,
 # I just don't see a clear benefit in doing that right now.
 def re_upload_file(libraryfile, restricted=False):
@@ -194,6 +196,41 @@
             return {'status': BuildSetStatus.NEEDSBUILD}
 
 
+def check_copy_permissions(person, archive, series, pocket,
+                           sourcepackagenames):
+    """Check that `person` has permission to copy a package.
+
+    :param person: User attempting the upload.
+    :param archive: Destination `Archive`.
+    :param series: Destination `DistroSeries`.
+    :param pocket: Destination `Pocket`.
+    :param sourcepackagenames: Sequence of `SourcePackageName`s for the
+        packages to be copied.
+    :raises CannotCopy: If the copy is not allowed.
+    """
+    if person is None:
+        raise CannotCopy("Cannot check copy permissions (no requester).")
+
+    # If there is a requester, check that he has upload permission into
+    # the destination (archive, component, pocket). This check is done
+    # here rather than in the security adapter because it requires more
+    # info than is available in the security adapter.
+    for spn in set(sourcepackagenames):
+        package = series.getSourcePackage(spn)
+        destination_component = package.latest_published_component
+
+        # If destination_component is not None, make sure the person
+        # has upload permission for this component.  Otherwise, any
+        # upload permission on this archive will do.
+        strict_component = destination_component is not None
+        reason = archive.checkUpload(
+            person, series, spn, destination_component, pocket,
+            strict_component=strict_component)
+
+        if reason is not None:
+            raise CannotCopy(reason)
+
+
 class CopyChecker:
     """Check copy candiates.
 
@@ -399,28 +436,10 @@
         :raise CannotCopy when a copy is not allowed to be performed
             containing the reason of the error.
         """
-        # If there is a requester, check that he has upload permission
-        # into the destination (archive, component, pocket). This check
-        # is done here rather than in the security adapter because it
-        # requires more info than is available in the security adapter.
         if check_permissions:
-            if person is None:
-                raise CannotCopy(
-                    'Cannot check copy permissions (no requester).')
-            else:
-                sourcepackagerelease = source.sourcepackagerelease
-                sourcepackagename = sourcepackagerelease.sourcepackagename
-                destination_component = series.getSourcePackage(
-                    sourcepackagename).latest_published_component
-                # If destination_component is not None, make sure the person
-                # has upload permission for this component. Otherwise, any
-                # upload permission on this archive will do.
-                strict_component = destination_component is not None
-                reason = self.archive.checkUpload(
-                    person, series, sourcepackagename, destination_component,
-                    pocket, strict_component=strict_component)
-                if reason is not None:
-                    raise CannotCopy(reason)
+            check_copy_permissions(
+                person, self.archive, series, pocket,
+                [source.sourcepackagerelease.sourcepackagename])
 
         if series not in self.archive.distribution.series:
             raise CannotCopy(