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
=== 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-14 20:58:56 +0000
@@ -319,27 +319,37 @@
319 print sep.join(row_content)319 print sep.join(row_content)
320320
321321
322def print_radio_button_field(content, name):322def get_radio_button_text_for_field(soup, name):
323 """Find the input called field.name, and print a friendly representation.323 """Find the input called field.name, and return an iterable of strings.
324324
325 The resulting output will look something like:325 The resulting output will look something like:
326 (*) A checked option326 ['(*) A checked option', '( ) An unchecked option']
327 ( ) An unchecked option
328 """327 """
329 main = BeautifulSoup(content)328 buttons = soup.findAll(
330 buttons = main.findAll(
331 'input', {'name': 'field.%s' % name})329 'input', {'name': 'field.%s' % name})
332 for button in buttons:330 for button in buttons:
333 if button.parent.name == 'label':331 if button.parent.name == 'label':
334 label = extract_text(button.parent)332 label = extract_text(button.parent)
335 else:333 else:
336 label = extract_text(334 label = extract_text(
337 main.find('label', attrs={'for': button['id']}))335 soup.find('label', attrs={'for': button['id']}))
338 if button.get('checked', None):336 if button.get('checked', None):
339 radio = '(*)'337 radio = '(*)'
340 else:338 else:
341 radio = '( )'339 radio = '( )'
342 print radio, label340 yield "%s %s" % (radio, label)
341
342
343def print_radio_button_field(content, name):
344 """Find the input called field.name, and print a friendly representation.
345
346 The resulting output will look something like:
347 (*) A checked option
348 ( ) An unchecked option
349 """
350 main = BeautifulSoup(content)
351 for field in get_radio_button_text_for_field(main, name):
352 print field
343353
344354
345def strip_label(label):355def strip_label(label):
346356
=== 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-14 20:58:56 +0000
@@ -12,6 +12,7 @@
12 'has_structured_doc',12 'has_structured_doc',
13 'LaunchpadEditFormView',13 'LaunchpadEditFormView',
14 'LaunchpadFormView',14 'LaunchpadFormView',
15 'render_radio_widget_part',
15 'ReturnToReferrerMixin',16 'ReturnToReferrerMixin',
16 'safe_action',17 'safe_action',
17 ]18 ]
@@ -543,3 +544,22 @@
543 "There should be no further path segments after "544 "There should be no further path segments after "
544 "query:has-structured-doc")545 "query:has-structured-doc")
545 return self.widget.context.queryTaggedValue('has_structured_doc')546 return self.widget.context.queryTaggedValue('has_structured_doc')
547
548
549def render_radio_widget_part(widget, term_value, current_value, label=None):
550 """Render a particular term for a radio button widget.
551
552 This may well work for other widgets, but has only been tested with radio
553 button widgets.
554 """
555 term = widget.vocabulary.getTerm(term_value)
556 if term.value == current_value:
557 render = widget.renderSelectedItem
558 else:
559 render = widget.renderItem
560 if label is None:
561 label = term.title
562 value = term.token
563 return render(
564 index=term.value, text=label, value=value, name=widget.name,
565 cssClass='')
546566
=== modified file 'lib/lp/app/templates/base-layout-macros.pt'
--- lib/lp/app/templates/base-layout-macros.pt 2010-12-13 18:04:24 +0000
+++ lib/lp/app/templates/base-layout-macros.pt 2010-12-14 20:58:56 +0000
@@ -607,6 +607,9 @@
607 tal:attributes="src string:${lp_js}/code/productseries-setbranch.js">607 tal:attributes="src string:${lp_js}/code/productseries-setbranch.js">
608 </script>608 </script>
609 <script type="text/javascript"609 <script type="text/javascript"
610 tal:attributes="src string:${lp_js}/code/sourcepackagerecipe.new.js">
611 </script>
612 <script type="text/javascript"
610 tal:attributes="src string:${lp_js}/app/comment.js"></script>613 tal:attributes="src string:${lp_js}/app/comment.js"></script>
611 <script type="text/javascript"614 <script type="text/javascript"
612 tal:attributes="src string:${lp_js}/app/errors.js"></script>615 tal:attributes="src string:${lp_js}/app/errors.js"></script>
613616
=== 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-14 20:58:56 +0000
@@ -36,10 +36,17 @@
36 Choice,36 Choice,
37 List,37 List,
38 Text,38 Text,
39 TextLine,
40 )
41from zope.schema.vocabulary import (
42 SimpleTerm,
43 SimpleVocabulary,
39 )44 )
4045
41from canonical.database.constants import UTC_NOW46from canonical.database.constants import UTC_NOW
47from canonical.launchpad import _
42from canonical.launchpad.browser.launchpad import Hierarchy48from canonical.launchpad.browser.launchpad import Hierarchy
49from canonical.launchpad.validators.name import name_validator
43from canonical.launchpad.webapp import (50from canonical.launchpad.webapp import (
44 canonical_url,51 canonical_url,
45 ContextMenu,52 ContextMenu,
@@ -54,13 +61,17 @@
54from canonical.launchpad.webapp.authorization import check_permission61from canonical.launchpad.webapp.authorization import check_permission
55from canonical.launchpad.webapp.breadcrumb import Breadcrumb62from canonical.launchpad.webapp.breadcrumb import Breadcrumb
56from canonical.widgets.suggestion import RecipeOwnerWidget63from canonical.widgets.suggestion import RecipeOwnerWidget
57from canonical.widgets.itemswidgets import LabeledMultiCheckBoxWidget64from canonical.widgets.itemswidgets import (
65 LabeledMultiCheckBoxWidget,
66 LaunchpadRadioWidget,
67 )
58from lp.app.browser.launchpadform import (68from lp.app.browser.launchpadform import (
59 action,69 action,
60 custom_widget,70 custom_widget,
61 has_structured_doc,71 has_structured_doc,
62 LaunchpadEditFormView,72 LaunchpadEditFormView,
63 LaunchpadFormView,73 LaunchpadFormView,
74 render_radio_widget_part,
64 )75 )
65from lp.code.errors import (76from lp.code.errors import (
66 BuildAlreadyPending,77 BuildAlreadyPending,
@@ -77,6 +88,7 @@
77 ISourcePackageRecipeBuildSource,88 ISourcePackageRecipeBuildSource,
78 )89 )
79from lp.registry.interfaces.pocket import PackagePublishingPocket90from lp.registry.interfaces.pocket import PackagePublishingPocket
91from lp.soyuz.model.archive import Archive
8092
8193
82RECIPE_BETA_MESSAGE = structured(94RECIPE_BETA_MESSAGE = structured(
@@ -268,7 +280,7 @@
268 self.next_url = self.cancel_url280 self.next_url = self.cancel_url
269281
270282
271class ISourcePackageAddEditSchema(Interface):283class ISourcePackageEditSchema(Interface):
272 """Schema for adding or editing a recipe."""284 """Schema for adding or editing a recipe."""
273285
274 use_template(ISourcePackageRecipe, include=[286 use_template(ISourcePackageRecipe, include=[
@@ -300,6 +312,38 @@
300 """))312 """))
301313
302314
315EXISTING_PPA = 'existing-ppa'
316CREATE_NEW = 'create-new'
317
318
319USE_ARCHIVE_VOCABULARY = SimpleVocabulary((
320 SimpleTerm(EXISTING_PPA, EXISTING_PPA, _("Use an existing PPA")),
321 SimpleTerm(
322 CREATE_NEW, CREATE_NEW, _("Create a new PPA for this recipe")),
323 ))
324
325
326class ISourcePackageAddSchema(ISourcePackageEditSchema):
327
328 daily_build_archive = Choice(vocabulary='TargetPPAs',
329 title=u'Daily build archive', required=False,
330 description=(
331 u'If built daily, this is the archive where the package '
332 u'will be uploaded.'))
333
334 use_ppa = Choice(
335 title=_('Which PPA'),
336 vocabulary=USE_ARCHIVE_VOCABULARY,
337 description=_("Which PPA to use..."),
338 required=True)
339
340 ppa_name = TextLine(
341 title=_("New PPA name"), required=False,
342 constraint=name_validator,
343 description=_("A new PPA with this name will be created for "
344 "the owner of the recipe ."))
345
346
303class RecipeTextValidatorMixin:347class RecipeTextValidatorMixin:
304 """Class to validate that the Source Package Recipe text is valid."""348 """Class to validate that the Source Package Recipe text is valid."""
305349
@@ -321,22 +365,39 @@
321365
322 title = label = 'Create a new source package recipe'366 title = label = 'Create a new source package recipe'
323367
324 schema = ISourcePackageAddEditSchema368 schema = ISourcePackageAddSchema
325 custom_widget('distros', LabeledMultiCheckBoxWidget)369 custom_widget('distros', LabeledMultiCheckBoxWidget)
326 custom_widget('owner', RecipeOwnerWidget)370 custom_widget('owner', RecipeOwnerWidget)
371 custom_widget('use_ppa', LaunchpadRadioWidget)
327372
328 def initialize(self):373 def initialize(self):
329 # XXX: rockstar: This should be removed when source package recipes
330 # are put into production. spec=sourcepackagerecipes
331 super(SourcePackageRecipeAddView, self).initialize()374 super(SourcePackageRecipeAddView, self).initialize()
375 # XXX: rockstar: This should be removed when source package recipes
376 # are put into production. spec=sourcepackagerecipes
332 self.request.response.addWarningNotification(RECIPE_BETA_MESSAGE)377 self.request.response.addWarningNotification(RECIPE_BETA_MESSAGE)
378 widget = self.widgets['use_ppa']
379 current_value = widget._getFormValue()
380 self.use_ppa_existing = render_radio_widget_part(
381 widget, EXISTING_PPA, current_value)
382 self.use_ppa_new = render_radio_widget_part(
383 widget, CREATE_NEW, current_value)
384 archive_widget = self.widgets['daily_build_archive']
385 self.show_ppa_chooser = len(archive_widget.vocabulary) > 0
386 if not self.show_ppa_chooser:
387 self.widgets['ppa_name'].setRenderedValue('ppa')
388 # Force there to be no '(no value)' item in the select. We do this as
389 # the input isn't listed as 'required' otherwise the validator gets
390 # all confused when we want to create a new PPA.
391 archive_widget._displayItemForMissingValue = False
333392
334 @property393 @property
335 def initial_values(self):394 def initial_values(self):
336 return {395 return {
337 'recipe_text': MINIMAL_RECIPE_TEXT % self.context.bzr_identity,396 'recipe_text': MINIMAL_RECIPE_TEXT % self.context.bzr_identity,
338 'owner': self.user,397 'owner': self.user,
339 'build_daily': False}398 'build_daily': False,
399 'use_ppa': EXISTING_PPA,
400 }
340401
341 @property402 @property
342 def cancel_url(self):403 def cancel_url(self):
@@ -345,11 +406,17 @@
345 @action('Create Recipe', name='create')406 @action('Create Recipe', name='create')
346 def request_action(self, action, data):407 def request_action(self, action, data):
347 try:408 try:
409 owner = data['owner']
410 if data['use_ppa'] == CREATE_NEW:
411 ppa_name = data.get('ppa_name', None)
412 ppa = owner.createPPA(ppa_name)
413 else:
414 ppa = data['daily_build_archive']
348 source_package_recipe = getUtility(415 source_package_recipe = getUtility(
349 ISourcePackageRecipeSource).new(416 ISourcePackageRecipeSource).new(
350 self.user, data['owner'], data['name'],417 self.user, owner, data['name'],
351 data['recipe_text'], data['description'], data['distros'],418 data['recipe_text'], data['description'], data['distros'],
352 data['daily_build_archive'], data['build_daily'])419 ppa, data['build_daily'])
353 Store.of(source_package_recipe).flush()420 Store.of(source_package_recipe).flush()
354 except TooNewRecipeFormat:421 except TooNewRecipeFormat:
355 self.setFieldError(422 self.setFieldError(
@@ -383,6 +450,15 @@
383 'name',450 'name',
384 'There is already a recipe owned by %s with this name.' %451 'There is already a recipe owned by %s with this name.' %
385 owner.displayname)452 owner.displayname)
453 if data['use_ppa'] == CREATE_NEW:
454 ppa_name = data.get('ppa_name', None)
455 if ppa_name is None:
456 self.setFieldError(
457 'ppa_name', 'You need to specify a name for the PPA.')
458 else:
459 error = Archive.validatePPA(owner, ppa_name)
460 if error is not None:
461 self.setFieldError('ppa_name', error)
386462
387463
388class SourcePackageRecipeEditView(RecipeTextValidatorMixin,464class SourcePackageRecipeEditView(RecipeTextValidatorMixin,
@@ -394,7 +470,7 @@
394 return 'Edit %s source package recipe' % self.context.name470 return 'Edit %s source package recipe' % self.context.name
395 label = title471 label = title
396472
397 schema = ISourcePackageAddEditSchema473 schema = ISourcePackageEditSchema
398 custom_widget('distros', LabeledMultiCheckBoxWidget)474 custom_widget('distros', LabeledMultiCheckBoxWidget)
399475
400 def setUpFields(self):476 def setUpFields(self):
@@ -481,7 +557,7 @@
481 @property557 @property
482 def adapters(self):558 def adapters(self):
483 """See `LaunchpadEditFormView`"""559 """See `LaunchpadEditFormView`"""
484 return {ISourcePackageAddEditSchema: self.context}560 return {ISourcePackageEditSchema: self.context}
485561
486 def validate(self, data):562 def validate(self, data):
487 super(SourcePackageRecipeEditView, self).validate(data)563 super(SourcePackageRecipeEditView, self).validate(data)
488564
=== 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-14 20:58:56 +0000
@@ -25,6 +25,7 @@
25 extract_text,25 extract_text,
26 find_main_content,26 find_main_content,
27 find_tags_by_class,27 find_tags_by_class,
28 get_radio_button_text_for_field,
28 )29 )
29from canonical.launchpad.webapp import canonical_url30from canonical.launchpad.webapp import canonical_url
30from canonical.testing.layers import (31from canonical.testing.layers import (
@@ -41,6 +42,7 @@
41 )42 )
42from lp.code.interfaces.sourcepackagerecipe import MINIMAL_RECIPE_TEXT43from lp.code.interfaces.sourcepackagerecipe import MINIMAL_RECIPE_TEXT
43from lp.code.tests.helpers import recipe_parser_newest_version44from lp.code.tests.helpers import recipe_parser_newest_version
45from lp.registry.interfaces.person import TeamSubscriptionPolicy
44from lp.registry.interfaces.pocket import PackagePublishingPocket46from lp.registry.interfaces.pocket import PackagePublishingPocket
45from lp.services.propertycache import clear_property_cache47from lp.services.propertycache import clear_property_cache
46from lp.soyuz.model.processor import ProcessorFamily48from lp.soyuz.model.processor import ProcessorFamily
@@ -412,6 +414,132 @@
412 get_message_text(browser, 2),414 get_message_text(browser, 2),
413 'Recipe may not refer to private branch: %s' % bzr_identity)415 'Recipe may not refer to private branch: %s' % bzr_identity)
414416
417 def test_ppa_selector_not_shown_if_user_has_no_ppas(self):
418 # If the user creating a recipe has no existing PPAs, the selector
419 # isn't shown, but the field to enter a new PPA name is.
420 self.user = self.factory.makePerson(password='test')
421 branch = self.factory.makeAnyBranch()
422 with person_logged_in(self.user):
423 content = self.getMainContent(branch, '+new-recipe')
424 ppa_name = content.find(attrs={'id': 'field.ppa_name'})
425 self.assertEqual('input', ppa_name.name)
426 self.assertEqual('text', ppa_name['type'])
427 # The new ppa name field has an initial value.
428 self.assertEqual('ppa', ppa_name['value'])
429 ppa_chooser = content.find(attrs={'id': 'field.daily_build_archive'})
430 self.assertIs(None, ppa_chooser)
431 # There is a hidden option to say create a new ppa.
432 ppa_options = content.find(attrs={'name': 'field.use_ppa'})
433 self.assertEqual('input', ppa_options.name)
434 self.assertEqual('hidden', ppa_options['type'])
435 self.assertEqual('create-new', ppa_options['value'])
436
437 def test_ppa_selector_shown_if_user_has_ppas(self):
438 # If the user creating a recipe has existing PPAs, the selector is
439 # shown, along with radio buttons to decide whether to use an existing
440 # ppa or to create a new one.
441 branch = self.factory.makeAnyBranch()
442 with person_logged_in(self.user):
443 content = self.getMainContent(branch, '+new-recipe')
444 ppa_name = content.find(attrs={'id': 'field.ppa_name'})
445 self.assertEqual('input', ppa_name.name)
446 self.assertEqual('text', ppa_name['type'])
447 # The new ppa name field has no initial value.
448 self.assertEqual('', ppa_name['value'])
449 ppa_chooser = content.find(attrs={'id': 'field.daily_build_archive'})
450 self.assertEqual('select', ppa_chooser.name)
451 ppa_options = list(
452 get_radio_button_text_for_field(content, 'use_ppa'))
453 self.assertEqual(
454 ['(*) Use an existing PPA',
455 '( ) Create a new PPA for this recipe'''],
456 ppa_options)
457
458 def test_create_new_ppa(self):
459 # If the user doesn't have any PPAs, a new once can be created.
460 self.user = self.factory.makePerson(name='eric', password='test')
461 branch = self.factory.makeAnyBranch()
462
463 # A new recipe can be created from the branch page.
464 browser = self.getUserBrowser(canonical_url(branch), user=self.user)
465 browser.getLink('Create packaging recipe').click()
466
467 browser.getControl(name='field.name').value = 'name'
468 browser.getControl('Description').value = 'Make some food!'
469 browser.getControl('Secret Squirrel').click()
470 browser.getControl('Create Recipe').click()
471
472 # A new recipe is created in a new PPA.
473 self.assertTrue(browser.url.endswith('/~eric/+recipe/name'))
474 # Since no PPA name was entered, the default name (ppa) was used.
475 login(ANONYMOUS)
476 new_ppa = self.user.getPPAByName('ppa')
477 self.assertIsNot(None, new_ppa)
478
479 def test_create_new_ppa_duplicate(self):
480 # If a new PPA is being created, and the user already has a ppa of the
481 # name specifed an error is shown.
482 self.user = self.factory.makePerson(name='eric', password='test')
483 # Make a PPA called 'ppa' using the default.
484 self.user.createPPA(name='foo')
485 branch = self.factory.makeAnyBranch()
486
487 # A new recipe can be created from the branch page.
488 browser = self.getUserBrowser(canonical_url(branch), user=self.user)
489 browser.getLink('Create packaging recipe').click()
490 browser.getControl(name='field.name').value = 'name'
491 browser.getControl('Description').value = 'Make some food!'
492 browser.getControl('Secret Squirrel').click()
493 browser.getControl('Create a new PPA').click()
494 browser.getControl(name='field.ppa_name').value = 'foo'
495 browser.getControl('Create Recipe').click()
496 self.assertEqual(
497 get_message_text(browser, 2),
498 "You already have a PPA named 'foo'.")
499
500 def test_create_new_ppa_missing_name(self):
501 # If a new PPA is being created, and the user has not specified a
502 # name, an error is shown.
503 self.user = self.factory.makePerson(name='eric', password='test')
504 branch = self.factory.makeAnyBranch()
505
506 # A new recipe can be created from the branch page.
507 browser = self.getUserBrowser(canonical_url(branch), user=self.user)
508 browser.getLink('Create packaging recipe').click()
509 browser.getControl(name='field.name').value = 'name'
510 browser.getControl('Description').value = 'Make some food!'
511 browser.getControl('Secret Squirrel').click()
512 browser.getControl(name='field.ppa_name').value = ''
513 browser.getControl('Create Recipe').click()
514 self.assertEqual(
515 get_message_text(browser, 2),
516 "You need to specify a name for the PPA.")
517
518 def test_create_new_ppa_owned_by_recipe_owner(self):
519 # The new PPA that is created is owned by the recipe owner.
520 self.user = self.factory.makePerson(name='eric', password='test')
521 team = self.factory.makeTeam(
522 name='vikings', members=[self.user],
523 subscription_policy=TeamSubscriptionPolicy.MODERATED)
524 branch = self.factory.makeAnyBranch(owner=team)
525
526 # A new recipe can be created from the branch page.
527 browser = self.getUserBrowser(canonical_url(branch), user=self.user)
528 browser.getLink('Create packaging recipe').click()
529
530 browser.getControl(name='field.name').value = 'name'
531 browser.getControl('Description').value = 'Make some food!'
532 browser.getControl(name='field.owner').value = ['vikings']
533 browser.getControl('Secret Squirrel').click()
534 browser.getControl('Create Recipe').click()
535
536 # A new recipe is created in a new PPA.
537 self.assertTrue(browser.url.endswith('/~vikings/+recipe/name'))
538 # Since no PPA name was entered, the default name (ppa) was used.
539 login(ANONYMOUS)
540 new_ppa = team.getPPAByName('ppa')
541 self.assertIsNot(None, new_ppa)
542
415543
416class TestSourcePackageRecipeEditView(TestCaseForRecipe):544class TestSourcePackageRecipeEditView(TestCaseForRecipe):
417 """Test the editing behaviour of a source package recipe."""545 """Test the editing behaviour of a source package recipe."""
418546
=== 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-14 20:58:56 +0000
@@ -0,0 +1,56 @@
1/* Copyright 2010 Canonical Ltd. This software is licensed under the
2 * GNU Affero General Public License version 3 (see the file LICENSE).
3 *
4 * Control enabling/disabling form elements on the +new-recipe page.
5 *
6 * @module Y.lp.code.sourcepackagerecipe.new
7 * @requires node, DOM
8 */
9YUI.add('lp.code.sourcepackagerecipe.new', function(Y) {
10 Y.log('loading lp.code.sourcepackagerecipe.new');
11 var module = Y.namespace('lp.code.sourcepackagerecipe.new');
12
13 function getRadioSelectedValue(selector) {
14 var tmpValue= false;
15 Y.all(selector).each(function(node) {
16 if (node.get('checked'))
17 tmpValue = node.get('value');
18 });
19 return tmpValue;
20 }
21
22 var PPA_SELECTOR_ID = 'field.daily_build_archive';
23 var PPA_NAME_ID = 'field.ppa_name';
24 var set_field_focus = false;
25
26 function set_enabled(field_id, is_enabled) {
27 var field = Y.DOM.byId(field_id);
28 field.disabled = !is_enabled;
29 if (is_enabled && set_field_focus) field.focus();
30 }
31
32 module.onclick_use_ppa = function(e) {
33 var value = getRadioSelectedValue('input[name=field.use_ppa]');
34 if (value == 'create-new') {
35 set_enabled(PPA_NAME_ID, true);
36 set_enabled(PPA_SELECTOR_ID, false);
37 }
38 else {
39 set_enabled(PPA_NAME_ID, false);
40 set_enabled(PPA_SELECTOR_ID, true);
41 }
42 };
43
44 module.setup = function() {
45 Y.all('input[name=field.use_ppa]').on(
46 'click', module.onclick_use_ppa);
47
48 // Set the initial state.
49 module.onclick_use_ppa();
50 // And from now on, set the focus to the active input field when the
51 // radio button is clicked.
52 set_field_focus = true;
53 };
54
55 }, "0.1", {"requires": ["node", "DOM"]}
56);
057
=== 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-14 20:58:56 +0000
@@ -29,6 +29,7 @@
29 )29 )
3030
31from canonical.database.datetimecol import UtcDateTimeCol31from canonical.database.datetimecol import UtcDateTimeCol
32from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
32from canonical.launchpad.interfaces.lpstorm import (33from canonical.launchpad.interfaces.lpstorm import (
33 IMasterStore,34 IMasterStore,
34 IStore,35 IStore,
@@ -62,7 +63,9 @@
6263
63def get_buildable_distroseries_set(user):64def get_buildable_distroseries_set(user):
64 ppas = getUtility(IArchiveSet).getPPAsForUser(user)65 ppas = getUtility(IArchiveSet).getPPAsForUser(user)
65 supported_distros = [ppa.distribution for ppa in ppas]66 supported_distros = set([ppa.distribution for ppa in ppas])
67 # Now add in Ubuntu.
68 supported_distros.add(getUtility(ILaunchpadCelebrities).ubuntu)
66 distros = getUtility(IDistroSeriesSet).search()69 distros = getUtility(IDistroSeriesSet).search()
6770
68 buildables = []71 buildables = []
6972
=== 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-14 20:58:56 +0000
@@ -6,6 +6,21 @@
6 metal:use-macro="view/macro:page/main_only"6 metal:use-macro="view/macro:page/main_only"
7 i18n:domain="launchpad">7 i18n:domain="launchpad">
8 <body>8 <body>
9
10<metal:block fill-slot="head_epilogue">
11 <style type="text/css">
12 .root-choice input[type="radio"] {
13 margin-left: 0;
14 }
15 .root-choice label {
16 font-weight: bold !important;
17 }
18 .subordinate {
19 margin: 0.5em 0 0.5em 2em;
20 }
21 </style>
22</metal:block>
23
9 <div metal:fill-slot="main">24 <div metal:fill-slot="main">
1025
11 <div>26 <div>
@@ -23,8 +38,76 @@
2338
24 </div>39 </div>
2540
26 <div metal:use-macro="context/@@launchpad_form/form" />41 <div metal:use-macro="context/@@launchpad_form/form">
2742
43 <metal:formbody fill-slot="widgets">
44
45 <table class="form">
46
47 <tal:widget define="widget nocall:view/widgets/name">
48 <metal:block use-macro="context/@@launchpad_form/widget_row" />
49 </tal:widget>
50 <tal:widget define="widget nocall:view/widgets/description">
51 <metal:block use-macro="context/@@launchpad_form/widget_row" />
52 </tal:widget>
53 <tal:widget define="widget nocall:view/widgets/owner">
54 <metal:block use-macro="context/@@launchpad_form/widget_row" />
55 </tal:widget>
56 <tal:widget define="widget nocall:view/widgets/build_daily">
57 <metal:block use-macro="context/@@launchpad_form/widget_row" />
58 </tal:widget>
59
60 <tal:show-ppa-choice condition="view/show_ppa_chooser">
61 <tr>
62 <td class='root-choice'>
63 <label tal:replace="structure view/use_ppa_existing">
64 Use existing PPA
65 </label>
66 <table class="subordinate">
67 <tal:widget define="widget nocall:view/widgets/daily_build_archive">
68 <metal:block use-macro="context/@@launchpad_form/widget_row" />
69 </tal:widget>
70 </table>
71 </td>
72 </tr>
73
74 <tr>
75 <td class='root-choice'>
76 <label tal:replace="structure view/use_ppa_new">
77 Create new PPA
78 </label>
79 <table class="subordinate">
80 <tal:widget define="widget nocall:view/widgets/ppa_name">
81 <metal:block use-macro="context/@@launchpad_form/widget_row" />
82 </tal:widget>
83 </table>
84 </td>
85 </tr>
86
87 <script type="text/javascript">
88 LPS.use('lp.code.sourcepackagerecipe.new', function(Y) {
89 Y.on('domready', Y.lp.code.sourcepackagerecipe.new.setup);
90 });
91 </script>
92 </tal:show-ppa-choice>
93
94 <tal:create-ppa condition="not: view/show_ppa_chooser">
95 <input name="field.use_ppa" value="create-new" type="hidden"/>
96 <tal:widget define="widget nocall:view/widgets/ppa_name">
97 <metal:block use-macro="context/@@launchpad_form/widget_row" />
98 </tal:widget>
99 </tal:create-ppa>
100
101 <tal:widget define="widget nocall:view/widgets/distros">
102 <metal:block use-macro="context/@@launchpad_form/widget_row" />
103 </tal:widget>
104 <tal:widget define="widget nocall:view/widgets/recipe_text">
105 <metal:block use-macro="context/@@launchpad_form/widget_row" />
106 </tal:widget>
107
108 </table>
109 </metal:formbody>
110 </div>
28 </div>111 </div>
29 </body>112 </body>
30</html>113</html>
31114
=== 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-14 20:58:56 +0000
@@ -81,6 +81,7 @@
81 custom_widget,81 custom_widget,
82 LaunchpadEditFormView,82 LaunchpadEditFormView,
83 LaunchpadFormView,83 LaunchpadFormView,
84 render_radio_widget_part,
84 ReturnToReferrerMixin,85 ReturnToReferrerMixin,
85 )86 )
86from lp.app.browser.tales import MenuAPI87from lp.app.browser.tales import MenuAPI
@@ -913,31 +914,19 @@
913 def setUpWidgets(self):914 def setUpWidgets(self):
914 """See `LaunchpadFormView`."""915 """See `LaunchpadFormView`."""
915 super(ProductSeriesSetBranchView, self).setUpWidgets()916 super(ProductSeriesSetBranchView, self).setUpWidgets()
916
917 def render(widget, term_value, current_value, label=None):
918 term = widget.vocabulary.getTerm(term_value)
919 if term.value == current_value:
920 render = widget.renderSelectedItem
921 else:
922 render = widget.renderItem
923 if label is None:
924 label = term.title
925 value = term.token
926 return render(index=term.value,
927 text=label,
928 value=value,
929 name=widget.name,
930 cssClass='')
931
932 widget = self.widgets['rcs_type']917 widget = self.widgets['rcs_type']
933 vocab = widget.vocabulary918 vocab = widget.vocabulary
934 current_value = widget._getFormValue()919 current_value = widget._getFormValue()
935 self.rcs_type_cvs = render(widget, vocab.CVS, current_value, 'CVS')920 self.rcs_type_cvs = render_radio_widget_part(
936 self.rcs_type_svn = render(widget, vocab.BZR_SVN, current_value,921 widget, vocab.CVS, current_value, 'CVS')
937 'SVN')922 self.rcs_type_svn = render_radio_widget_part(
938 self.rcs_type_git = render(widget, vocab.GIT, current_value)923 widget, vocab.BZR_SVN, current_value, 'SVN')
939 self.rcs_type_hg = render(widget, vocab.HG, current_value)924 self.rcs_type_git = render_radio_widget_part(
940 self.rcs_type_bzr = render(widget, vocab.BZR, current_value)925 widget, vocab.GIT, current_value)
926 self.rcs_type_hg = render_radio_widget_part(
927 widget, vocab.HG, current_value)
928 self.rcs_type_bzr = render_radio_widget_part(
929 widget, vocab.BZR, current_value)
941 self.rcs_type_emptymarker = widget._emptyMarker()930 self.rcs_type_emptymarker = widget._emptyMarker()
942931
943 widget = self.widgets['branch_type']932 widget = self.widgets['branch_type']
@@ -947,7 +936,7 @@
947 (self.branch_type_link,936 (self.branch_type_link,
948 self.branch_type_create,937 self.branch_type_create,
949 self.branch_type_import) = [938 self.branch_type_import) = [
950 render(widget, value, current_value)939 render_radio_widget_part(widget, value, current_value)
951 for value in (LINK_LP_BZR, CREATE_NEW, IMPORT_EXTERNAL)]940 for value in (LINK_LP_BZR, CREATE_NEW, IMPORT_EXTERNAL)]
952941
953 def _validateLinkLpBzr(self, data):942 def _validateLinkLpBzr(self, data):
954943
=== modified file 'lib/lp/soyuz/model/archive.py'
--- lib/lp/soyuz/model/archive.py 2010-12-13 06:44:16 +0000
+++ lib/lp/soyuz/model/archive.py 2010-12-14 20:58:56 +0000
@@ -1716,7 +1716,7 @@
17161716
1717 enabled_restricted_families = property(_getEnabledRestrictedFamilies,1717 enabled_restricted_families = property(_getEnabledRestrictedFamilies,
1718 _setEnabledRestrictedFamilies)1718 _setEnabledRestrictedFamilies)
1719 1719
1720 @classmethod1720 @classmethod
1721 def validatePPA(self, person, proposed_name):1721 def validatePPA(self, person, proposed_name):
1722 ubuntu = getUtility(ILaunchpadCelebrities).ubuntu1722 ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
17231723
=== modified file 'lib/lp/testing/__init__.py'
--- lib/lp/testing/__init__.py 2010-12-13 10:04:34 +0000
+++ lib/lp/testing/__init__.py 2010-12-14 20:58:56 +0000
@@ -715,14 +715,20 @@
715 else:715 else:
716 return self.getUserBrowser(url, self.user)716 return self.getUserBrowser(url, self.user)
717717
718 def getMainText(718 def getMainContent(self, context, view_name=None, rootsite=None,
719 self, context, view_name=None, rootsite=None, no_login=False):719 no_login=False):
720 """Return the main text of a context's page."""720 """Beautiful soup of the main content area of context's page."""
721 from canonical.launchpad.testing.pages import (721 from canonical.launchpad.testing.pages import find_main_content
722 extract_text, find_main_content)
723 browser = self.getViewBrowser(722 browser = self.getViewBrowser(
724 context, view_name, rootsite=rootsite, no_login=no_login)723 context, view_name, rootsite=rootsite, no_login=no_login)
725 return extract_text(find_main_content(browser.contents))724 return find_main_content(browser.contents)
725
726 def getMainText(self, context, view_name=None, rootsite=None,
727 no_login=False):
728 """Return the main text of a context's page."""
729 from canonical.launchpad.testing.pages import extract_text
730 return extract_text(
731 self.getMainContent(context, view_name, rootsite, no_login))
726732
727733
728class WindmillTestCase(TestCaseWithFactory):734class WindmillTestCase(TestCaseWithFactory):