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

Proposed by Tim Penhey
Status: Merged
Approved by: Tim Penhey
Approved revision: no longer in the source branch.
Merged at revision: 12074
Proposed branch: lp:~thumper/launchpad/recipe-new-ppa
Merge into: lp:launchpad
Diff against target: 742 lines (+425/-51)
11 files modified
lib/canonical/launchpad/testing/pages.py (+18/-8)
lib/lp/app/browser/launchpadform.py (+20/-0)
lib/lp/app/templates/base-layout-macros.pt (+3/-0)
lib/lp/code/browser/sourcepackagerecipe.py (+86/-10)
lib/lp/code/browser/tests/test_sourcepackagerecipe.py (+128/-0)
lib/lp/code/javascript/sourcepackagerecipe.new.js (+56/-0)
lib/lp/code/model/sourcepackagerecipe.py (+4/-1)
lib/lp/code/templates/sourcepackagerecipe-new.pt (+85/-2)
lib/lp/registry/browser/productseries.py (+12/-23)
lib/lp/soyuz/model/archive.py (+1/-1)
lib/lp/testing/__init__.py (+12/-6)
To merge this branch: bzr merge lp:~thumper/launchpad/recipe-new-ppa
Reviewer Review Type Date Requested Status
Henning Eggers (community) ui Approve
Graham Binns (community) code Approve
Review via email: mp+42805@code.launchpad.net

Commit message

[r=gmb][ui=henninge][bug=670440] Allow the user to create a PPA when creating a new recipe.

Description of the change

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

To post a comment you must log in.
Revision history for this message
Graham Binns (gmb) wrote :

Hi Tim,

I'm happy with the code in this branch, with a few nitpicks. You need to
get a UI review too (and I'm not a UI reviewer). I've requested a UI review
for the MP.

82 + return render(index=term.value,
83 + text=label,
84 + value=value,
85 + name=widget.name,
86 + cssClass='')

This should be formatted as:

    return render(
        index=term.value, text=label, value=value, name=widget.name,
        cssClass='')

167 +USE_ARCHIVE_VOCABULARY = SimpleVocabulary((
168 + SimpleTerm(EXISTING_PPA, EXISTING_PPA,
169 + _("Use an existing PPA")),
170 + SimpleTerm(CREATE_NEW, CREATE_NEW,
171 + _("Create a new PPA for this recipe")),
172 + ))

This should be formatted:

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")),
    ))

183 + ppa_name = TextLine(
184 + title=_("New PPA name"), required=False,
185 + constraint=name_validator,
186 + description=_("A new PPA with this name will be created for "
187 + "the owner of the recipe ."))

The arguments here should be indented by one level from the declaration,
not two.

A couple of JS points that I'm not too worried about (but decided to
point out all the same):

444 + function set_enabled(field_id, is_enabled) {
445 + var field = Y.DOM.byId(field_id);
446 + field.disabled = !is_enabled;
447 + if (is_enabled && set_field_focus) field.focus();
448 + };

You don't need a semicolon here.

450 + module.onclick_use_ppa = function(e) {
451 + var value = getRadioSelectedValue('input[name=field.use_ppa]');
452 + if (value == 'create-new') {
453 + set_enabled(PPA_NAME_ID, true);
454 + set_enabled(PPA_SELECTOR_ID, false);
455 + }
456 + else {
457 + set_enabled(PPA_NAME_ID, false);
458 + set_enabled(PPA_SELECTOR_ID, true);
459 + }
460 + }

But technically you *do* need one here, since it's part of an
assignment. Ain't JS fun?

review: Approve (code)
Revision history for this message
Henning Eggers (henninge) wrote :

Hi Tim,
great idea to fix this! I just wish we had a nice "combo box" widget that would allow selection or creation. I don't know if the picker widget (branch/person) could be used for that, too. But your solution is nice, too.

I have one major complaint, though: It does not work. When I select "Create new PPA for this recipe" and submit the form, I get an error "Required input is missing." under the drop-down box and if I left the text field empty it complains that "You already have a PPA named 'ppa'." The latter is actually true but confusing because the text field is empty.

It is true that person/+activate-ppa defaults to "ppa" but it actually preloads the input field with that string but only if this is the first ppa being created. Otherwise there is no default. This is what should happen here, too.

Sorry but this cannot yet be landed without these issues being resolved.

Cheers, Henning

review: Needs Fixing (ui)
Revision history for this message
Tim Penhey (thumper) wrote :

> I have one major complaint, though: It does not work.

Oops, must have not tested that bit.

