← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~thumper/launchpad/recipe-new-ppa into lp:launchpad

 

Tim Penhey has proposed merging lp:~thumper/launchpad/recipe-new-ppa into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  #670440 Very difficult to create a recipe targeted to a new archive
  https://bugs.launchpad.net/bugs/670440


The main purpose of this branch is to offer the user the ability to create a
PPA while they are creating a new recipe.  Before this work a simple select
control was shown with the possible PPAs in it.  If the user did not have any
PPAs, nor was in any team that had a PPA, this select control was empty (and
required).

Now if the user does not have any possible PPAs to choose, they are just shown
an entry field for the new PPA name.  The name is optional, and 'ppa' is used
if nothing is entered (this is the default used by Person.createPPA).

If the use does have one or more possible PPAs, there are now radio buttons
shown to the user to indicate whether to use an existing PPA, or to create a
new one.  If the user has javascript enabled, the appropriate fields are
enabled or disabled based on the radio button choices.

And now for the changes...

lib/canonical/launchpad/testing/pages.py
  - generalises the print_radio_button_field so it can be used in unit tests

lib/lp/app/browser/launchpadform.py
  - Moves the render_radio_widget_part from productseries view below.

lib/lp/app/templates/base-layout-macros.pt
  - Add the new javascript file

lib/lp/code/browser/sourcepackagerecipe.py
  - Split the ISourcePackageAddEditSchema into separate Add and Edit schemas
  - Add two more fields to the Add schema for the ppa options and name

lib/lp/code/browser/tests/test_sourcepackagerecipe.py
  - the tests for the new recipe work

lib/lp/code/javascript/sourcepackagerecipe.new.js
  - the javascript file that enables and disables the fields.

lib/lp/code/model/sourcepackagerecipe.py
  - Add ubuntu to the supported_distros for the builable distroseries
    This was needed to make the distroseries appear on the new recipe
    page when the user didn't yet have any PPAs

lib/lp/code/templates/sourcepackagerecipe-new.pt
  - The form went from being a simple widget rendering using the defaults,
    to one where we need to specify the widgets in order to control the
    optionality of some of the fields.

lib/lp/registry/browser/productseries.py
  - Move the render method somewhere reusable.

lib/lp/soyuz/model/archive.py
  - drive by lint fix

lib/lp/testing/__init__.py
  - generalise the getting the main content

-- 
https://code.launchpad.net/~thumper/launchpad/recipe-new-ppa/+merge/42805
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~thumper/launchpad/recipe-new-ppa into lp:launchpad.
=== modified file 'lib/canonical/launchpad/testing/pages.py'
--- lib/canonical/launchpad/testing/pages.py	2010-11-08 12:52:43 +0000
+++ lib/canonical/launchpad/testing/pages.py	2010-12-06 02:48:20 +0000
@@ -319,27 +319,37 @@
             print sep.join(row_content)
 
 
-def print_radio_button_field(content, name):
-    """Find the input called field.name, and print a friendly representation.
+def get_radio_button_text_for_field(soup, name):
+    """Find the input called field.name, and return an iterable of strings.
 
     The resulting output will look something like:
