Merge lp:~wallyworld/launchpad/request-build-popup into lp:launchpad

Proposed by Ian Booth
Status: Merged
Approved by: Ian Booth
Approved revision: no longer in the source branch.
Merged at revision: 12422
Proposed branch: lp:~wallyworld/launchpad/request-build-popup
Merge into: lp:launchpad
Diff against target: 792 lines (+540/-122)
8 files modified
lib/lp/code/browser/configure.zcml (+8/-1)
lib/lp/code/browser/sourcepackagerecipe.py (+104/-37)
lib/lp/code/javascript/requestbuild_overlay.js (+214/-0)
lib/lp/code/model/sourcepackagerecipe.py (+3/-1)
lib/lp/code/templates/sourcepackagerecipe-builds.pt (+79/-0)
lib/lp/code/templates/sourcepackagerecipe-index.pt (+33/-83)
lib/lp/code/windmill/tests/test_recipe_request_build.py (+96/-0)
lib/lp/soyuz/model/archive.py (+3/-0)
To merge this branch: bzr merge lp:~wallyworld/launchpad/request-build-popup
Reviewer Review Type Date Requested Status
Tim Penhey (community) Approve
Curtis Hovey (community) ui Approve
Review via email: mp+48864@code.launchpad.net

Commit message

[r=thumper][ui=sinzui][bug=673519] When requesting recipe builds, use a popup form if Javascript is enabled instead of an html form which requires extra page loads.

Description of the change

This branch introduces a popup form to allow the user to initiate recipe builds. Previously, a separate page load was used to display a standard html form.

== Implementation ==

The existing SourcePackageRecipeRequestBuildsView was used as a base class and new subclasses introduced:
- SourcePackageRecipeRequestBuildsHtmlView
- SourcePackageRecipeRequestBuildsAjaxView

The base class defines the form schema and provides the validation functionality. The individual subclasses provide slightly different implementations of the submit action, error handling and data rendering.
The html form behaves as previously. It renders the form page and relies on the underlying Launchpad form error handling to display field errors etc.

The ajax form works slightly differently. It renders just the recipe builds table (via a new +builds Zope URL). So when build(s) are requested and everything goes ok, a new version of the builds table is returned and the Javascrip ajax handler re-renders that part of the recipe page, flashing the rows of new builds. If there are errors, the ajax form returns a json data object containing the errors and sets the request status code to 400. This allows the client side Javascript to parse the json data and display the errors on the popup form. At the moment, only a single form wide error message is used, even though the json data contains field level errors. This is a limitation of the lazr.js FormOverlay implementation.

On the client side, the popup form acts modally. Client side validation is done and errors displayed on the form without a back end request - this is used to check that at least on distro series has been selected. Once the submit button is presses, the "please wait" spinner appears on the popup form. If the request went ok, the form disappears the the recipe page updated. If there are errors, the form stays visible and the errors are displayed on the form. This allows the user to correct any issues and try again. The most common case would be a user requesting an identical build to one already pending. They just need to untick that distro series and try again.

A driveby change was to make the pocket parameter of the requestBuild() method on SourcePackageRecipe default to PackagePublishingPocket.RELEASE

== Demo ==

See screenshot: http://people.canonical.com/~ianb/request-build-error.png

The screenshot shows how an error is displayed, but also gives a good indication of what the form looks like. The form is standard lazr.js functionality.

== QA ==

Simply use the form to initiate some recipe builds and ensure the overall functionality is equivalent to that of the html form.

== Tests ==

A new Windmill test was written to check the form can initiate a build and display errors as required.
bin/test -vvt test_recipe_request_build

== Lint ==

Linting changed files:
  lib/lp/code/browser/configure.zcml
  lib/lp/code/browser/sourcepackagerecipe.py
  lib/lp/code/javascript/requestbuild_overlay.js
  lib/lp/code/model/sourcepackagerecipe.py
  lib/lp/code/templates/sourcepackagerecipe-builds.pt
  lib/lp/code/templates/sourcepackagerecipe-index.pt
  lib/lp/code/windmill/tests/test_recipe_request_build.py

./lib/lp/code/templates/sourcepackagerecipe-builds.pt
      13: unbound prefix

To post a comment you must log in.
Revision history for this message
Curtis Hovey (sinzui) wrote :

The check and cross button look wrong. I do not know of any other "popups" or "overlays" that use them. I only see them used when editing inline content. I do not think they mean submit or cancel. I think users believe they mean done editing or cancel editing.

I expect to see a button [Request Build] to submit the form. Overlay forms use a [Cancel] button, but I think that contradicts the _Cancel_ rule for page forms. Most popups (gosh, maybe all) place the cancel (x) icon in the top right corner. There is a rule (maybe in code) that when the (x) cancel icon is in the right corner the popup will be dismissed when the user clicks outside the popup. Overlays remain on visible when he user clicks outside. I think the unfocus rule will help us decide the buttons.