I've updated it to make sure that the ppa name must be entered if a new ppa is being created, also default the ppa name to 'ppa' if it is the first ppa.

Updated the code also with Graham's suggestions.

Revision history for this message
Henning Eggers (henninge) wrote :

Thanks for the fix! This looks good now UI-wise.

review: Approve (ui)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/testing/pages.py'
2--- lib/canonical/launchpad/testing/pages.py 2010-11-08 12:52:43 +0000
3+++ lib/canonical/launchpad/testing/pages.py 2010-12-14 20:58:56 +0000
4@@ -319,27 +319,37 @@
5 print sep.join(row_content)
6
7
8-def print_radio_button_field(content, name):
9- """Find the input called field.name, and print a friendly representation.
10+def get_radio_button_text_for_field(soup, name):
11+ """Find the input called field.name, and return an iterable of strings.
12
13 The resulting output will look something like:
14- (*) A checked option
15- ( ) An unchecked option
16+ ['(*) A checked option', '( ) An unchecked option']
17 """
18- main = BeautifulSoup(content)
19- buttons = main.findAll(
20+ buttons = soup.findAll(
21 'input', {'name': 'field.%s' % name})
22 for button in buttons:
23 if button.parent.name == 'label':
24 label = extract_text(button.parent)
25 else:
26 label = extract_text(
27- main.find('label', attrs={'for': button['id']}))
28+ soup.find('label', attrs={'for': button['id']}))
29 if button.get('checked', None):
30 radio = '(*)'
31 else:
32 radio = '( )'
33- print radio, label
34+ yield "%s %s" % (radio, label)
35+
36+
37+def print_radio_button_field(content, name):
38+ """Find the input called field.name, and print a friendly representation.
39+
40+ The resulting output will look something like:
41+ (*) A checked option
42+ ( ) An unchecked option
43+ """
44+ main = BeautifulSoup(content)
45+ for field in get_radio_button_text_for_field(main, name):
46+ print field
47
48
49 def strip_label(label):
50
51=== modified file 'lib/lp/app/browser/launchpadform.py'
52--- lib/lp/app/browser/launchpadform.py 2010-11-24 03:35:12 +0000
53+++ lib/lp/app/browser/launchpadform.py 2010-12-14 20:58:56 +0000
54@@ -12,6 +12,7 @@
55 'has_structured_doc',
56 'LaunchpadEditFormView',
57 'LaunchpadFormView',
58+ 'render_radio_widget_part',
59 'ReturnToReferrerMixin',
60 'safe_action',
61 ]
62@@ -543,3 +544,22 @@
63 "There should be no further path segments after "
64 "query:has-structured-doc")
65 return self.widget.context.queryTaggedValue('has_structured_doc')
66+
67+
68+def render_radio_widget_part(widget, term_value, current_value, label=None):
69+ """Render a particular term for a radio button widget.
70+
71+ This may well work for other widgets, but has only been tested with radio
72+ button widgets.
73+ """
74+ term = widget.vocabulary.getTerm(term_value)
75+ if term.value == current_value:
76+ render = widget.renderSelectedItem
77+ else:
78+ render = widget.renderItem
79+ if label is None:
80+ label = term.title
81+ value = term.token
82+ return render(
83+ index=term.value, text=label, value=value, name=widget.name,
84+ cssClass='')
85
86=== modified file 'lib/lp/app/templates/base-layout-macros.pt'
87--- lib/lp/app/templates/base-layout-macros.pt 2010-12-13 18:04:24 +0000
88+++ lib/lp/app/templates/base-layout-macros.pt 2010-12-14 20:58:56 +0000
89@@ -607,6 +607,9 @@
90 tal:attributes="src string:${lp_js}/code/productseries-setbranch.js">
91 </script>
92 <script type="text/javascript"
93+ tal:attributes="src string:${lp_js}/code/sourcepackagerecipe.new.js">
94+ </script>
95+ <script type="text/javascript"
96 tal:attributes="src string:${lp_js}/app/comment.js"></script>
97 <script type="text/javascript"
98 tal:attributes="src string:${lp_js}/app/errors.js"></script>
99
100=== modified file 'lib/lp/code/browser/sourcepackagerecipe.py'
101--- lib/lp/code/browser/sourcepackagerecipe.py 2010-11-28 23:32:25 +0000
102+++ lib/lp/code/browser/sourcepackagerecipe.py 2010-12-14 20:58:56 +0000
103@@ -36,10 +36,17 @@
104 Choice,
105 List,
106 Text,
107+ TextLine,
108+ )
109+from zope.schema.vocabulary import (
110+ SimpleTerm,
111+ SimpleVocabulary,
112 )
113
114 from canonical.database.constants import UTC_NOW
115+from canonical.launchpad import _
116 from canonical.launchpad.browser.launchpad import Hierarchy
117+from canonical.launchpad.validators.name import name_validator
118 from canonical.launchpad.webapp import (
119 canonical_url,
120 ContextMenu,
121@@ -54,13 +61,17 @@
122 from canonical.launchpad.webapp.authorization import check_permission
123 from canonical.launchpad.webapp.breadcrumb import Breadcrumb
124 from canonical.widgets.suggestion import RecipeOwnerWidget
125-from canonical.widgets.itemswidgets import LabeledMultiCheckBoxWidget
126+from canonical.widgets.itemswidgets import (
127+ LabeledMultiCheckBoxWidget,
128+ LaunchpadRadioWidget,
129+ )
130 from lp.app.browser.launchpadform import (
131 action,
132 custom_widget,
133 has_structured_doc,
134 LaunchpadEditFormView,
135 LaunchpadFormView,
136+ render_radio_widget_part,
137 )
138 from lp.code.errors import (
139 BuildAlreadyPending,
140@@ -77,6 +88,7 @@
141 ISourcePackageRecipeBuildSource,
142 )
143 from lp.registry.interfaces.pocket import PackagePublishingPocket
144+from lp.soyuz.model.archive import Archive
145
146
147 RECIPE_BETA_MESSAGE = structured(
148@@ -268,7 +280,7 @@
149 self.next_url = self.cancel_url
150
151
152-class ISourcePackageAddEditSchema(Interface):
153+class ISourcePackageEditSchema(Interface):
154 """Schema for adding or editing a recipe."""
155
156 use_template(ISourcePackageRecipe, include=[
157@@ -300,6 +312,38 @@
158 """))
159
160
161+EXISTING_PPA = 'existing-ppa'
162+CREATE_NEW = 'create-new'
163+
164+
165+USE_ARCHIVE_VOCABULARY = SimpleVocabulary((
166+ SimpleTerm(EXISTING_PPA, EXISTING_PPA, _("Use an existing PPA")),
167+ SimpleTerm(
168+ CREATE_NEW, CREATE_NEW, _("Create a new PPA for this recipe")),
169+ ))
170+
171+
172+class ISourcePackageAddSchema(ISourcePackageEditSchema):
173+
174+ daily_build_archive = Choice(vocabulary='TargetPPAs',
175+ title=u'Daily build archive', required=False,
176+ description=(
177+ u'If built daily, this is the archive where the package '
178+ u'will be uploaded.'))
179+
180+ use_ppa = Choice(
181+ title=_('Which PPA'),
182+ vocabulary=USE_ARCHIVE_VOCABULARY,
183+ description=_("Which PPA to use..."),
184+ required=True)
185+
186+ ppa_name = TextLine(
187+ title=_("New PPA name"), required=False,
188+ constraint=name_validator,
189+ description=_("A new PPA with this name will be created for "
190+ "the owner of the recipe ."))
191+
192+
193 class RecipeTextValidatorMixin:
194 """Class to validate that the Source Package Recipe text is valid."""
195
196@@ -321,22 +365,39 @@
197
198 title = label = 'Create a new source package recipe'
199
200- schema = ISourcePackageAddEditSchema
201+ schema = ISourcePackageAddSchema
202 custom_widget('distros', LabeledMultiCheckBoxWidget)
203 custom_widget('owner', RecipeOwnerWidget)
204+ custom_widget('use_ppa', LaunchpadRadioWidget)
205
206 def initialize(self):
207- # XXX: rockstar: This should be removed when source package recipes
208- # are put into production. spec=sourcepackagerecipes
209 super(SourcePackageRecipeAddView, self).initialize()
210+ # XXX: rockstar: This should be removed when source package recipes
211+ # are put into production. spec=sourcepackagerecipes
212 self.request.response.addWarningNotification(RECIPE_BETA_MESSAGE)
213+ widget = self.widgets['use_ppa']
214+ current_value = widget._getFormValue()
215+ self.use_ppa_existing = render_radio_widget_part(
216+ widget, EXISTING_PPA, current_value)
217+ self.use_ppa_new = render_radio_widget_part(
218+ widget, CREATE_NEW, current_value)
219+ archive_widget = self.widgets['daily_build_archive']
220+ self.show_ppa_chooser = len(archive_widget.vocabulary) > 0
221+ if not self.show_ppa_chooser:
222+ self.widgets['ppa_name'].setRenderedValue('ppa')
223+ # Force there to be no '(no value)' item in the select. We do this as
224+ # the input isn't listed as 'required' otherwise the validator gets
225+ # all confused when we want to create a new PPA.
226+ archive_widget._displayItemForMissingValue = False
227
228 @property
229 def initial_values(self):
230 return {
231 'recipe_text': MINIMAL_RECIPE_TEXT % self.context.bzr_identity,
232 'owner': self.user,
233- 'build_daily': False}
234+ 'build_daily': False,
235+ 'use_ppa': EXISTING_PPA,
236+ }
237
238 @property
239 def cancel_url(self):
240@@ -345,11 +406,17 @@
241 @action('Create Recipe', name='create')
242 def request_action(self, action, data):
243 try:
244+ owner = data['owner']
245+ if data['use_ppa'] == CREATE_NEW:
246+ ppa_name = data.get('ppa_name', None)
247+ ppa = owner.createPPA(ppa_name)
248+ else:
249+ ppa = data['daily_build_archive']
250 source_package_recipe = getUtility(
251 ISourcePackageRecipeSource).new(
252- self.user, data['owner'], data['name'],
253+ self.user, owner, data['name'],
254 data['recipe_text'], data['description'], data['distros'],
255- data['daily_build_archive'], data['build_daily'])
256+ ppa, data['build_daily'])
257 Store.of(source_package_recipe).flush()
258 except TooNewRecipeFormat:
259 self.setFieldError(
260@@ -383,6 +450,15 @@
261 'name',
262 'There is already a recipe owned by %s with this name.' %
263 owner.displayname)
264+ if data['use_ppa'] == CREATE_NEW:
265+ ppa_name = data.get('ppa_name', None)
266+ if ppa_name is None:
267+ self.setFieldError(
268+ 'ppa_name', 'You need to specify a name for the PPA.')
269+ else:
270+ error = Archive.validatePPA(owner, ppa_name)
271+ if error is not None:
272+ self.setFieldError('ppa_name', error)
273
274
275 class SourcePackageRecipeEditView(RecipeTextValidatorMixin,
276@@ -394,7 +470,7 @@
277 return 'Edit %s source package recipe' % self.context.name
278 label = title
279
280- schema = ISourcePackageAddEditSchema
281+ schema = ISourcePackageEditSchema
282 custom_widget('distros', LabeledMultiCheckBoxWidget)
283
284 def setUpFields(self):
285@@ -481,7 +557,7 @@
286 @property
287 def adapters(self):
288 """See `LaunchpadEditFormView`"""
289- return {ISourcePackageAddEditSchema: self.context}
290+ return {ISourcePackageEditSchema: self.context}
291
292 def validate(self, data):
293 super(SourcePackageRecipeEditView, self).validate(data)
294
295=== modified file 'lib/lp/code/browser/tests/test_sourcepackagerecipe.py'
296--- lib/lp/code/browser/tests/test_sourcepackagerecipe.py 2010-11-30 11:48:27 +0000
297+++ lib/lp/code/browser/tests/test_sourcepackagerecipe.py 2010-12-14 20:58:56 +0000
298@@ -25,6 +25,7 @@
299 extract_text,
300 find_main_content,
301 find_tags_by_class,
302+ get_radio_button_text_for_field,
303 )
304 from canonical.launchpad.webapp import canonical_url
305 from canonical.testing.layers import (
306@@ -41,6 +42,7 @@
307 )
308 from lp.code.interfaces.sourcepackagerecipe import MINIMAL_RECIPE_TEXT
309 from lp.code.tests.helpers import recipe_parser_newest_version
310+from lp.registry.interfaces.person import TeamSubscriptionPolicy
311 from lp.registry.interfaces.pocket import PackagePublishingPocket
312 from lp.services.propertycache import clear_property_cache
313 from lp.soyuz.model.processor import ProcessorFamily
314@@ -412,6 +414,132 @@
315 get_message_text(browser, 2),
316 'Recipe may not refer to private branch: %s' % bzr_identity)
317
318+ def test_ppa_selector_not_shown_if_user_has_no_ppas(self):
319+ # If the user creating a recipe has no existing PPAs, the selector
320+ # isn't shown, but the field to enter a new PPA name is.
321+ self.user = self.factory.makePerson(password='test')
322+ branch = self.factory.makeAnyBranch()
323+ with person_logged_in(self.user):
324+ content = self.getMainContent(branch, '+new-recipe')
325+ ppa_name = content.find(attrs={'id': 'field.ppa_name'})
326+ self.assertEqual('input', ppa_name.name)
327+ self.assertEqual('text', ppa_name['type'])
328+ # The new ppa name field has an initial value.
329+ self.assertEqual('ppa', ppa_name['value'])
330+ ppa_chooser = content.find(attrs={'id': 'field.daily_build_archive'})
331+ self.assertIs(None, ppa_chooser)
332+ # There is a hidden option to say create a new ppa.
333+ ppa_options = content.find(attrs={'name': 'field.use_ppa'})
334+ self.assertEqual('input', ppa_options.name)
335+ self.assertEqual('hidden', ppa_options['type'])
336+ self.assertEqual('create-new', ppa_options['value'])
337+
338+ def test_ppa_selector_shown_if_user_has_ppas(self):
339+ # If the user creating a recipe has existing PPAs, the selector is
340+ # shown, along with radio buttons to decide whether to use an existing
341+ # ppa or to create a new one.
342+ branch = self.factory.makeAnyBranch()
343+ with person_logged_in(self.user):
344+ content = self.getMainContent(branch, '+new-recipe')
345+ ppa_name = content.find(attrs={'id': 'field.ppa_name'})
346+ self.assertEqual('input', ppa_name.name)
347+ self.assertEqual('text', ppa_name['type'])
348+ # The new ppa name field has no initial value.
349+ self.assertEqual('', ppa_name['value'])
350+ ppa_chooser = content.find(attrs={'id': 'field.daily_build_archive'})
351+ self.assertEqual('select', ppa_chooser.name)
352+ ppa_options = list(
353+ get_radio_button_text_for_field(content, 'use_ppa'))
354+ self.assertEqual(
355+ ['(*) Use an existing PPA',
356+ '( ) Create a new PPA for this recipe'''],
357+ ppa_options)
358+
359+ def test_create_new_ppa(self):
360+ # If the user doesn't have any PPAs, a new once can be created.
361+ self.user = self.factory.makePerson(name='eric', password='test')
362+ branch = self.factory.makeAnyBranch()
363+
364+ # A new recipe can be created from the branch page.
365+ browser = self.getUserBrowser(canonical_url(branch), user=self.user)
366+ browser.getLink('Create packaging recipe').click()
367+
368+ browser.getControl(name='field.name').value = 'name'
369+ browser.getControl('Description').value = 'Make some food!'
370+ browser.getControl('Secret Squirrel').click()
371+ browser.getControl('Create Recipe').click()
372+
373+ # A new recipe is created in a new PPA.
374+ self.assertTrue(browser.url.endswith('/~eric/+recipe/name'))
375+ # Since no PPA name was entered, the default name (ppa) was used.
376+ login(ANONYMOUS)
377+ new_ppa = self.user.getPPAByName('ppa')
378+ self.assertIsNot(None, new_ppa)
379+
380+ def test_create_new_ppa_duplicate(self):
381+ # If a new PPA is being created, and the user already has a ppa of the
382+ # name specifed an error is shown.
383+ self.user = self.factory.makePerson(name='eric', password='test')
384+ # Make a PPA called 'ppa' using the default.
385+ self.user.createPPA(name='foo')
386+ branch = self.factory.makeAnyBranch()
387+
388+ # A new recipe can be created from the branch page.
389+ browser = self.getUserBrowser(canonical_url(branch), user=self.user)
390+ browser.getLink('Create packaging recipe').click()
391+ browser.getControl(name='field.name').value = 'name'
392+ browser.getControl('Description').value = 'Make some food!'
393+ browser.getControl('Secret Squirrel').click()
394+ browser.getControl('Create a new PPA').click()
395+ browser.getControl(name='field.ppa_name').value = 'foo'
396+ browser.getControl('Create Recipe').click()
397+ self.assertEqual(
398+ get_message_text(browser, 2),
399+ "You already have a PPA named 'foo'.")
400+
401+ def test_create_new_ppa_missing_name(self):
402+ # If a new PPA is being created, and the user has not specified a
403+ # name, an error is shown.
404+ self.user = self.factory.makePerson(name='eric', password='test')
405+ branch = self.factory.makeAnyBranch()
406+
407+ # A new recipe can be created from the branch page.
408+ browser = self.getUserBrowser(canonical_url(branch), user=self.user)
409+ browser.getLink('Create packaging recipe').click()
410+ browser.getControl(name='field.name').value = 'name'
411+ browser.getControl('Description').value = 'Make some food!'
412+ browser.getControl('Secret Squirrel').click()
413+ browser.getControl(name='field.ppa_name').value = ''
414+ browser.getControl('Create Recipe').click()
415+ self.assertEqual(
416+ get_message_text(browser, 2),
417+ "You need to specify a name for the PPA.")
418+
419+ def test_create_new_ppa_owned_by_recipe_owner(self):
420+ # The new PPA that is created is owned by the recipe owner.
421+ self.user = self.factory.makePerson(name='eric', password='test')
422+ team = self.factory.makeTeam(
423+ name='vikings', members=[self.user],
424+ subscription_policy=TeamSubscriptionPolicy.MODERATED)
425+ branch = self.factory.makeAnyBranch(owner=team)
426+
427+ # A new recipe can be created from the branch page.
428+ browser = self.getUserBrowser(canonical_url(branch), user=self.user)
429+ browser.getLink('Create packaging recipe').click()
430+
431+ browser.getControl(name='field.name').value = 'name'
432+ browser.getControl('Description').value = 'Make some food!'
433+ browser.getControl(name='field.owner').value = ['vikings']
434+ browser.getControl('Secret Squirrel').click()
435+ browser.getControl('Create Recipe').click()
436+
437+ # A new recipe is created in a new PPA.
438+ self.assertTrue(browser.url.endswith('/~vikings/+recipe/name'))
439+ # Since no PPA name was entered, the default name (ppa) was used.
440+ login(ANONYMOUS)
441+ new_ppa = team.getPPAByName('ppa')
442+ self.assertIsNot(None, new_ppa)
443+
444
445 class TestSourcePackageRecipeEditView(TestCaseForRecipe):
446 """Test the editing behaviour of a source package recipe."""
447
448=== added file 'lib/lp/code/javascript/sourcepackagerecipe.new.js'
449--- lib/lp/code/javascript/sourcepackagerecipe.new.js 1970-01-01 00:00:00 +0000
450+++ lib/lp/code/javascript/sourcepackagerecipe.new.js 2010-12-14 20:58:56 +0000
451@@ -0,0 +1,56 @@
452+/* Copyright 2010 Canonical Ltd. This software is licensed under the
453+ * GNU Affero General Public License version 3 (see the file LICENSE).
454+ *
455+ * Control enabling/disabling form elements on the +new-recipe page.
456+ *
457+ * @module Y.lp.code.sourcepackagerecipe.new
458+ * @requires node, DOM
459+ */
460+YUI.add('lp.code.sourcepackagerecipe.new', function(Y) {
461+ Y.log('loading lp.code.sourcepackagerecipe.new');
462+ var module = Y.namespace('lp.code.sourcepackagerecipe.new');
463+
464+ function getRadioSelectedValue(selector) {
465+ var tmpValue= false;
466+ Y.all(selector).each(function(node) {
467+ if (node.get('checked'))
468+ tmpValue = node.get('value');
469+ });
470+ return tmpValue;
471+ }
472+
473+ var PPA_SELECTOR_ID = 'field.daily_build_archive';
474+ var PPA_NAME_ID = 'field.ppa_name';
475+ var set_field_focus = false;
476+
477+ function set_enabled(field_id, is_enabled) {
478+ var field = Y.DOM.byId(field_id);
479+ field.disabled = !is_enabled;
480+ if (is_enabled && set_field_focus) field.focus();
481+ }
482+
483+ module.onclick_use_ppa = function(e) {
484+ var value = getRadioSelectedValue('input[name=field.use_ppa]');
485+ if (value == 'create-new') {
486+ set_enabled(PPA_NAME_ID, true);
487+ set_enabled(PPA_SELECTOR_ID, false);
488+ }
489+ else {
490+ set_enabled(PPA_NAME_ID, false);
491+ set_enabled(PPA_SELECTOR_ID, true);
492+ }
493+ };
494+
495+ module.setup = function() {
496+ Y.all('input[name=field.use_ppa]').on(
497+ 'click', module.onclick_use_ppa);
498+
499+ // Set the initial state.
500+ module.onclick_use_ppa();
501+ // And from now on, set the focus to the active input field when the
502+ // radio button is clicked.
503+ set_field_focus = true;
504+ };
505+
506+ }, "0.1", {"requires": ["node", "DOM"]}
507+);
508
509=== modified file 'lib/lp/code/model/sourcepackagerecipe.py'
510--- lib/lp/code/model/sourcepackagerecipe.py 2010-12-01 11:26:57 +0000
511+++ lib/lp/code/model/sourcepackagerecipe.py 2010-12-14 20:58:56 +0000
512@@ -29,6 +29,7 @@
513 )
514
515 from canonical.database.datetimecol import UtcDateTimeCol
516+from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
517 from canonical.launchpad.interfaces.lpstorm import (
518 IMasterStore,
519 IStore,
520@@ -62,7 +63,9 @@
521
522 def get_buildable_distroseries_set(user):
523 ppas = getUtility(IArchiveSet).getPPAsForUser(user)
524- supported_distros = [ppa.distribution for ppa in ppas]
525+ supported_distros = set([ppa.distribution for ppa in ppas])
526+ # Now add in Ubuntu.
527+ supported_distros.add(getUtility(ILaunchpadCelebrities).ubuntu)
528 distros = getUtility(IDistroSeriesSet).search()
529
530 buildables = []
531
532=== modified file 'lib/lp/code/templates/sourcepackagerecipe-new.pt'
533--- lib/lp/code/templates/sourcepackagerecipe-new.pt 2010-11-25 03:35:05 +0000
534+++ lib/lp/code/templates/sourcepackagerecipe-new.pt 2010-12-14 20:58:56 +0000
535@@ -6,6 +6,21 @@
536 metal:use-macro="view/macro:page/main_only"
537 i18n:domain="launchpad">
538 <body>
539+
540+<metal:block fill-slot="head_epilogue">
541+ <style type="text/css">
542+ .root-choice input[type="radio"] {
543+ margin-left: 0;
544+ }
545+ .root-choice label {
546+ font-weight: bold !important;
547+ }
548+ .subordinate {
549+ margin: 0.5em 0 0.5em 2em;
550+ }
551+ </style>
552+</metal:block>
553+
554 <div metal:fill-slot="main">
555
556 <div>
557@@ -23,8 +38,76 @@
558
559 </div>
560
561- <div metal:use-macro="context/@@launchpad_form/form" />
562-
563+ <div metal:use-macro="context/@@launchpad_form/form">
564+
565+ <metal:formbody fill-slot="widgets">
566+
567+ <table class="form">
568+
569+ <tal:widget define="widget nocall:view/widgets/name">
570+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
571+ </tal:widget>
572+ <tal:widget define="widget nocall:view/widgets/description">
573+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
574+ </tal:widget>
575+ <tal:widget define="widget nocall:view/widgets/owner">
576+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
577+ </tal:widget>
578+ <tal:widget define="widget nocall:view/widgets/build_daily">
579+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
580+ </tal:widget>
581+
582+ <tal:show-ppa-choice condition="view/show_ppa_chooser">
583+ <tr>
584+ <td class='root-choice'>
585+ <label tal:replace="structure view/use_ppa_existing">
586+ Use existing PPA
587+ </label>
588+ <table class="subordinate">
589+ <tal:widget define="widget nocall:view/widgets/daily_build_archive">
590+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
591+ </tal:widget>
592+ </table>
593+ </td>
594+ </tr>
595+
596+ <tr>
597+ <td class='root-choice'>
598+ <label tal:replace="structure view/use_ppa_new">
599+ Create new PPA
600+ </label>
601+ <table class="subordinate">
602+ <tal:widget define="widget nocall:view/widgets/ppa_name">
603+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
604+ </tal:widget>
605+ </table>
606+ </td>
607+ </tr>
608+
609+ <script type="text/javascript">
610+ LPS.use('lp.code.sourcepackagerecipe.new', function(Y) {
611+ Y.on('domready', Y.lp.code.sourcepackagerecipe.new.setup);
612+ });
613+ </script>
614+ </tal:show-ppa-choice>
615+
616+ <tal:create-ppa condition="not: view/show_ppa_chooser">
617+ <input name="field.use_ppa" value="create-new" type="hidden"/>
618+ <tal:widget define="widget nocall:view/widgets/ppa_name">
619+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
620+ </tal:widget>
621+ </tal:create-ppa>
622+
623+ <tal:widget define="widget nocall:view/widgets/distros">
624+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
625+ </tal:widget>
626+ <tal:widget define="widget nocall:view/widgets/recipe_text">
627+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
628+ </tal:widget>
629+
630+ </table>
631+ </metal:formbody>
632+ </div>
633 </div>
634 </body>
635 </html>
636
637=== modified file 'lib/lp/registry/browser/productseries.py'
638--- lib/lp/registry/browser/productseries.py 2010-11-23 23:22:27 +0000
639+++ lib/lp/registry/browser/productseries.py 2010-12-14 20:58:56 +0000
640@@ -81,6 +81,7 @@
641 custom_widget,
642 LaunchpadEditFormView,
643 LaunchpadFormView,
644+ render_radio_widget_part,
645 ReturnToReferrerMixin,
646 )
647 from lp.app.browser.tales import MenuAPI
648@@ -913,31 +914,19 @@
649 def setUpWidgets(self):
650 """See `LaunchpadFormView`."""
651 super(ProductSeriesSetBranchView, self).setUpWidgets()
652-
653- def render(widget, term_value, current_value, label=None):
654- term = widget.vocabulary.getTerm(term_value)
655- if term.value == current_value:
656- render = widget.renderSelectedItem
657- else:
658- render = widget.renderItem
659- if label is None:
660- label = term.title
661- value = term.token
662- return render(index=term.value,
663- text=label,
664- value=value,
665- name=widget.name,
666- cssClass='')
667-
668 widget = self.widgets['rcs_type']
669 vocab = widget.vocabulary
670 current_value = widget._getFormValue()
671- self.rcs_type_cvs = render(widget, vocab.CVS, current_value, 'CVS')
672- self.rcs_type_svn = render(widget, vocab.BZR_SVN, current_value,
673- 'SVN')
674- self.rcs_type_git = render(widget, vocab.GIT, current_value)
675- self.rcs_type_hg = render(widget, vocab.HG, current_value)
676- self.rcs_type_bzr = render(widget, vocab.BZR, current_value)
677+ self.rcs_type_cvs = render_radio_widget_part(
678+ widget, vocab.CVS, current_value, 'CVS')
679+ self.rcs_type_svn = render_radio_widget_part(
680+ widget, vocab.BZR_SVN, current_value, 'SVN')
681+ self.rcs_type_git = render_radio_widget_part(
682+ widget, vocab.GIT, current_value)
683+ self.rcs_type_hg = render_radio_widget_part(
684+ widget, vocab.HG, current_value)
685+ self.rcs_type_bzr = render_radio_widget_part(
686+ widget, vocab.BZR, current_value)
687 self.rcs_type_emptymarker = widget._emptyMarker()
688
689 widget = self.widgets['branch_type']
690@@ -947,7 +936,7 @@
691 (self.branch_type_link,
692 self.branch_type_create,
693 self.branch_type_import) = [
694- render(widget, value, current_value)
695+ render_radio_widget_part(widget, value, current_value)
696 for value in (LINK_LP_BZR, CREATE_NEW, IMPORT_EXTERNAL)]
697
698 def _validateLinkLpBzr(self, data):
699
700=== modified file 'lib/lp/soyuz/model/archive.py'
701--- lib/lp/soyuz/model/archive.py 2010-12-13 06:44:16 +0000
702+++ lib/lp/soyuz/model/archive.py 2010-12-14 20:58:56 +0000
703@@ -1716,7 +1716,7 @@
704
705 enabled_restricted_families = property(_getEnabledRestrictedFamilies,
706 _setEnabledRestrictedFamilies)
707-
708+
709 @classmethod
710 def validatePPA(self, person, proposed_name):
711 ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
712
713=== modified file 'lib/lp/testing/__init__.py'
714--- lib/lp/testing/__init__.py 2010-12-13 10:04:34 +0000
715+++ lib/lp/testing/__init__.py 2010-12-14 20:58:56 +0000
716@@ -715,14 +715,20 @@
717 else:
718 return self.getUserBrowser(url, self.user)
719
720- def getMainText(
721- self, context, view_name=None, rootsite=None, no_login=False):
722- """Return the main text of a context's page."""
723- from canonical.launchpad.testing.pages import (
724- extract_text, find_main_content)
725+ def getMainContent(self, context, view_name=None, rootsite=None,
726+ no_login=False):
727+ """Beautiful soup of the main content area of context's page."""
728+ from canonical.launchpad.testing.pages import find_main_content
729 browser = self.getViewBrowser(
730 context, view_name, rootsite=rootsite, no_login=no_login)
731- return extract_text(find_main_content(browser.contents))
732+ return find_main_content(browser.contents)
733+
734+ def getMainText(self, context, view_name=None, rootsite=None,
735+ no_login=False):
736+ """Return the main text of a context's page."""
737+ from canonical.launchpad.testing.pages import extract_text
738+ return extract_text(
739+ self.getMainContent(context, view_name, rootsite, no_login))
740
741
742 class WindmillTestCase(TestCaseWithFactory):