-    (*) A checked option
-    ( ) An unchecked option
+    ['(*) A checked option', '( ) An unchecked option']
     """
-    main = BeautifulSoup(content)
-    buttons = main.findAll(
+    buttons = soup.findAll(
         'input', {'name': 'field.%s' % name})
     for button in buttons:
         if button.parent.name == 'label':
             label = extract_text(button.parent)
         else:
             label = extract_text(
-                main.find('label', attrs={'for': button['id']}))
+                soup.find('label', attrs={'for': button['id']}))
         if button.get('checked', None):
             radio = '(*)'
         else:
             radio = '( )'
-        print radio, label
+        yield "%s %s" % (radio, label)
+
+
+def print_radio_button_field(content, name):
+    """Find the input called field.name, and print a friendly representation.
+
+    The resulting output will look something like:
+    (*) A checked option
+    ( ) An unchecked option
+    """
+    main = BeautifulSoup(content)
+    for field in get_radio_button_text_for_field(main, name):
+        print field
 
 
 def strip_label(label):

=== modified file 'lib/lp/app/browser/launchpadform.py'
--- lib/lp/app/browser/launchpadform.py	2010-11-24 03:35:12 +0000
+++ lib/lp/app/browser/launchpadform.py	2010-12-06 02:48:20 +0000
@@ -12,6 +12,7 @@
     'has_structured_doc',
     'LaunchpadEditFormView',
     'LaunchpadFormView',
+    'render_radio_widget_part',
     'ReturnToReferrerMixin',
     'safe_action',
     ]
@@ -543,3 +544,24 @@
                 "There should be no further path segments after "
                 "query:has-structured-doc")
         return self.widget.context.queryTaggedValue('has_structured_doc')
+
+
+def render_radio_widget_part(widget, term_value, current_value, label=None):
+    """Render a particular term for a radio button widget.
+
+    This may well work for other widgets, but has only been tested with radio
+    button widgets.
+    """
+    term = widget.vocabulary.getTerm(term_value)
+    if term.value == current_value:
+        render = widget.renderSelectedItem
+    else:
+        render = widget.renderItem
+    if label is None:
+        label = term.title
+    value = term.token
+    return render(index=term.value,
+                  text=label,
+                  value=value,
+                  name=widget.name,
+                  cssClass='')

=== modified file 'lib/lp/app/templates/base-layout-macros.pt'
--- lib/lp/app/templates/base-layout-macros.pt	2010-11-16 18:44:52 +0000
+++ lib/lp/app/templates/base-layout-macros.pt	2010-12-06 02:48:20 +0000
@@ -607,6 +607,9 @@
             tal:attributes="src string:${lp_js}/code/productseries-setbranch.js">
     </script>
     <script type="text/javascript"
+            tal:attributes="src string:${lp_js}/code/sourcepackagerecipe.new.js">
+    </script>
+    <script type="text/javascript"
             tal:attributes="src string:${lp_js}/app/comment.js"></script>
     <script type="text/javascript"
             tal:attributes="src string:${lp_js}/app/errors.js"></script>

=== modified file 'lib/lp/code/browser/sourcepackagerecipe.py'
--- lib/lp/code/browser/sourcepackagerecipe.py	2010-11-28 23:32:25 +0000
+++ lib/lp/code/browser/sourcepackagerecipe.py	2010-12-06 02:48:20 +0000
@@ -36,10 +36,17 @@
     Choice,
     List,
     Text,
+    TextLine,
+    )
+from zope.schema.vocabulary import (
+    SimpleTerm,
+    SimpleVocabulary,
     )
 
 from canonical.database.constants import UTC_NOW
+from canonical.launchpad import _
 from canonical.launchpad.browser.launchpad import Hierarchy
+from canonical.launchpad.validators.name import name_validator
 from canonical.launchpad.webapp import (
     canonical_url,
     ContextMenu,
@@ -54,13 +61,17 @@
 from canonical.launchpad.webapp.authorization import check_permission
 from canonical.launchpad.webapp.breadcrumb import Breadcrumb
 from canonical.widgets.suggestion import RecipeOwnerWidget
-from canonical.widgets.itemswidgets import LabeledMultiCheckBoxWidget
+from canonical.widgets.itemswidgets import (
+    LabeledMultiCheckBoxWidget,
+    LaunchpadRadioWidget,
+    )
 from lp.app.browser.launchpadform import (
     action,
     custom_widget,
     has_structured_doc,
     LaunchpadEditFormView,
     LaunchpadFormView,
+    render_radio_widget_part,
     )
 from lp.code.errors import (
     BuildAlreadyPending,
@@ -77,6 +88,7 @@
     ISourcePackageRecipeBuildSource,
     )
 from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.soyuz.model.archive import Archive
 
 
 RECIPE_BETA_MESSAGE = structured(
@@ -268,7 +280,7 @@
         self.next_url = self.cancel_url
 
 
-class ISourcePackageAddEditSchema(Interface):
+class ISourcePackageEditSchema(Interface):
     """Schema for adding or editing a recipe."""
 
     use_template(ISourcePackageRecipe, include=[
@@ -300,6 +312,33 @@
                """))
 
 