If the popup is dismissed when it loses focus, place the (x) cancel icon in the right corner. If it should remain, we use a [Cancel] button. The submit button should be [Request Build].

/me is very aware that the NE cancel icon contradicts his unity desktop conventions

review: Needs Fixing (ui)
Revision history for this message
Ian Booth (wallyworld) wrote :

> The check and cross button look wrong. I do not know of any other "popups" or
> "overlays" that use them. I only see them used when editing inline content. I
> do not think they mean submit or cancel. I think users believe they mean done
> editing or cancel editing.
>

They are used on a few other popup forms, mainly in the bugs area. eg Mark a Bug Report as Duplicate etc

> I expect to see a button [Request Build] to submit the form. Overlay forms use
> a [Cancel] button, but I think that contradicts the _Cancel_ rule for page
> forms. Most popups (gosh, maybe all) place the cancel (x) icon in the top
> right corner. There is a rule (maybe in code) that when the (x) cancel icon is
> in the right corner the popup will be dismissed when the user clicks outside
> the popup. Overlays remain on visible when he user clicks outside. I think the
> unfocus rule will help us decide the buttons.
>
> If the popup is dismissed when it loses focus, place the (x) cancel icon in
> the right corner. If it should remain, we use a [Cancel] button. The submit
> button should be [Request Build].
>
> /me is very aware that the NE cancel icon contradicts his unity desktop
> conventions

There's 2 lazr widgets at play here - PrettyOverlay which provides the behaviour where a popup has the close button in the top right corner and is dismissed if the user clicks outside. Then there's FormOverlay which extends PrettyOverlay and wires in form submission behaviour and doesn't wire in the "dismiss when click outside" behaviour since it allows for a proper cancel button as such (ie as used by the bug forms mentioned earlier).

I've stuck with using FormOverlay since it really is a modal form that is required here. The user clicks Request Build and they see a spinner and when the submission has been processed, the form disappears. On the other hand, if there's an error processing the form stays and an error is shown. They can then choose to correct the error and try again or click cancel to dismiss the form. What I did do though is replace the check/cross buttons with standard "Request Builds" and "Cancel" buttons.

http://people.canonical.com/~ianb/request-build-error1.png

What do you think?

Revision history for this message
Curtis Hovey (sinzui) wrote :

Hi Ian.

Thank you for taking the time to resolve the UI issues. I think this looks good to land.

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

Remove Y.log when loading the module.
Add some comments in the form processing as discussed.
Change SLEEP to FOR_ELEMENT.