+EXISTING_PPA = 'existing-ppa'
+CREATE_NEW = 'create-new'
+
+
+USE_ARCHIVE_VOCABULARY = SimpleVocabulary((
+    SimpleTerm(EXISTING_PPA, EXISTING_PPA,
+               _("Use an existing PPA")),
+    SimpleTerm(CREATE_NEW, CREATE_NEW,
+               _("Create a new PPA for this recipe")),
+    ))
+
+
+class ISourcePackageAddSchema(ISourcePackageEditSchema):
+
+    use_ppa = Choice(
+        title=_('Which PPA'),
+        vocabulary=USE_ARCHIVE_VOCABULARY,
+        description=_("Which PPA to use..."),
+        required=True)
+
+    ppa_name = TextLine(
+            title=_("New PPA name"), required=False,
+            constraint=name_validator,
+            description=_("A new PPA with this name will be created for "
+                          "the owner of the recipe ."))
+
+
 class RecipeTextValidatorMixin:
     """Class to validate that the Source Package Recipe text is valid."""
 
@@ -321,9 +360,10 @@
 
     title = label = 'Create a new source package recipe'
 
-    schema = ISourcePackageAddEditSchema
+    schema = ISourcePackageAddSchema
     custom_widget('distros', LabeledMultiCheckBoxWidget)
     custom_widget('owner', RecipeOwnerWidget)
+    custom_widget('use_ppa', LaunchpadRadioWidget)
 
     def initialize(self):
         # XXX: rockstar: This should be removed when source package recipes
@@ -331,12 +371,26 @@
         super(SourcePackageRecipeAddView, self).initialize()
         self.request.response.addWarningNotification(RECIPE_BETA_MESSAGE)
 
+    def setUpWidgets(self):
+        """See `LaunchpadFormView`."""
+        super(SourcePackageRecipeAddView, self).setUpWidgets()
+        widget = self.widgets['use_ppa']
+        current_value = widget._getFormValue()
+        self.use_ppa_existing = render_radio_widget_part(
+            widget, EXISTING_PPA, current_value)
+        self.use_ppa_new = render_radio_widget_part(
+            widget, CREATE_NEW, current_value)
+        self.show_ppa_chooser = len(
+            self.widgets['daily_build_archive'].vocabulary) > 0
+
     @property
     def initial_values(self):
         return {
             'recipe_text': MINIMAL_RECIPE_TEXT % self.context.bzr_identity,
             'owner': self.user,
-            'build_daily': False}
+            'build_daily': False,
+            'use_ppa': EXISTING_PPA,
+            }
 
     @property
     def cancel_url(self):
@@ -345,11 +399,17 @@
     @action('Create Recipe', name='create')
     def request_action(self, action, data):
         try:
+            owner = data['owner']
+            if data['use_ppa'] == CREATE_NEW:
+                ppa_name = data.get('ppa_name', None)
+                ppa = owner.createPPA(ppa_name)
+            else:
+                ppa = data['daily_build_archive']
             source_package_recipe = getUtility(
                 ISourcePackageRecipeSource).new(
-                    self.user, data['owner'], data['name'],
+                    self.user, owner, data['name'],
                     data['recipe_text'], data['description'], data['distros'],
-                    data['daily_build_archive'], data['build_daily'])
+                    ppa, data['build_daily'])
             Store.of(source_package_recipe).flush()
         except TooNewRecipeFormat:
             self.setFieldError(
@@ -383,6 +443,11 @@
                     'name',
                     'There is already a recipe owned by %s with this name.' %
                         owner.displayname)
+        if data['use_ppa'] == CREATE_NEW:
+            ppa_name = data.get('ppa_name', None)
+            error = Archive.validatePPA(owner, ppa_name)
+            if error is not None:
+                self.setFieldError('ppa_name', error)
 
 
 class SourcePackageRecipeEditView(RecipeTextValidatorMixin,
@@ -394,7 +459,7 @@
         return 'Edit %s source package recipe' % self.context.name
     label = title
 
-    schema = ISourcePackageAddEditSchema
+    schema = ISourcePackageEditSchema
     custom_widget('distros', LabeledMultiCheckBoxWidget)
 
     def setUpFields(self):
@@ -481,7 +546,7 @@
     @property
     def adapters(self):
         """See `LaunchpadEditFormView`"""
-        return {ISourcePackageAddEditSchema: self.context}
+        return {ISourcePackageEditSchema: self.context}
 
     def validate(self, data):
         super(SourcePackageRecipeEditView, self).validate(data)

=== modified file 'lib/lp/code/browser/tests/test_sourcepackagerecipe.py'
--- lib/lp/code/browser/tests/test_sourcepackagerecipe.py	2010-11-30 11:48:27 +0000
+++ lib/lp/code/browser/tests/test_sourcepackagerecipe.py	2010-12-06 02:48:20 +0000
@@ -25,6 +25,7 @@
     extract_text,
     find_main_content,
     find_tags_by_class,
+    get_radio_button_text_for_field,
     )
 from canonical.launchpad.webapp import canonical_url
 from canonical.testing.layers import (
@@ -41,6 +42,7 @@
     )
 from lp.code.interfaces.sourcepackagerecipe import MINIMAL_RECIPE_TEXT
 from lp.code.tests.helpers import recipe_parser_newest_version
+from lp.registry.interfaces.person import TeamSubscriptionPolicy
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.services.propertycache import clear_property_cache
 from lp.soyuz.model.processor import ProcessorFamily
@@ -412,6 +414,110 @@
             get_message_text(browser, 2),
             'Recipe may not refer to private branch: %s' % bzr_identity)
 
+    def test_ppa_selector_not_shown_if_user_has_no_ppas(self):
+        # If the user creating a recipe has no existing PPAs, the selector
+        # isn't shown, but the field to enter a new PPA name is.
+        self.user = self.factory.makePerson(password='test')
+        branch = self.factory.makeAnyBranch()
+        with person_logged_in(self.user):
+            content = self.getMainContent(branch, '+new-recipe')
+        tag = content.find(attrs={'id': 'field.ppa_name'})
+        self.assertEqual('input', tag.name)
+        self.assertEqual('text', tag['type'])
+        ppa_chooser = content.find(attrs={'id': 'field.daily_build_archive'})
+        self.assertIs(None, ppa_chooser)
+        # There is a hidden option to say create a new ppa.
+        ppa_options = content.find(attrs={'name': 'field.use_ppa'})
+        self.assertEqual('input', ppa_options.name)
+        self.assertEqual('hidden', ppa_options['type'])
+        self.assertEqual('create-new', ppa_options['value'])
+
+    def test_ppa_selector_shown_if_user_has_ppas(self):
+        # If the user creating a recipe has existing PPAs, the selector is
+        # shown, along with radio buttons to decide whether to use an existing
+        # ppa or to create a new one.
+        branch = self.factory.makeAnyBranch()
+        with person_logged_in(self.user):
+            content = self.getMainContent(branch, '+new-recipe')
+        tag = content.find(attrs={'id': 'field.ppa_name'})
+        self.assertEqual('input', tag.name)
+        self.assertEqual('text', tag['type'])
+        ppa_chooser = content.find(attrs={'id': 'field.daily_build_archive'})
+        self.assertEqual('select', ppa_chooser.name)
+        ppa_options = list(
+            get_radio_button_text_for_field(content, 'use_ppa'))
+        self.assertEqual(
+            ['(*) Use an existing PPA',
+             '( ) Create a new PPA for this recipe'''],
+            ppa_options)
+
+    def test_create_new_ppa(self):
+        # If the user doesn't have any PPAs, a new once can be created.
+        self.user = self.factory.makePerson(name='eric', password='test')
+        branch = self.factory.makeAnyBranch()
+
+        # A new recipe can be created from the branch page.
+        browser = self.getUserBrowser(canonical_url(branch), user=self.user)
+        browser.getLink('Create packaging recipe').click()
+
+        browser.getControl(name='field.name').value = 'name'
+        browser.getControl('Description').value = 'Make some food!'
+        browser.getControl('Secret Squirrel').click()
+        browser.getControl('Create Recipe').click()
+
+        # A new recipe is created in a new PPA.
+        self.assertTrue(browser.url.endswith('/~eric/+recipe/name'))
+        # Since no PPA name was entered, the default name (ppa) was used.
+        login(ANONYMOUS)
+        new_ppa = self.user.getPPAByName('ppa')
+        self.assertIsNot(None, new_ppa)
+
+    def test_create_new_ppa_duplicate(self):
+        # If a new PPA is being created, and the user already has a ppa of the
+        # name specifed (or not specified as the case may be), an error is
+        # shown.
+        self.user = self.factory.makePerson(name='eric', password='test')
+        # Make a PPA called 'ppa' using the default.
+        self.user.createPPA()
+        branch = self.factory.makeAnyBranch()
+
+        # A new recipe can be created from the branch page.
+        browser = self.getUserBrowser(canonical_url(branch), user=self.user)
+        browser.getLink('Create packaging recipe').click()
+        browser.getControl(name='field.name').value = 'name'
+        browser.getControl('Description').value = 'Make some food!'
+        browser.getControl('Secret Squirrel').click()
+        browser.getControl('Create a new PPA').click()
+        browser.getControl('Create Recipe').click()
+        self.assertEqual(
+            get_message_text(browser, 2),
+            "You already have a PPA named 'ppa'.")
+
+    def test_create_new_ppa_owned_by_recipe_owner(self):
+        # The new PPA that is created is owned by the recipe owner.
+        self.user = self.factory.makePerson(name='eric', password='test')
+        team = self.factory.makeTeam(
+            name='vikings', members=[self.user],
+            subscription_policy=TeamSubscriptionPolicy.MODERATED)
+        branch = self.factory.makeAnyBranch(owner=team)
+
+        # A new recipe can be created from the branch page.
+        browser = self.getUserBrowser(canonical_url(branch), user=self.user)
+        browser.getLink('Create packaging recipe').click()
+
+        browser.getControl(name='field.name').value = 'name'
+        browser.getControl('Description').value = 'Make some food!'
+        browser.getControl(name='field.owner').value = ['vikings']
+        browser.getControl('Secret Squirrel').click()
+        browser.getControl('Create Recipe').click()
+
+        # A new recipe is created in a new PPA.
+        self.assertTrue(browser.url.endswith('/~vikings/+recipe/name'))
+        # Since no PPA name was entered, the default name (ppa) was used.
+        login(ANONYMOUS)
+        new_ppa = team.getPPAByName('ppa')
+        self.assertIsNot(None, new_ppa)
+
 
 class TestSourcePackageRecipeEditView(TestCaseForRecipe):
     """Test the editing behaviour of a source package recipe."""