Good with these changes.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/lp/code/browser/configure.zcml'
--- lib/lp/code/browser/configure.zcml 2011-01-18 21:46:21 +0000
+++ lib/lp/code/browser/configure.zcml 2011-02-21 23:04:01 +0000
@@ -1228,10 +1228,17 @@
1228 <browser:page1228 <browser:page
1229 for="lp.code.interfaces.sourcepackagerecipe.ISourcePackageRecipe"1229 for="lp.code.interfaces.sourcepackagerecipe.ISourcePackageRecipe"
1230 layer="lp.code.publisher.CodeLayer"1230 layer="lp.code.publisher.CodeLayer"
1231 class="lp.code.browser.sourcepackagerecipe.SourcePackageRecipeRequestBuildsView"1231 class="lp.code.browser.sourcepackagerecipe.SourcePackageRecipeRequestBuildsHtmlView"
1232 name="+request-builds"1232 name="+request-builds"
1233 template="../templates/sourcepackagerecipe-request-builds.pt"1233 template="../templates/sourcepackagerecipe-request-builds.pt"
1234 permission="launchpad.AnyPerson"/>1234 permission="launchpad.AnyPerson"/>
1235 <browser:page
1236 for="lp.code.interfaces.sourcepackagerecipe.ISourcePackageRecipe"
1237 layer="lp.code.publisher.CodeLayer"
1238 class="lp.code.browser.sourcepackagerecipe.SourcePackageRecipeRequestBuildsAjaxView"
1239 name="+builds"
1240 template="../templates/sourcepackagerecipe-builds.pt"
1241 permission="launchpad.View"/>
1235 </facet>1242 </facet>
1236 <facet facet="branches">1243 <facet facet="branches">
1237 <browser:defaultView1244 <browser:defaultView
12381245
=== modified file 'lib/lp/code/browser/sourcepackagerecipe.py'
--- lib/lp/code/browser/sourcepackagerecipe.py 2011-02-18 02:39:47 +0000
+++ lib/lp/code/browser/sourcepackagerecipe.py 2011-02-21 23:04:01 +0000
@@ -14,6 +14,8 @@
14 'SourcePackageRecipeView',14 'SourcePackageRecipeView',
15 ]15 ]
1616
17import simplejson
18
17from bzrlib.plugins.builder.recipe import (19from bzrlib.plugins.builder.recipe import (
18 ForbiddenInstructionError,20 ForbiddenInstructionError,
19 RecipeParseError,21 RecipeParseError,
@@ -93,7 +95,6 @@
93 MINIMAL_RECIPE_TEXT,95 MINIMAL_RECIPE_TEXT,
94 RECIPE_BETA_FLAG,96 RECIPE_BETA_FLAG,
95 )97 )
96from lp.registry.interfaces.pocket import PackagePublishingPocket
97from lp.services.features import getFeatureFlag98from lp.services.features import getFeatureFlag
98from lp.services.propertycache import cachedproperty99from lp.services.propertycache import cachedproperty
99from lp.soyuz.model.archive import Archive100from lp.soyuz.model.archive import Archive
@@ -216,21 +217,7 @@
216217
217 @property218 @property
218 def builds(self):219 def builds(self):
219 """A list of interesting builds.220 return builds_for_recipe(self.context)
220
221 All pending builds are shown, as well as 1-5 recent builds.
222 Recent builds are ordered by date finished (if completed) or
223 date_started (if date finished is not set due to an error building or
224 other circumstance which resulted in the build not being completed).
225 This allows started but unfinished builds to show up in the view but
226 be discarded as more recent builds become available.
227 """
228 builds = list(self.context.getPendingBuilds())
229 for build in self.context.getBuilds():
230 builds.append(build)
231 if len(builds) >= 5:
232 break
233 return builds
234221
235 def dailyBuildWithoutUploadPermission(self):222 def dailyBuildWithoutUploadPermission(self):
236 """Returns true if there are upload permissions to the daily archive.223 """Returns true if there are upload permissions to the daily archive.
@@ -288,6 +275,24 @@
288 self.context, description, title="")275 self.context, description, title="")
289276
290277
278def builds_for_recipe(recipe):
279 """A list of interesting builds.
280
281 All pending builds are shown, as well as 1-5 recent builds.
282 Recent builds are ordered by date finished (if completed) or
283 date_started (if date finished is not set due to an error building or
284 other circumstance which resulted in the build not being completed).
285 This allows started but unfinished builds to show up in the view but
286 be discarded as more recent builds become available.
287 """
288 builds = list(recipe.getPendingBuilds())
289 for build in recipe.getBuilds():
290 builds.append(build)
291 if len(builds) >= 5:
292 break
293 return builds
294
295
291class SourcePackageRecipeRequestBuildsView(LaunchpadFormView):296class SourcePackageRecipeRequestBuildsView(LaunchpadFormView):
292 """A view for requesting builds of a SourcePackageRecipe."""297 """A view for requesting builds of a SourcePackageRecipe."""
293298
@@ -305,24 +310,20 @@
305310
306 class schema(Interface):311 class schema(Interface):
307 """Schema for requesting a build."""312 """Schema for requesting a build."""
313 archive = Choice(vocabulary='TargetPPAs', title=u'Archive')
308 distros = List(314 distros = List(
309 Choice(vocabulary='BuildableDistroSeries'),315 Choice(vocabulary='BuildableDistroSeries'),
310 title=u'Distribution series')316 title=u'Distribution series')
311 archive = Choice(vocabulary='TargetPPAs', title=u'Archive')
312317
313 custom_widget('distros', LabeledMultiCheckBoxWidget)318 custom_widget('distros', LabeledMultiCheckBoxWidget)
314319
315 @property
316 def title(self):
317 return 'Request builds for %s' % self.context.name
318
319 label = title
320
321 @property
322 def cancel_url(self):
323 return canonical_url(self.context)
324
325 def validate(self, data):320 def validate(self, data):
321 distros = data.get('distros', [])
322 if not len(distros):
323 self.setFieldError('distros',
324 "You need to specify at least one distro series for which "
325 "to build.")
326 return
326 over_quota_distroseries = []327 over_quota_distroseries = []
327 for distroseries in data['distros']:328 for distroseries in data['distros']:
328 if self.context.isOverQuota(self.user, distroseries):329 if self.context.isOverQuota(self.user, distroseries):
@@ -333,23 +334,89 @@
333 "You have exceeded today's quota for %s." %334 "You have exceeded today's quota for %s." %
334 ', '.join(over_quota_distroseries))335 ', '.join(over_quota_distroseries))
335336
336 @action('Request builds', name='request')337 def requestBuild(self, data):
337 def request_action(self, action, data):338 """User action for requesting a number of builds.
338 """User action for requesting a number of builds."""339
340 We raise exceptions for most errors but if there's already a pending
341 build for a particular distroseries, we simply record that so that
342 other builds can ne queued and a message be displayed to the caller.
343 """
344 errors = {}
339 for distroseries in data['distros']:345 for distroseries in data['distros']:
340 try:346 try:
341 self.context.requestBuild(347 self.context.requestBuild(
342 data['archive'], self.user, distroseries,348 data['archive'], self.user, distroseries, manual=True)
343 PackagePublishingPocket.RELEASE, manual=True)
344 except BuildAlreadyPending, e:349 except BuildAlreadyPending, e:
345 self.setFieldError(350 errors['distros'] = ("An identical build is already pending "
346 'distros',351 "for %s." % e.distroseries)
347 'An identical build is already pending for %s.' %352 return errors
348 e.distroseries)353
349 return354
355class SourcePackageRecipeRequestBuildsHtmlView(
356 SourcePackageRecipeRequestBuildsView):
357 """Supports HTML form recipe build requests."""
358
359 @property
360 def title(self):
361 return 'Request builds for %s' % self.context.name
362
363 label = title
364
365 @property
366 def cancel_url(self):
367 return canonical_url(self.context)
368
369 @action('Request builds', name='request')
370 def request_action(self, action, data):
371 errors = self.requestBuild(data)
372 if errors:
373 [self.setFieldError(field, message)
374 for (field, message) in errors.items()]
375 return
350 self.next_url = self.cancel_url376 self.next_url = self.cancel_url
351377
352378
379class SourcePackageRecipeRequestBuildsAjaxView(
380 SourcePackageRecipeRequestBuildsView):
381 """Supports AJAX form recipe build requests."""
382
383 def _process_error(self, data, errors, reason):
384 """Set up the response and json data to return to the caller."""
385 self.request.response.setStatus(400, reason)
386 self.request.response.setHeader('Content-type', 'application/json')
387 return simplejson.dumps(errors)
388
389 def failure(self, action, data, errors):
390 """Called by the form if validate() finds any errors.
391
392 We simply convert the errors to json and return that data to the
393 caller for display to the user.
394 """
395 return self._process_error(data, self.widget_errors, "Validation")
396
397 @action('Request builds', name='request', failure=failure)
398 def request_action(self, action, data):
399 """User action for requesting a number of builds.
400
401 The failure handler will handle any validation errors. We still need
402 to handle errors which may occur when invoking the business logic.
403 These "expected" errors are ones which result in a predefined message
404 being displayed to the user. If the business method raises an
405 unexpected exception, that will be handled using the form's standard
406 exception processing mechanism (using response code 500).
407 """
408 errors = self.requestBuild(data)
409 # If there are errors we return a json data snippet containing the
410 # errors instead of rendering the form. These errors are processed
411 # by the caller's response handler and displayed to the user.
412 if errors:
413 return self._process_error(data, errors, "Request Build")
414
415 @property
416 def builds(self):
417 return builds_for_recipe(self.context)
418
419
353class ISourcePackageEditSchema(Interface):420class ISourcePackageEditSchema(Interface):
354 """Schema for adding or editing a recipe."""421 """Schema for adding or editing a recipe."""
355422
356423
=== added file 'lib/lp/code/javascript/requestbuild_overlay.js'
--- lib/lp/code/javascript/requestbuild_overlay.js 1970-01-01 00:00:00 +0000
+++ lib/lp/code/javascript/requestbuild_overlay.js 2011-02-21 23:04:01 +0000
@@ -0,0 +1,214 @@
1/* Copyright 2011 Canonical Ltd. This software is licensed under the
2 * GNU Affero General Public License version 3 (see the file LICENSE).
3 *
4 * A form overlay that can request builds for a recipe..
5 *
6 * @namespace Y.lp.code.recipebuild_overlay
7 * @requires dom, node, io-base, lazr.anim, lazr.formoverlay
8 */
9YUI.add('lp.code.requestbuild_overlay', function(Y) {
10
11var namespace = Y.namespace('lp.code.requestbuild_overlay');
12
13var lp_client;
14var request_build_overlay = null;
15var response_handler;
16
17function set_up_lp_client() {
18 if (lp_client === undefined) {
19 lp_client = new LP.client.Launchpad();
20 }
21}
22
23// This handler is used to process the results of form submission or other
24// such operation (eg get, post). It provides some boiler plate and allows the
25// developer to specify onSuccess and onError hooks. It is quite generic and
26// perhaps could be moved to an infrastructure class.
27
28RequestResponseHandler = function () {};
29RequestResponseHandler.prototype = {
30 clearProgressUI: function () {},
31 showError: function (error_msg) {},
32 getErrorHandler: function (errorCallback) {
33 var self = this;
34 return function (id, response) {
35 self.clearProgressUI();
36 // If it was a timeout...
37 if (response.status == 503) {
38 self.showError(
39 'Timeout error, please try again in a few minutes.');
40 } else {
41 if (errorCallback != null) {
42 errorCallback(self, id, response);
43 } else {
44 self.showError(response.responseText);
45 }
46 }
47 };
48 },
49 getSuccessHandler: function (successCallback) {
50 var self = this;
51 return function (id, response) {
52 self.clearProgressUI();
53 successCallback(self, id, response);
54 };
55 }
56};
57
58namespace.connect_requestbuild = function() {
59
60 var request_build_handle = Y.one('#request-builds');
61 request_build_handle.addClass('js-action');
62 request_build_handle.on('click', function(e) {
63 e.preventDefault();
64 if (request_build_overlay == null) {
65 // Render the form and load the widgets to display
66 var recipe_name = LP.client.cache.context['name'];
67 request_build_overlay = new Y.lazr.FormOverlay({
68 headerContent: '<h2>Request builds for '
69 + recipe_name + ' </h2>',
70 form_submit_button: Y.Node.create(
71 '<button type="submit" name="field.actions.request" ' +
72 'value="Request builds">Request Builds</button>'),
73 form_cancel_button: Y.Node.create(
74 '<button type="button" name="field.actions.cancel" ' +
75 '>Cancel</button>'),
76 centered: true,
77 form_submit_callback: do_request_builds,
78 visible: false
79 });
80 request_build_overlay.render();
81 }
82 request_build_overlay.clearError();
83 var temp_spinner = [
84 '<div id="temp-spinner">',
85 '<img src="/@@/spinner"/>Loading...',
86 '</div>'].join('');
87 request_build_overlay.form_node.set("innerHTML", temp_spinner);
88 request_build_overlay.loadFormContentAndRender('+builds/++form++');
89 request_build_overlay.show();
90 });
91
92 // Wire up the processing hooks
93 response_handler = new RequestResponseHandler();
94 response_handler.clearProgressUI = function() {
95 destroy_temporary_spinner();
96 };
97 response_handler.showError = function(error) {
98 request_build_overlay.showError(error);
99 Y.log(error);
100 };
101};
102
103/*
104 * A function to return the current build records as displayed on the page
105 */
106function harvest_current_build_records() {
107 var row_classes = ['package-build', 'binary-build'];
108 var builds = new Array();
109 Y.Array.each(row_classes, function(row_class) {
110 Y.all('.'+row_class).each(function(row) {
111 var row_id = row.getAttribute('id');
112 if (builds.indexOf(row_id)<0) {
113 builds.push(row_id);
114 }
115 });
116 });
117 return builds;
118}
119
120/*
121 * Perform any client side validation
122 * Return: true if data is valid
123 */
124function validate(data) {
125 var distros = data['field.distros']
126 if (Y.Object.size(distros) == 0) {
127 response_handler.showError("You need to specify at least one " +
128 "distro series for which to build.");
129 return false;
130 }
131 return true;
132}
133
134/*
135 * The form submit function
136 */
137function do_request_builds(data) {
138 if (!validate(data))
139 return;
140 create_temporary_spinner();
141 var base_url = LP.client.cache.context.web_link;
142 var submit_url = base_url+"/+builds";
143 var current_builds = harvest_current_build_records();
144 var y_config = {
145 method: "POST",
146 headers: {'Accept': 'application/json; application/xhtml'},
147 on: {
148 failure: response_handler.getErrorHandler(
149 function(handler, id, response) {
150 if( response.status >= 500 ) {
151 // There's some error content we need to display.
152 request_build_overlay.set(
153 'form_content', response.responseText);
154 request_build_overlay.get("form_submit_button")
155 .setStyle('display', 'none');
156 request_build_overlay.renderUI();
157 //We want to force the form to be re-created
158 request_build_overlay = null;
159 return;
160 }
161 var error_info = Y.JSON.parse(response.responseText)
162 var errors = [];
163 for (var field_name in error_info)
164 errors.push(error_info[field_name]);
165 handler.showError(errors);
166 }),
167 success: response_handler.getSuccessHandler(
168 function(handler, id, response) {
169 request_build_overlay.hide();
170 var target = Y.one('#builds-target');
171 target.set('innerHTML', response.responseText);
172 var new_builds = harvest_current_build_records();
173 Y.Array.each(new_builds, function(row_id) {
174 if (current_builds.indexOf(row_id)>=0)
175 return;
176 var row = Y.one('#'+row_id);
177 var anim = Y.lazr.anim.green_flash({node: row});
178 anim.run();
179 });
180 })
181 },
182 form: {
183 id: request_build_overlay.form_node,
184 useDisabled: true
185 }
186 };
187 Y.io(submit_url, y_config);
188}
189
190/*
191 * Show the temporary "Requesting..." text
192 */
193function create_temporary_spinner() {
194 // Add the temp "Requesting build..." text
195 var temp_spinner = Y.Node.create([
196 '<div id="temp-spinner">',
197 '<img src="/@@/spinner"/>Requesting build...',
198 '</div>'].join(''));
199 var request_build_handle = Y.one('.yui3-lazr-formoverlay-actions');
200 request_build_handle.insert(temp_spinner, request_build_handle);
201}
202
203/*
204 * Destroy the temporary "Requesting..." text
205 */
206function destroy_temporary_spinner() {
207 var temp_spinner = Y.one('#temp-spinner');
208 var spinner_parent = temp_spinner.get('parentNode');
209 spinner_parent.removeChild(temp_spinner);
210}
211
212}, "0.1", {"requires": [
213 "dom", "node", "io-base", "lazr.anim", "lazr.formoverlay"
214 ]});
0215
=== modified file 'lib/lp/code/model/sourcepackagerecipe.py'
--- lib/lp/code/model/sourcepackagerecipe.py 2011-02-18 02:39:47 +0000
+++ lib/lp/code/model/sourcepackagerecipe.py 2011-02-21 23:04:01 +0000
@@ -70,6 +70,7 @@
70from lp.code.model.sourcepackagerecipedata import SourcePackageRecipeData70from lp.code.model.sourcepackagerecipedata import SourcePackageRecipeData
71from lp.registry.interfaces.distroseries import IDistroSeriesSet71from lp.registry.interfaces.distroseries import IDistroSeriesSet
72from lp.registry.model.distroseries import DistroSeries72from lp.registry.model.distroseries import DistroSeries
73from lp.registry.interfaces.pocket import PackagePublishingPocket
73from lp.services.database.stormexpr import Greatest74from lp.services.database.stormexpr import Greatest
74from lp.soyuz.interfaces.archive import IArchiveSet75from lp.soyuz.interfaces.archive import IArchiveSet
75from lp.soyuz.model.archive import Archive76from lp.soyuz.model.archive import Archive
@@ -244,7 +245,8 @@
244 return SourcePackageRecipeBuild.getRecentBuilds(245 return SourcePackageRecipeBuild.getRecentBuilds(
245 requester, self, distroseries).count() >= 5246 requester, self, distroseries).count() >= 5
246247
247 def requestBuild(self, archive, requester, distroseries, pocket,248 def requestBuild(self, archive, requester, distroseries,
249 pocket=PackagePublishingPocket.RELEASE,
248 manual=False):250 manual=False):
249 """See `ISourcePackageRecipe`."""251 """See `ISourcePackageRecipe`."""
250 if not archive.is_ppa:252 if not archive.is_ppa:
251253
=== added file 'lib/lp/code/templates/sourcepackagerecipe-builds.pt'
--- lib/lp/code/templates/sourcepackagerecipe-builds.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/code/templates/sourcepackagerecipe-builds.pt 2011-02-21 23:04:01 +0000
@@ -0,0 +1,79 @@
1<div id="latest-builds" class="portlet">
2<h2>Latest builds</h2>
3<table id="latest-builds-listing" class="listing" style='margin-bottom: 1em;'>
4 <thead>
5 <tr>
6 <th>Status</th>
7 <th>When complete</th>
8 <th>Distribution series</th>
9 <th>Archive</th>
10 </tr>
11 </thead>
12 <tbody>
13 <tal:recipe-builds repeat="build view/builds">
14 <tal:build-view define="buildview nocall:build/@@+index">
15 <tr class="package-build" tal:attributes="id string:build-${build/url_id}">
16 <td>
17 <span tal:replace="structure build/image:icon" />
18 <a tal:content="buildview/status"
19 tal:attributes="href build/fmt:url"></a>
20 </td>
21 <td>
22 <tal:date replace="buildview/date/fmt:displaydate" />
23 <tal:estimate condition="buildview/estimate">
24 (estimated)
25 </tal:estimate>
26
27 <tal:build-log define="file build/log"
28 tal:condition="file">
29 <a class="sprite download"
30 tal:attributes="href build/log_url">buildlog</a>
31 (<span tal:replace="file/content/filesize/fmt:bytes" />)
32 </tal:build-log>
33 </td>
34 <td>
35 <tal:distro
36 replace="structure build/distroseries/fmt:link:mainsite" />
37 </td>
38 <td>
39 <tal:archive replace="structure build/archive/fmt:link"/>
40 </td>
41 </tr>
42 <tal:binary-builds repeat="binary buildview/binary_builds">
43 <tr tal:define="binaryview nocall:binary/@@+index"
44 class="binary-build" tal:attributes="id string:build-${binary/url_id}">
45 <td class="indent">
46 <span tal:replace="structure binary/image:icon"/>
47 <a tal:content="binary/source_package_release/title"
48 tal:attributes="href binary/fmt:url">package - version</a>
49 </td>
50 <td>
51 <tal:date replace="binaryview/date/fmt:displaydate" />
52 <tal:estimate condition="binaryview/estimate">
53 (estimated)
54 </tal:estimate>
55
56 <tal:build-log define="file binary/log"
57 tal:condition="file">
58 <a class="sprite download"
59 tal:attributes="href binary/log_url">buildlog</a>
60 (<span tal:replace="file/content/filesize/fmt:bytes" />)
61 </tal:build-log>
62 </td>
63 <td class="indent">
64 <a class="sprite distribution"
65 tal:define="archseries binary/distro_arch_series"
66 tal:attributes="href archseries/fmt:url"
67 tal:content="archseries/architecturetag">i386</a>
68 </td>
69 </tr>
70 </tal:binary-builds>
71 </tal:build-view>
72 </tal:recipe-builds>
73 </tbody>
74</table>
75<p tal:condition="not: view/builds">
76 This recipe has not been built yet.
77</p>
78</div>
79
080
=== modified file 'lib/lp/code/templates/sourcepackagerecipe-index.pt'
--- lib/lp/code/templates/sourcepackagerecipe-index.pt 2011-02-15 02:33:13 +0000
+++ lib/lp/code/templates/sourcepackagerecipe-index.pt 2011-02-21 23:04:01 +0000
@@ -86,91 +86,41 @@
86 </div>86 </div>
87 </div>87 </div>
88 </div>88 </div>
89
89 <div class="yui-g">90 <div class="yui-g">
90 <div class="portlet">91 <div id="builds-target" tal:content="structure context/@@+builds" />
91 <h2>Latest builds</h2>92 </div>
92 <table class="listing" style='margin-bottom: 1em;'>93 <div
93 <thead>94 tal:define="link context/menu:context/request_builds"
94 <tr>95 tal:condition="link/enabled"
95 <th>Status</th>96 >
96 <th>When complete</th>97 <a id="request-builds"
97 <th>Distribution series</th>98 class="sprite add"
98 <th>Archive</th>99 tal:attributes="href link/url"
99 </tr>100 tal:content="link/text" />
100 </thead>101 </div>
101 <tbody>102 <div class='portlet'>
102 <tal:recipe-builds repeat="build view/builds">103 <h2>Recipe contents</h2>
103 <tal:build-view define="buildview nocall:build/@@+index">
104 <tr>
105 <td>
106 <span tal:replace="structure build/image:icon" />
107 <a tal:content="buildview/status"
108 tal:attributes="href build/fmt:url"></a>
109 </td>
110 <td>
111 <tal:date replace="buildview/date/fmt:displaydate" />
112 <tal:estimate condition="buildview/estimate">
113 (estimated)
114 </tal:estimate>
115
116 <tal:build-log define="file build/log"
117 tal:condition="file">
118 <a class="sprite download"
119 tal:attributes="href build/log_url">buildlog</a>
120 (<span tal:replace="file/content/filesize/fmt:bytes" />)
121 </tal:build-log>
122 </td>
123 <td>
124 <tal:distro
125 replace="structure build/distroseries/fmt:link:mainsite" />
126 </td>
127 <td>
128 <tal:archive replace="structure build/archive/fmt:link"/>
129 </td>
130 </tr>
131 <tal:binary-builds repeat="binary buildview/binary_builds">
132 <tr tal:define="binaryview nocall:binary/@@+index"
133 class="binary-build">
134 <td class="indent">
135 <span tal:replace="structure binary/image:icon"/>
136 <a tal:content="binary/source_package_release/title"
137 tal:attributes="href binary/fmt:url">package - version</a>
138 </td>
139 <td>
140 <tal:date replace="binaryview/date/fmt:displaydate" />
141 <tal:estimate condition="binaryview/estimate">
142 (estimated)
143 </tal:estimate>
144
145 <tal:build-log define="file binary/log"
146 tal:condition="file">
147 <a class="sprite download"
148 tal:attributes="href binary/log_url">buildlog</a>
149 (<span tal:replace="file/content/filesize/fmt:bytes" />)
150 </tal:build-log>
151 </td>
152 <td class="indent">
153 <a class="sprite distribution"
154 tal:define="archseries binary/distro_arch_series"
155 tal:attributes="href archseries/fmt:url"
156 tal:content="archseries/architecturetag">i386</a>
157 </td>
158 </tr>
159 </tal:binary-builds>
160 </tal:build-view>
161 </tal:recipe-builds>
162 </tbody>
163 </table>
164 <p tal:condition="not: view/builds">
165 This recipe has not been built yet.
166 </p>
167 <tal:request replace="structure context/menu:context/request_builds/fmt:link" />
168 </div>
169 <div class='portlet'>
170 <h2>Recipe contents</h2>
171 <tal:widget replace="structure view/recipe_text_widget"/>104 <tal:widget replace="structure view/recipe_text_widget"/>
172 </div>
173 </div>
174 </div>105 </div>
106
107 <tal:script>
108 <script id='requestbuild-script' type="text/javascript" tal:content="string:
109 LPS.use('io-base', 'lp.code.requestbuild_overlay', function(Y) {
110 if(Y.UA.ie) {
111 return;
112 }
113
114 Y.on('load', function() {
115 var logged_in = LP.client.links['me'] !== undefined;
116 if (logged_in) {
117 Y.lp.code.requestbuild_overlay.connect_requestbuild();
118 }
119 }, window);
120 });"
121 >
122 </script>
123 </tal:script>
124</div>
175</body>125</body>
176</html>126</html>
177127
=== added file 'lib/lp/code/windmill/tests/test_recipe_request_build.py'
--- lib/lp/code/windmill/tests/test_recipe_request_build.py 1970-01-01 00:00:00 +0000
+++ lib/lp/code/windmill/tests/test_recipe_request_build.py 2011-02-21 23:04:01 +0000
@@ -0,0 +1,96 @@
1# Copyright 2011 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Tests for requesting recipe builds."""
5
6__metaclass__ = type
7__all__ = []
8
9import transaction
10from zope.security.proxy import removeSecurityProxy
11
12from canonical.launchpad.webapp.publisher import canonical_url
13from lp.testing.windmill.constants import (
14 FOR_ELEMENT,
15 PAGE_LOAD,
16 SLEEP,
17 )
18from lp.testing.windmill.lpuser import login_person
19from lp.app.browser.tales import PPAFormatterAPI
20from lp.code.windmill.testing import CodeWindmillLayer
21from lp.soyuz.model.processor import ProcessorFamily
22from lp.testing import WindmillTestCase
23
24
25class TestRecipeBuild(WindmillTestCase):
26 """Test setting branch status."""
27
28 layer = CodeWindmillLayer
29 suite_name = "Request recipe build"
30
31 def setUp(self):
32 super(TestRecipeBuild, self).setUp()
33 self.chef = self.factory.makePerson(
34 displayname='Master Chef', name='chef', password='test',
35 email="chef@example.com")
36 self.user = self.chef
37 self.ppa = self.factory.makeArchive(
38 displayname='Secret PPA', owner=self.chef, name='ppa')
39 self.squirrel = self.factory.makeDistroSeries(
40 displayname='Secret Squirrel', name='secret', version='100.04',
41 distribution=self.ppa.distribution)
42 naked_squirrel = removeSecurityProxy(self.squirrel)
43 naked_squirrel.nominatedarchindep = self.squirrel.newArch(
44 'i386', ProcessorFamily.get(1), False, self.chef,
45 supports_virtualized=True)
46 chocolate = self.factory.makeProduct(name='chocolate')
47 cake_branch = self.factory.makeProductBranch(
48 owner=self.chef, name='cake', product=chocolate)
49 self.recipe = self.factory.makeSourcePackageRecipe(
50 owner=self.chef, distroseries=self.squirrel, name=u'cake_recipe',
51 description=u'This recipe builds a foo for disto bar, with my'
52 ' Secret Squirrel changes.', branches=[cake_branch],
53 daily_build_archive=self.ppa)
54 transaction.commit()
55 login_person(self.chef, "chef@example.com", "test", self.client)
56
57 def makeRecipeBuild(self):
58 """Create and return a specific recipe."""
59 build = self.factory.makeSourcePackageRecipeBuild(recipe=self.recipe)
60 return build
61
62 def test_recipe_build_request(self):
63 """Request a recipe build."""
64
65 client = self.client
66 client.open(url=canonical_url(self.recipe))
67 client.waits.forElement(
68 id=u'request-builds', timeout=PAGE_LOAD)
69
70 # Request a new build.
71 client.click(id=u'request-builds')
72 client.waits.forElement(id=u'field.archive')
73 client.click(name=u'field.actions.request')
74
75 # Ensure it shows up.
76 client.waits.forElement(
77 xpath = (u'//tr[contains(@class, "package-build")]/td[4]'
78 '/a[@href="%s"]') % PPAFormatterAPI(self.ppa).url(),
79 timeout=FOR_ELEMENT)
80
81 # And try the same one again.
82 client.click(id=u'request-builds')
83 client.waits.forElement(id=u'field.archive')
84 client.click(name=u'field.actions.request')
85
86 # And check that there's an error.
87 client.waits.forElement(
88 xpath = (
89 u'//div[contains(@class, "yui3-lazr-formoverlay-errors")]'
90 '/ul/li'), timeout=FOR_ELEMENT)
91 client.asserts.assertText(
92 xpath = (
93 u'//div[contains(@class, "yui3-lazr-formoverlay-errors")]'
94 '/ul/li'),
95 validator=u'An identical build is already pending for %s %s.'
96 % (self.ppa.distribution.name, self.squirrel.name))
097
=== modified file 'lib/lp/soyuz/model/archive.py'
--- lib/lp/soyuz/model/archive.py 2011-02-04 09:12:13 +0000
+++ lib/lp/soyuz/model/archive.py 2011-02-21 23:04:01 +0000
@@ -1969,6 +1969,9 @@
1969 # Avoiding circular imports.1969 # Avoiding circular imports.
1970 from lp.soyuz.model.archivepermission import ArchivePermission1970 from lp.soyuz.model.archivepermission import ArchivePermission
19711971
1972 # If there's no user logged in, then there's no archives.
1973 if user is None:
1974 return []
1972 store = Store.of(user)1975 store = Store.of(user)
1973 direct_membership = store.find(1976 direct_membership = store.find(
1974 Archive,1977 Archive,