=== added file 'lib/lp/code/javascript/sourcepackagerecipe.new.js'
--- lib/lp/code/javascript/sourcepackagerecipe.new.js	1970-01-01 00:00:00 +0000
+++ lib/lp/code/javascript/sourcepackagerecipe.new.js	2010-12-06 02:48:20 +0000
@@ -0,0 +1,56 @@
+/* Copyright 2010 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Control enabling/disabling form elements on the +new-recipe page.
+ *
+ * @module Y.lp.code.sourcepackagerecipe.new
+ * @requires node, DOM
+ */
+YUI.add('lp.code.sourcepackagerecipe.new', function(Y) {
+    Y.log('loading lp.code.sourcepackagerecipe.new');
+    var module = Y.namespace('lp.code.sourcepackagerecipe.new');
+
+    function getRadioSelectedValue(selector) {
+      var tmpValue= false;
+      Y.all(selector).each(function(node) {
+          if (node.get('checked'))
+            tmpValue = node.get('value');
+        });
+      return tmpValue;
+    }
+
+    var PPA_SELECTOR_ID = 'field.daily_build_archive';
+    var PPA_NAME_ID = 'field.ppa_name';
+    var set_field_focus = false;
+
+    function set_enabled(field_id, is_enabled) {
+       var field = Y.DOM.byId(field_id);
+       field.disabled = !is_enabled;
+       if (is_enabled && set_field_focus) field.focus();
+    };
+
+    module.onclick_use_ppa = function(e) {
+      var value = getRadioSelectedValue('input[name=field.use_ppa]');
+      if (value == 'create-new') {
+        set_enabled(PPA_NAME_ID, true);
+        set_enabled(PPA_SELECTOR_ID, false);
+      }
+      else {
+        set_enabled(PPA_NAME_ID, false);
+        set_enabled(PPA_SELECTOR_ID, true);
+      }
+    }
+
+    module.setup = function() {
+       Y.all('input[name=field.use_ppa]').on(
+          'click', module.onclick_use_ppa);
+
+       // Set the initial state.
+       module.onclick_use_ppa();
+       // And from now on, set the focus to the active input field when the
+       // radio button is clicked.
+       set_field_focus = true;
+    };
+
+   }, "0.1", {"requires": ["node", "DOM"]}
+);

=== modified file 'lib/lp/code/model/sourcepackagerecipe.py'
--- lib/lp/code/model/sourcepackagerecipe.py	2010-12-01 11:26:57 +0000
+++ lib/lp/code/model/sourcepackagerecipe.py	2010-12-06 02:48:20 +0000
@@ -29,6 +29,7 @@
     )
 
 from canonical.database.datetimecol import UtcDateTimeCol
+from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
 from canonical.launchpad.interfaces.lpstorm import (
     IMasterStore,
     IStore,
@@ -62,7 +63,9 @@
 
 def get_buildable_distroseries_set(user):
     ppas = getUtility(IArchiveSet).getPPAsForUser(user)
-    supported_distros = [ppa.distribution for ppa in ppas]
+    supported_distros = set([ppa.distribution for ppa in ppas])
+    # Now add in Ubuntu.
+    supported_distros.add(getUtility(ILaunchpadCelebrities).ubuntu)
     distros = getUtility(IDistroSeriesSet).search()
 
     buildables = []

=== modified file 'lib/lp/code/templates/sourcepackagerecipe-new.pt'
--- lib/lp/code/templates/sourcepackagerecipe-new.pt	2010-11-25 03:35:05 +0000
+++ lib/lp/code/templates/sourcepackagerecipe-new.pt	2010-12-06 02:48:20 +0000
@@ -6,6 +6,21 @@
   metal:use-macro="view/macro:page/main_only"
   i18n:domain="launchpad">
   <body>
+
+<metal:block fill-slot="head_epilogue">
+  <style type="text/css">
+    .root-choice input[type="radio"] {
+      margin-left: 0;
+    }
+    .root-choice label {
+      font-weight: bold !important;
+    }
+    .subordinate {
+      margin: 0.5em 0 0.5em 2em;
+    }
+  </style>
+</metal:block>
+
     <div metal:fill-slot="main">
 
       <div>
@@ -23,8 +38,76 @@
 
       </div>
 
-      <div metal:use-macro="context/@@launchpad_form/form" />
-
+      <div metal:use-macro="context/@@launchpad_form/form">
+
+        <metal:formbody fill-slot="widgets">
+
+          <table class="form">
+
+            <tal:widget define="widget nocall:view/widgets/name">
+              <metal:block use-macro="context/@@launchpad_form/widget_row" />
+            </tal:widget>
+            <tal:widget define="widget nocall:view/widgets/description">
+              <metal:block use-macro="context/@@launchpad_form/widget_row" />
+            </tal:widget>
+            <tal:widget define="widget nocall:view/widgets/owner">
+              <metal:block use-macro="context/@@launchpad_form/widget_row" />
+            </tal:widget>
+            <tal:widget define="widget nocall:view/widgets/build_daily">
+              <metal:block use-macro="context/@@launchpad_form/widget_row" />
+            </tal:widget>
+
+            <tal:show-ppa-choice condition="view/show_ppa_chooser">
+              <tr>
+                <td class='root-choice'>
+                  <label tal:replace="structure view/use_ppa_existing">
+                    Use existing PPA
+                  </label>
+                  <table class="subordinate">
+                    <tal:widget define="widget nocall:view/widgets/daily_build_archive">
+                      <metal:block use-macro="context/@@launchpad_form/widget_row" />
+                    </tal:widget>
+                  </table>
+                </td>
+              </tr>
+
+              <tr>
+                <td class='root-choice'>
+                  <label tal:replace="structure view/use_ppa_new">
+                    Create new PPA
+                  </label>
+                  <table class="subordinate">
+                    <tal:widget define="widget nocall:view/widgets/ppa_name">
+                      <metal:block use-macro="context/@@launchpad_form/widget_row" />
+                    </tal:widget>
+                  </table>
+                </td>
+              </tr>
+
+              <script type="text/javascript">
+                LPS.use('lp.code.sourcepackagerecipe.new', function(Y) {
+                  Y.on('domready', Y.lp.code.sourcepackagerecipe.new.setup);
+                });
+              </script>
+            </tal:show-ppa-choice>
+
+            <tal:create-ppa condition="not: view/show_ppa_chooser">
+              <input name="field.use_ppa" value="create-new" type="hidden"/>
+              <tal:widget define="widget nocall:view/widgets/ppa_name">
+                <metal:block use-macro="context/@@launchpad_form/widget_row" />
+              </tal:widget>
+            </tal:create-ppa>
+
+            <tal:widget define="widget nocall:view/widgets/distros">
+              <metal:block use-macro="context/@@launchpad_form/widget_row" />
+            </tal:widget>
+            <tal:widget define="widget nocall:view/widgets/recipe_text">
+              <metal:block use-macro="context/@@launchpad_form/widget_row" />
+            </tal:widget>
+
+          </table>
+        </metal:formbody>
+      </div>
     </div>
   </body>
 </html>

=== modified file 'lib/lp/registry/browser/productseries.py'
--- lib/lp/registry/browser/productseries.py	2010-11-23 23:22:27 +0000
+++ lib/lp/registry/browser/productseries.py	2010-12-06 02:48:20 +0000
@@ -81,6 +81,7 @@
     custom_widget,
     LaunchpadEditFormView,
     LaunchpadFormView,
+    render_radio_widget_part,
     ReturnToReferrerMixin,
     )
 from lp.app.browser.tales import MenuAPI
@@ -913,31 +914,19 @@
     def setUpWidgets(self):
         """See `LaunchpadFormView`."""
         super(ProductSeriesSetBranchView, self).setUpWidgets()
-
-        def render(widget, term_value, current_value, label=None):
-            term = widget.vocabulary.getTerm(term_value)
-            if term.value == current_value:
-                render = widget.renderSelectedItem
-            else:
-                render = widget.renderItem
-            if label is None:
-                label = term.title
-            value = term.token
-            return render(index=term.value,
-                          text=label,
-                          value=value,
-                          name=widget.name,
-                          cssClass='')
-
         widget = self.widgets['rcs_type']
         vocab = widget.vocabulary
         current_value = widget._getFormValue()
-        self.rcs_type_cvs = render(widget, vocab.CVS, current_value, 'CVS')
-        self.rcs_type_svn = render(widget, vocab.BZR_SVN, current_value,
-                                   'SVN')
-        self.rcs_type_git = render(widget, vocab.GIT, current_value)
-        self.rcs_type_hg = render(widget, vocab.HG, current_value)
-        self.rcs_type_bzr = render(widget, vocab.BZR, current_value)
+        self.rcs_type_cvs = render_radio_widget_part(
+            widget, vocab.CVS, current_value, 'CVS')
+        self.rcs_type_svn = render_radio_widget_part(
+            widget, vocab.BZR_SVN, current_value, 'SVN')
+        self.rcs_type_git = render_radio_widget_part(
+            widget, vocab.GIT, current_value)
+        self.rcs_type_hg = render_radio_widget_part(
+            widget, vocab.HG, current_value)
+        self.rcs_type_bzr = render_radio_widget_part(
+            widget, vocab.BZR, current_value)
         self.rcs_type_emptymarker = widget._emptyMarker()
 
         widget = self.widgets['branch_type']
@@ -947,7 +936,7 @@
         (self.branch_type_link,
          self.branch_type_create,
          self.branch_type_import) = [
-            render(widget, value, current_value)
+            render_radio_widget_part(widget, value, current_value)
             for value in (LINK_LP_BZR, CREATE_NEW, IMPORT_EXTERNAL)]
 
     def _validateLinkLpBzr(self, data):

=== modified file 'lib/lp/soyuz/model/archive.py'
--- lib/lp/soyuz/model/archive.py	2010-12-02 14:57:58 +0000
+++ lib/lp/soyuz/model/archive.py	2010-12-06 02:48:20 +0000
@@ -1715,7 +1715,7 @@
 
     enabled_restricted_families = property(_getEnabledRestrictedFamilies,
                                            _setEnabledRestrictedFamilies)
-    
+
     @classmethod
     def validatePPA(self, person, proposed_name):
         ubuntu = getUtility(ILaunchpadCelebrities).ubuntu

=== modified file 'lib/lp/testing/__init__.py'
--- lib/lp/testing/__init__.py	2010-12-03 16:33:03 +0000
+++ lib/lp/testing/__init__.py	2010-12-06 02:48:20 +0000
@@ -714,12 +714,16 @@
         else:
             return self.getUserBrowser(url, self.user)
 
+    def getMainContent(self, context, view_name=None):
+        """Beautiful soup of the main content area of context's page."""
+        from canonical.launchpad.testing.pages import find_main_content
+        browser = self.getViewBrowser(context, view_name)
+        return find_main_content(browser.contents)
+
     def getMainText(self, context, view_name=None):
         """Return the main text of a context's page."""
-        from canonical.launchpad.testing.pages import (
-            extract_text, find_main_content)
-        browser = self.getViewBrowser(context, view_name)
-        return extract_text(find_main_content(browser.contents))
+        from canonical.launchpad.testing.pages import extract_text
+        return extract_text(self.getMainContent(context, view_name))
 
 
 class WindmillTestCase(TestCaseWithFactory):