Merge lp:~cjwatson/launchpad/snap-request-builds-ui into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 18780
Proposed branch: lp:~cjwatson/launchpad/snap-request-builds-ui
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/snap-daily-builds-request-builds
Diff against target: 1746 lines (+1034/-232)
14 files modified
lib/lp/app/browser/configure.zcml (+8/-1)
lib/lp/app/browser/tales.py (+35/-1)
lib/lp/snappy/browser/snap.py (+20/-17)
lib/lp/snappy/browser/tests/test_snap.py (+62/-23)
lib/lp/snappy/interfaces/snap.py (+36/-3)
lib/lp/snappy/interfaces/snapjob.py (+21/-0)
lib/lp/snappy/javascript/snap.update_build_statuses.js (+131/-38)
lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.html (+20/-14)
lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.js (+342/-126)
lib/lp/snappy/model/snap.py (+100/-3)
lib/lp/snappy/model/snapjob.py (+62/-4)
lib/lp/snappy/templates/snap-index.pt (+12/-0)
lib/lp/snappy/tests/test_snap.py (+172/-2)
lib/lp/testing/factory.py (+13/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/snap-request-builds-ui
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+348299@code.launchpad.net

Commit message

Convert the snap web UI to use Snap.requestBuilds.

Description of the change

The hard bit here was sorting out the JavaScript that auto-refreshes bits of Snap:+index. I went for converting Snap.getBuildSummariesForSnapBuildIds into a more general Snap.getBuildSummaries that handles both build requests and builds, making it possible to extend the existing updater.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/app/browser/configure.zcml'
2--- lib/lp/app/browser/configure.zcml 2017-09-01 12:57:34 +0000
3+++ lib/lp/app/browser/configure.zcml 2018-09-13 15:15:44 +0000
4@@ -1,4 +1,4 @@
5-<!-- Copyright 2009-2015 Canonical Ltd. This software is licensed under the
6+<!-- Copyright 2009-2018 Canonical Ltd. This software is licensed under the
7 GNU Affero General Public License version 3 (see the file LICENSE).
8 -->
9
10@@ -591,6 +591,13 @@
11 name="image"
12 />
13
14+ <adapter
15+ for="lp.snappy.interfaces.snap.ISnapBuildRequest"
16+ provides="zope.traversing.interfaces.IPathAdapter"
17+ factory="lp.app.browser.tales.SnapBuildRequestImageDisplayAPI"
18+ name="image"
19+ />
20+
21 <!-- TALES badges: namespace -->
22
23 <adapter
24
25=== modified file 'lib/lp/app/browser/tales.py'
26--- lib/lp/app/browser/tales.py 2017-11-10 11:23:27 +0000
27+++ lib/lp/app/browser/tales.py 2018-09-13 15:15:44 +0000
28@@ -1,4 +1,4 @@
29-# Copyright 2009-2017 Canonical Ltd. This software is licensed under the
30+# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
31 # GNU Affero General Public License version 3 (see the file LICENSE).
32
33 """Implementation of the lp: htmlform: fmt: namespaces in TALES."""
34@@ -95,6 +95,7 @@
35 )
36 from lp.services.webapp.session import get_cookie_domain
37 from lp.services.webapp.url import urlappend
38+from lp.snappy.interfaces.snap import SnapBuildRequestStatus
39 from lp.soyuz.enums import ArchivePurpose
40 from lp.soyuz.interfaces.archive import (
41 IArchive,
42@@ -1164,6 +1165,39 @@
43 return self.icon_template % (alt, title, source)
44
45
46+class SnapBuildRequestImageDisplayAPI(ObjectImageDisplayAPI):
47+ """Adapter for ISnapBuildRequest objects to an image.
48+
49+ Used for image:icon.
50+ """
51+ icon_template = (
52+ '<img width="%(width)s" height="14" alt="%(alt)s" '
53+ 'title="%(title)s" src="%(src)s" />')
54+
55+ def icon(self):
56+ """Return the appropriate <img> tag for the build request icon."""
57+ icon_map = {
58+ SnapBuildRequestStatus.PENDING: {'src': "/@@/processing"},
59+ SnapBuildRequestStatus.FAILED: {
60+ 'src': "/@@/build-failed",
61+ 'width': "16",
62+ },
63+ SnapBuildRequestStatus.COMPLETED: {'src': "/@@/build-success"},
64+ }
65+
66+ alt = '[%s]' % self._context.status.name
67+ title = self._context.status.title
68+ source = icon_map[self._context.status].get('src')
69+ width = icon_map[self._context.status].get('width', '14')
70+
71+ return self.icon_template % {
72+ 'alt': alt,
73+ 'title': title,
74+ 'src': source,
75+ 'width': width,
76+ }
77+
78+
79 class BadgeDisplayAPI:
80 """Adapter for IHasBadges to the images for the badges.
81
82
83=== modified file 'lib/lp/snappy/browser/snap.py'
84--- lib/lp/snappy/browser/snap.py 2018-09-13 09:36:38 +0000
85+++ lib/lp/snappy/browser/snap.py 2018-09-13 15:15:44 +0000
86@@ -257,11 +257,16 @@
87 archive = Reference(IArchive, title=u'Source archive', required=True)
88 distro_arch_series = List(
89 Choice(vocabulary='SnapDistroArchSeries'),
90- title=u'Architectures', required=True)
91+ title=u'Architectures', required=True,
92+ description=(
93+ u'If you do not explicitly select any architectures, then '
94+ u'the snap package will be built for all architectures '
95+ u'allowed by its configuration.'))
96 pocket = Choice(
97 title=u'Pocket', vocabulary=PackagePublishingPocket, required=True,
98- description=u'The package stream within the source distribution '
99- 'series to use when building the snap package.')
100+ description=(
101+ u'The package stream within the source distribution series '
102+ u'to use when building the snap package.'))
103
104 custom_widget_archive = SnapArchiveWidget
105 custom_widget_distro_arch_series = LabeledMultiCheckBoxWidget
106@@ -280,18 +285,10 @@
107 """See `LaunchpadFormView`."""
108 return {
109 'archive': self.context.distro_series.main_archive,
110- 'distro_arch_series': self.context.getAllowedArchitectures(),
111+ 'distro_arch_series': [],
112 'pocket': PackagePublishingPocket.UPDATES,
113 }
114
115- def validate(self, data):
116- """See `LaunchpadFormView`."""
117- arches = data.get('distro_arch_series', [])
118- if not arches:
119- self.setFieldError(
120- 'distro_arch_series',
121- "You need to select at least one architecture.")
122-
123 def requestBuild(self, data):
124 """User action for requesting a number of builds.
125
126@@ -318,12 +315,18 @@
127
128 @action('Request builds', name='request')
129 def request_action(self, action, data):
130- builds, informational = self.requestBuild(data)
131+ if data['distro_arch_series']:
132+ builds, informational = self.requestBuild(data)
133+ already_pending = informational.get('already_pending')
134+ notification_text = new_builds_notification_text(
135+ builds, already_pending)
136+ self.request.response.addNotification(notification_text)
137+ else:
138+ self.context.requestBuilds(
139+ self.user, data['archive'], data['pocket'])
140+ self.request.response.addNotification(
141+ _('Builds will be dispatched soon.'))
142 self.next_url = self.cancel_url
143- already_pending = informational.get('already_pending')
144- notification_text = new_builds_notification_text(
145- builds, already_pending)
146- self.request.response.addNotification(notification_text)
147
148
149 class ISnapEditSchema(Interface):
150
151=== modified file 'lib/lp/snappy/browser/tests/test_snap.py'
152--- lib/lp/snappy/browser/tests/test_snap.py 2018-07-13 16:42:08 +0000
153+++ lib/lp/snappy/browser/tests/test_snap.py 2018-09-13 15:15:44 +0000
154@@ -26,6 +26,9 @@
155 import responses
156 import soupmatchers
157 from testtools.matchers import (
158+ AfterPreprocessing,
159+ Equals,
160+ Is,
161 MatchesSetwise,
162 MatchesStructure,
163 )
164@@ -33,6 +36,7 @@
165 from zope.component import getUtility
166 from zope.publisher.interfaces import NotFound
167 from zope.security.interfaces import Unauthorized
168+from zope.security.proxy import removeSecurityProxy
169
170 from lp.app.enums import InformationType
171 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
172@@ -64,6 +68,7 @@
173 ISnapSet,
174 SNAP_PRIVATE_FEATURE_FLAG,
175 SNAP_TESTING_FLAGS,
176+ SnapBuildRequestStatus,
177 SnapPrivateFeatureDisabled,
178 )
179 from lp.snappy.interfaces.snappyseries import ISnappyDistroSeriesSet
180@@ -1443,6 +1448,9 @@
181 Architectures:
182 amd64
183 i386
184+ If you do not explicitly select any architectures, then the snap
185+ package will be built for all architectures allowed by its
186+ configuration.
187 Pocket:
188 Release
189 Security
190@@ -1462,12 +1470,13 @@
191 self.assertRaises(
192 Unauthorized, self.getViewBrowser, self.snap, "+request-builds")
193
194- def test_request_builds_action(self):
195- # Requesting a build creates pending builds.
196+ def test_request_builds_with_architectures_action(self):
197+ # Requesting a build with architectures selected creates pending
198+ # builds.
199 browser = self.getViewBrowser(
200 self.snap, "+request-builds", user=self.person)
201- self.assertTrue(browser.getControl("amd64").selected)
202- self.assertTrue(browser.getControl("i386").selected)
203+ browser.getControl("amd64").selected = True
204+ browser.getControl("i386").selected = True
205 browser.getControl("Request builds").click()
206
207 login_person(self.person)
208@@ -1483,44 +1492,74 @@
209 self.assertContentEqual(
210 [2510], set(build.buildqueue_record.lastscore for build in builds))
211
212- def test_request_builds_ppa(self):
213- # Selecting a different archive creates builds in that archive.
214+ def test_request_builds_with_architectures_ppa(self):
215+ # Selecting a different archive with architectures selected creates
216+ # builds in that archive.
217 ppa = self.factory.makeArchive(
218 distribution=self.ubuntu, owner=self.person, name="snap-ppa")
219 browser = self.getViewBrowser(
220 self.snap, "+request-builds", user=self.person)
221 browser.getControl("PPA").click()
222 browser.getControl(name="field.archive.ppa").value = ppa.reference
223- self.assertTrue(browser.getControl("amd64").selected)
224- browser.getControl("i386").selected = False
225+ browser.getControl("amd64").selected = True
226+ self.assertFalse(browser.getControl("i386").selected)
227 browser.getControl("Request builds").click()
228
229 login_person(self.person)
230 builds = self.snap.pending_builds
231 self.assertEqual([ppa], [build.archive for build in builds])
232
233- def test_request_builds_no_architectures(self):
234- # Selecting no architectures causes a validation failure.
235- browser = self.getViewBrowser(
236- self.snap, "+request-builds", user=self.person)
237- browser.getControl("amd64").selected = False
238- browser.getControl("i386").selected = False
239- browser.getControl("Request builds").click()
240- self.assertIn(
241- "You need to select at least one architecture.",
242- extract_text(find_main_content(browser.contents)))
243-
244- def test_request_builds_rejects_duplicate(self):
245- # A duplicate build request causes a notification.
246+ def test_request_builds_with_architectures_rejects_duplicate(self):
247+ # A duplicate build request with architectures selected causes a
248+ # notification.
249 self.snap.requestBuild(
250 self.person, self.ubuntu.main_archive, self.distroseries["amd64"],
251 PackagePublishingPocket.UPDATES)
252 browser = self.getViewBrowser(
253 self.snap, "+request-builds", user=self.person)
254- self.assertTrue(browser.getControl("amd64").selected)
255- self.assertTrue(browser.getControl("i386").selected)
256+ browser.getControl("amd64").selected = True
257+ browser.getControl("i386").selected = True
258 browser.getControl("Request builds").click()
259 main_text = extract_text(find_main_content(browser.contents))
260 self.assertIn("1 new build has been queued.", main_text)
261 self.assertIn(
262 "An identical build is already pending for amd64.", main_text)
263+
264+ def test_request_builds_no_architectures_action(self):
265+ # Requesting a build with no architectures selected creates a
266+ # pending build request.
267+ browser = self.getViewBrowser(
268+ self.snap, "+request-builds", user=self.person)
269+ self.assertFalse(browser.getControl("amd64").selected)
270+ self.assertFalse(browser.getControl("i386").selected)
271+ browser.getControl("Request builds").click()
272+
273+ login_person(self.person)
274+ [request] = self.snap.pending_build_requests
275+ self.assertThat(removeSecurityProxy(request), MatchesStructure(
276+ snap=Equals(self.snap),
277+ status=Equals(SnapBuildRequestStatus.PENDING),
278+ error_message=Is(None),
279+ builds=AfterPreprocessing(list, Equals([])),
280+ archive=Equals(self.ubuntu.main_archive),
281+ _job=MatchesStructure(
282+ requester=Equals(self.person),
283+ pocket=Equals(PackagePublishingPocket.UPDATES),
284+ channels=Is(None))))
285+
286+ def test_request_builds_no_architectures_ppa(self):
287+ # Selecting a different archive with no architectures selected
288+ # creates a build request targeting that archive.
289+ ppa = self.factory.makeArchive(
290+ distribution=self.ubuntu, owner=self.person, name="snap-ppa")
291+ browser = self.getViewBrowser(
292+ self.snap, "+request-builds", user=self.person)
293+ browser.getControl("PPA").click()
294+ browser.getControl(name="field.archive.ppa").value = ppa.reference
295+ self.assertFalse(browser.getControl("amd64").selected)
296+ self.assertFalse(browser.getControl("i386").selected)
297+ browser.getControl("Request builds").click()
298+
299+ login_person(self.person)
300+ [request] = self.snap.pending_build_requests
301+ self.assertEqual(ppa, request.archive)
302
303=== modified file 'lib/lp/snappy/interfaces/snap.py'
304--- lib/lp/snappy/interfaces/snap.py 2018-09-10 11:18:42 +0000
305+++ lib/lp/snappy/interfaces/snap.py 2018-09-13 15:15:44 +0000
306@@ -317,6 +317,11 @@
307 value_type=Reference(schema=Interface),
308 required=True, readonly=True))
309
310+ archive = Reference(
311+ IArchive,
312+ title=u"The source archive for builds produced by this request",
313+ required=True, readonly=True)
314+
315
316 class ISnapView(Interface):
317 """`ISnap` attributes that require launchpad.View permission."""
318@@ -449,21 +454,49 @@
319 :return: `ISnapBuildRequest`.
320 """
321
322+ pending_build_requests = exported(doNotSnapshot(CollectionField(
323+ title=_("Pending build requests for this snap package."),
324+ value_type=Reference(ISnapBuildRequest),
325+ required=True, readonly=True)))
326+
327+ # XXX cjwatson 2018-06-20: Deprecated as an exported method; can become
328+ # an internal helper method once production JavaScript no longer uses
329+ # it.
330 @operation_parameters(
331 snap_build_ids=List(
332- title=_("A list of snap build ids."),
333- value_type=Int()))
334+ title=_("A list of snap build IDs."), value_type=Int()))
335 @export_read_operation()
336 @operation_for_version("devel")
337 def getBuildSummariesForSnapBuildIds(snap_build_ids):
338 """Return a dictionary containing a summary of the build statuses.
339
340- :param snap_build_ids: A list of snap build ids.
341+ :param snap_build_ids: A list of snap build IDs.
342 :type source_ids: ``list``
343 :return: A dict consisting of the overall status summaries for the
344 given snap builds.
345 """
346
347+ @call_with(user=REQUEST_USER)
348+ @operation_parameters(
349+ request_ids=List(
350+ title=_("A list of snap build request IDs."), value_type=Int(),
351+ required=False),
352+ build_ids=List(
353+ title=_("A list of snap build IDs."), value_type=Int(),
354+ required=False))
355+ @export_read_operation()
356+ @operation_for_version("devel")
357+ def getBuildSummaries(request_ids=None, build_ids=None, user=None):
358+ """Return a dictionary containing a summary of build information.
359+
360+ :param request_ids: A list of snap build request IDs.
361+ :param build_ids: A list of snap build IDs.
362+ :param user: The `IPerson` requesting this information.
363+ :return: A dict of {"requests", "builds"}, consisting of the overall
364+ status summaries for the given snap build requests and snap
365+ builds respectively.
366+ """
367+
368 builds = exported(doNotSnapshot(CollectionField(
369 title=_("All builds of this snap package."),
370 description=_(
371
372=== modified file 'lib/lp/snappy/interfaces/snapjob.py'
373--- lib/lp/snappy/interfaces/snapjob.py 2018-09-10 11:18:42 +0000
374+++ lib/lp/snappy/interfaces/snapjob.py 2018-09-13 15:15:44 +0000
375@@ -112,6 +112,16 @@
376 for these builds.
377 """
378
379+ def findBySnap(snap, statuses=None, job_ids=None):
380+ """Find jobs for a snap.
381+
382+ :param snap: A snap package to search for.
383+ :param statuses: An optional iterable of `JobStatus`es to search for.
384+ :param job_ids: An optional iterable of job IDs to search for.
385+ :return: A sequence of `SnapRequestBuildsJob`s with the specified
386+ snap.
387+ """
388+
389 def getBySnapAndID(snap, job_id):
390 """Get a job by snap and job ID.
391
392@@ -119,3 +129,14 @@
393 :raises: `NotFoundError` if there is no job with the specified snap
394 and ID, or its `job_type` is not `SnapJobType.REQUEST_BUILDS`.
395 """
396+
397+ def findBuildsForJobs(jobs, user=None):
398+ """Find builds resulting from an iterable of `SnapRequestBuildJob`s.
399+
400+ :param jobs: An iterable of `SnapRequestBuildJob`s to search for.
401+ :param user: If passed, check that the builds are for archives
402+ visible by this user. (No access checks are performed on the
403+ snaps or on the builds.)
404+ :return: A dictionary mapping `SnapRequestBuildJob` IDs to lists of
405+ their resulting builds.
406+ """
407
408=== modified file 'lib/lp/snappy/javascript/snap.update_build_statuses.js'
409--- lib/lp/snappy/javascript/snap.update_build_statuses.js 2017-08-31 13:35:55 +0000
410+++ lib/lp/snappy/javascript/snap.update_build_statuses.js 2018-09-13 15:15:44 +0000
411@@ -1,4 +1,4 @@
412-/* Copyright 2016 Canonical Ltd. This software is licensed under the
413+/* Copyright 2016-2018 Canonical Ltd. This software is licensed under the
414 * GNU Affero General Public License version 3 (see the file LICENSE).
415 *
416 * The lp.snappy.snap.update_build_statuses module uses the
417@@ -15,11 +15,108 @@
418 module.pending_states = [
419 "NEEDSBUILD", "BUILDING", "UPLOADING", "CANCELLING"];
420
421+ module.update_date_built = function(node, build_summary) {
422+ node.set("text", build_summary.when_complete);
423+ if (build_summary.when_complete_estimate) {
424+ node.appendChild(document.createTextNode(' (estimated)'));
425+ }
426+ if (build_summary.build_log_url !== null) {
427+ var new_link = Y.Node.create(
428+ '<a class="sprite download">buildlog</a>');
429+ new_link.setAttribute('href', build_summary.build_log_url);
430+ node.appendChild(document.createTextNode(' '));
431+ node.appendChild(new_link);
432+ if (build_summary.build_log_size !== null) {
433+ node.appendChild(document.createTextNode(' '));
434+ node.append("(" + build_summary.build_log_size + " bytes)");
435+ }
436+ }
437+ };
438+
439 module.domUpdate = function(table, data_object) {
440- Y.each(data_object, function(build_summary, build_id) {
441+ var tbody = table.one('tbody');
442+ if (tbody === null) {
443+ return;
444+ }
445+ var tbody_changed = false;
446+
447+ Y.each(data_object['requests'], function(request_summary, request_id) {
448+ var tr_elem = tbody.one('tr#request-' + request_id);
449+ if (tr_elem === null) {
450+ return;
451+ }
452+
453+ if (request_summary['status'] === 'FAILED') {
454+ // XXX cjwatson 2018-06-18: Maybe we should show the error
455+ // message in this case, but we don't show non-pending
456+ // requests in the non-JS case, so it's not clear where
457+ // would be appropriate.
458+ tr_elem.remove();
459+ tbody_changed = true;
460+ return;
461+ } else if (request_summary['status'] === 'COMPLETED') {
462+ // Insert rows for the new builds.
463+ Y.Array.each(request_summary['builds'],
464+ function(build_summary) {
465+ // Construct the new row.
466+ var new_row = Y.Node.create(
467+ '<tr>' +
468+ '<td class="build_status"><img/><a/></td>' +
469+ '<td class="datebuilt"/>' +
470+ '<td><a class="sprite distribution"/></td>' +
471+ '<td><span class="archive-placeholder"/></td>' +
472+ '</tr>');
473+ new_row.set('id', 'build-' + build_summary.id);
474+ new_row.one('td.build_status a')
475+ .set('href', build_summary.self_link);
476+ Y.lp.buildmaster.buildstatus.update_build_status(
477+ new_row.one('td.build_status'), build_summary);
478+ if (build_summary.when_complete !== null) {
479+ module.update_date_built(
480+ new_row.one('td.datebuilt'), build_summary);
481+ }
482+ new_row.one('td a.distribution')
483+ .set('href', build_summary.distro_arch_series_link)
484+ .set('text', build_summary.architecture_tag);
485+ new_row.one('td .archive-placeholder')
486+ .replace(build_summary.archive_link);
487+
488+ // Insert the new row, maintaining descending-ID sorted
489+ // order.
490+ var tr_next = null;
491+ tbody.get('children').some(function(tr) {
492+ var tr_id = tr.get('id');
493+ if (tr_id !== null &&
494+ tr_id.substr(0, 6) === 'build-') {
495+ var build_id = parseInt(
496+ tr_id.replace('build-', ''), 10);
497+ if (!isNaN(build_id) &&
498+ build_id < build_summary.id) {
499+ tr_next = tr;
500+ return true;
501+ }
502+ }
503+ return false;
504+ });
505+ tbody.insert(new_row, tr_next);
506+ });
507+
508+ // Remove the completed build request row.
509+ tr_elem.remove();
510+ tbody_changed = true;
511+ return;
512+ }
513+ });
514+
515+ if (tbody_changed) {
516+ var anim = Y.lp.anim.green_flash({node: tbody});
517+ anim.run();
518+ }
519+
520+ Y.each(data_object['builds'], function(build_summary, build_id) {
521 var ui_changed = false;
522
523- var tr_elem = Y.one("tr#build-" + build_id);
524+ var tr_elem = tbody.one("tr#build-" + build_id);
525 if (tr_elem === null) {
526 return;
527 }
528@@ -38,25 +135,7 @@
529
530 if (build_summary.when_complete !== null) {
531 ui_changed = true;
532- td_datebuilt.set("innerHTML", build_summary.when_complete);
533- if (build_summary.when_complete_estimate) {
534- td_datebuilt.appendChild(
535- document.createTextNode(' (estimated)'));
536- }
537- if (build_summary.build_log_url !== null) {
538- var new_link = Y.Node.create(
539- '<a class="sprite download">buildlog</a>');
540- new_link.setAttribute(
541- 'href', build_summary.build_log_url);
542- td_datebuilt.appendChild(document.createTextNode(' '));
543- td_datebuilt.appendChild(new_link);
544- if (build_summary.build_log_size !== null) {
545- td_datebuilt.appendChild(
546- document.createTextNode(' '));
547- td_datebuilt.append(
548- "(" + build_summary.build_log_size + " bytes)");
549- }
550- }
551+ module.update_date_built(td_datebuilt, build_summary);
552 }
553
554 if (ui_changed) {
555@@ -67,32 +146,46 @@
556 };
557
558 module.parameterEvaluator = function(table_node) {
559- var td_list = table_node.all('td.build_status');
560- var pending = td_list.filter("." + module.pending_states.join(",."));
561- if (pending.size() === 0) {
562+ var td_request_list = table_node.all('td.request_status');
563+ var pending_requests = td_request_list.filter('.PENDING');
564+ var td_build_list = table_node.all('td.build_status');
565+ var pending_builds = td_build_list.filter(
566+ "." + module.pending_states.join(",."));
567+ if (pending_requests.size() === 0 && pending_builds.size() === 0) {
568 return null;
569 }
570
571- var snap_build_ids = [];
572- Y.each(pending, function(node) {
573- var elem_id = node.ancestor().get('id');
574- var snap_build_id = elem_id.replace('build-', '');
575- snap_build_ids.push(snap_build_id);
576- });
577-
578- return {snap_build_ids: snap_build_ids};
579+ var request_ids = [];
580+ Y.each(pending_requests, function(node) {
581+ var elem_id = node.ancestor().get('id');
582+ var request_id = elem_id.replace('request-', '');
583+ request_ids.push(request_id);
584+ });
585+
586+ var build_ids = [];
587+ Y.each(pending_builds, function(node) {
588+ var elem_id = node.ancestor().get('id');
589+ var build_id = elem_id.replace('build-', '');
590+ build_ids.push(build_id);
591+ });
592+
593+ return {request_ids: request_ids, build_ids: build_ids};
594 };
595
596 module.stopUpdatesCheck = function(table_node) {
597- // Stop updating when there aren't any builds to update
598- var td_list = table_node.all('td.build_status');
599- var pending = td_list.filter("." + module.pending_states.join(",."));
600- return (pending.size() === 0);
601+ // Stop updating when there aren't any build requests or builds to
602+ // update.
603+ var td_request_list = table_node.all('td.request_status');
604+ var pending_requests = td_request_list.filter('.PENDING');
605+ var td_build_list = table_node.all('td.build_status');
606+ var pending_builds = td_build_list.filter(
607+ "." + module.pending_states.join(",."));
608+ return pending_requests.size() === 0 && pending_builds.size() === 0;
609 };
610
611 module.config = {
612 uri: null,
613- api_method_name: 'getBuildSummariesForSnapBuildIds',
614+ api_method_name: 'getBuildSummaries',
615 lp_client: null,
616 domUpdateFunction: module.domUpdate,
617 parameterEvaluatorFunction: module.parameterEvaluator,
618
619=== modified file 'lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.html'
620--- lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.html 2017-08-31 13:35:55 +0000
621+++ lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.html 2018-09-13 15:15:44 +0000
622@@ -1,6 +1,6 @@
623 <!DOCTYPE html>
624 <!--
625-Copyright 2016 Canonical Ltd. This software is licensed under the
626+Copyright 2016-2018 Canonical Ltd. This software is licensed under the
627 GNU Affero General Public License version 3 (see the file LICENSE).
628 -->
629
630@@ -62,19 +62,10 @@
631 </tr>
632 </thead>
633 <tbody>
634- <tr id="build-1">
635- <td class="build_status NEEDSBUILD">
636- <img width="14" height="14" alt="[NEEDSBUILD]" title="Needs building" src="/@@/build-needed" />
637- <a href="snap/+build/1">Needs building</a>
638- </td>
639- <td class="datebuilt">
640- in 1 minute (estimated)
641- </td>
642- <td>
643- <a class="sprite distribution" href="/ubuntu/hoary/i386">i386</a>
644- </td>
645- <td>
646- <a href="/ubuntu" class="sprite distribution">Primary Archive for Ubuntu Linux</a>
647+ <tr id="request-1">
648+ <td class="request_status PENDING">
649+ <img width="14" height="14" alt="[PENDING]" title="Pending" src="/@@/build-needed" />
650+ Pending build request
651 </td>
652 </tr>
653 <tr id="build-2">
654@@ -94,6 +85,21 @@
655 <a href="/ubuntu" class="sprite distribution">Primary Archive for Ubuntu Linux</a>
656 </td>
657 </tr>
658+ <tr id="build-1">
659+ <td class="build_status NEEDSBUILD">
660+ <img width="14" height="14" alt="[NEEDSBUILD]" title="Needs building" src="/@@/build-needed" />
661+ <a href="snap/+build/1">Needs building</a>
662+ </td>
663+ <td class="datebuilt">
664+ in 1 minute (estimated)
665+ </td>
666+ <td>
667+ <a class="sprite distribution" href="/ubuntu/hoary/i386">i386</a>
668+ </td>
669+ <td>
670+ <a href="/ubuntu" class="sprite distribution">Primary Archive for Ubuntu Linux</a>
671+ </td>
672+ </tr>
673 </tbody>
674 </table>
675
676
677=== modified file 'lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.js'
678--- lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.js 2017-08-31 13:35:55 +0000
679+++ lib/lp/snappy/javascript/tests/test_snap.update_build_statuses.js 2018-09-13 15:15:44 +0000
680@@ -1,4 +1,4 @@
681-/* Copyright 2016 Canonical Ltd. This software is licensed under the
682+/* Copyright 2016-2018 Canonical Ltd. This software is licensed under the
683 * GNU Affero General Public License version 3 (see the file LICENSE). */
684
685 YUI.add('lp.snappy.snap.update_build_statuses.test', function (Y) {
686@@ -10,13 +10,42 @@
687 name: 'lp.snappy.snap.update_build_statuses_tests',
688
689 setUp: function () {
690- this.table = Y.one('table#latest-builds-listing');
691- this.tr_build_1 = Y.one('tr#build-1');
692+ // Clone the table from the test data so that we can reliably
693+ // restore it.
694+ this.table = Y.one('table#latest-builds-listing').cloneNode(true);
695+ this.tbody = this.table.one('tbody');
696+ this.tr_request_1 = this.tbody.one('tr#request-1');
697+ this.tr_build_1 = this.tbody.one('tr#build-1');
698 this.td_status = this.tr_build_1.one('td.build_status');
699 this.td_datebuilt = this.tr_build_1.one("td.datebuilt");
700- this.td_status_class = this.td_status.getAttribute("class");
701- this.td_status_img = this.td_status.one("img");
702- this.td_status_a = this.td_status.one("a");
703+ },
704+
705+ assert_node_matches: function(expected, node) {
706+ Y.each(expected, function(value, key) {
707+ if (key === "tag") {
708+ Y.Assert.areEqual(
709+ value, node.get("tagName").toLowerCase());
710+ } else if (key === "attrs") {
711+ Y.each(value, function(attr_value, attr_key) {
712+ Y.Assert.areEqual(
713+ attr_value, node.getAttribute(attr_key));
714+ });
715+ } else if (key === "text") {
716+ Y.Assert.areEqual(value, node.get("text").trim());
717+ } else if (key === "children") {
718+ var children = [];
719+ node.get("children").each(function(child) {
720+ children.push(child);
721+ });
722+ Y.Array.each(Y.Array.zip(value, children), function(item) {
723+ Y.Assert.isObject(item[0]);
724+ Y.Assert.isObject(item[1]);
725+ this.assert_node_matches(item[0], item[1]);
726+ }, this);
727+ } else {
728+ Y.Assert.fail("unhandled key " + key);
729+ }
730+ }, this);
731 },
732
733 test_dom_updater_plugin_attached: function() {
734@@ -32,167 +61,354 @@
735
736 test_parameter_evaluator: function() {
737 // parameterEvaluator should return an object with the ids of
738- // builds in pending states.
739+ // build requests and builds in pending states.
740 var params = module.parameterEvaluator(this.table);
741 Y.lp.testing.assert.assert_equal_structure(
742- {snap_build_ids: ["1"]}, params);
743+ {request_ids: ["1"], build_ids: ["1"]}, params);
744 },
745
746 test_parameter_evaluator_empty: function() {
747 // parameterEvaluator should return empty if no builds remaining
748 // in pending states.
749+ this.tr_request_1.remove();
750 this.td_status.setAttribute("class", "build_status FULLYBUILT");
751 var params = module.parameterEvaluator(this.table);
752 Y.Assert.isNull(params);
753- // reset td class to the original value
754- this.td_status.setAttribute("class", this.td_status_class);
755 },
756
757 test_stop_updates_check: function() {
758- // stopUpdatesCheck should return false if pending builds exist.
759- Y.Assert.isFalse(module.stopUpdatesCheck(this.table));
760- // stopUpdatesCheck should return true if no pending builds exist.
761+ // stopUpdatesCheck should return false if pending build
762+ // requests or pending builds exist.
763+ Y.Assert.isFalse(module.stopUpdatesCheck(this.table));
764+ this.tr_request_1.one('td.request_status')
765+ .setAttribute('class', 'request_status COMPLETED');
766+ Y.Assert.isFalse(module.stopUpdatesCheck(this.table));
767+ // stopUpdatesCheck should return true if no pending build
768+ // requests or pending builds exist.
769 this.td_status.setAttribute("class", "build_status FULLYBUILT");
770 Y.Assert.isTrue(module.stopUpdatesCheck(this.table));
771+ this.tr_request_1.remove();
772+ Y.Assert.isTrue(module.stopUpdatesCheck(this.table));
773 for (var i = 0; i < module.pending_states.length; i++) {
774 this.td_status.setAttribute(
775 "class", "build_status " + module.pending_states[i]);
776 Y.Assert.isFalse(module.stopUpdatesCheck(this.table));
777 }
778- // reset td class to the original value
779- this.td_status.setAttribute("class", this.td_status_class);
780- },
781-
782- test_update_build_status_dom_building: function() {
783- var original_a_href = this.td_status_a.get("href");
784- var data = {
785- "1": {
786- "status": "BUILDING",
787- "build_log_url": null,
788- "when_complete_estimate": true,
789- "buildstate": "Currently building",
790- "build_log_size": null,
791- "when_complete": "in 1 minute"
792- }
793- };
794- module.domUpdate(this.table, data);
795- Y.Assert.areEqual(
796- "build_status BUILDING", this.td_status.getAttribute("class"));
797- Y.Assert.areEqual(
798- "Currently building", this.td_status.get("text").trim());
799- Y.Assert.areEqual("[BUILDING]", this.td_status_img.get("alt"));
800- Y.Assert.areEqual(
801- "Currently building", this.td_status_img.get("title"));
802- Y.Assert.areEqual(
803- "file:///@@/processing", this.td_status_img.get("src"));
804- Y.Assert.areEqual("14", this.td_status_img.get("width"));
805- Y.Assert.areEqual(original_a_href, this.td_status_a.get("href"));
806- },
807-
808- test_update_build_status_dom_building: function() {
809- var original_a_href = this.td_status_a.get("href");
810- var data = {
811- "1": {
812- "status": "BUILDING",
813- "build_log_url": null,
814- "when_complete_estimate": true,
815- "buildstate": "Currently building",
816- "build_log_size": null,
817- "when_complete": "in 1 minute"
818- }
819- };
820- module.domUpdate(this.table, data);
821- Y.Assert.areEqual(
822- "build_status BUILDING", this.td_status.getAttribute("class"));
823- Y.Assert.areEqual(
824- "Currently building", this.td_status.get("text").trim());
825- Y.Assert.areEqual("[BUILDING]", this.td_status_img.get("alt"));
826- Y.Assert.areEqual(
827- "Currently building", this.td_status_img.get("title"));
828- Y.Assert.areEqual(
829- "file:///@@/processing", this.td_status_img.get("src"));
830- Y.Assert.areEqual("14", this.td_status_img.get("width"));
831- Y.Assert.areEqual(original_a_href, this.td_status_a.get("href"));
832+ },
833+
834+ test_update_build_request_status_dom_completed: function() {
835+ var data = {
836+ "requests": {
837+ "1": {
838+ "status": "COMPLETED",
839+ "error_message": null,
840+ "builds": [
841+ {
842+ "self_link": "/~max/+snap/snap/+build/3",
843+ "id": 3,
844+ "distro_arch_series_link":
845+ "/ubuntu/hoary/amd64",
846+ "architecture_tag": "amd64",
847+ "archive_link":
848+ '<a href="/ubuntu" ' +
849+ 'class="sprite distribution">Primary ' +
850+ 'Archive for Ubuntu Linux</a>',
851+ "status": "NEEDSBUILD",
852+ "build_log_url": null,
853+ "when_complete_estimate": false,
854+ "buildstate": "Needs building",
855+ "build_log_size": null,
856+ "when_complete": null
857+ },
858+ {
859+ "self_link": "/~max/+snap/snap/+build/4",
860+ "id": 4,
861+ "distro_arch_series_link":
862+ "/ubuntu/hoary/i386",
863+ "architecture_tag": "i386",
864+ "archive_link":
865+ '<a href="/ubuntu" ' +
866+ 'class="sprite distribution">Primary ' +
867+ 'Archive for Ubuntu Linux</a>',
868+ "status": "BUILDING",
869+ "build_log_url": null,
870+ "when_complete_estimate": true,
871+ "buildstate": "Currently building",
872+ "build_log_size": null,
873+ "when_complete": "in 1 minute"
874+ }
875+ ]
876+ }
877+ },
878+ "builds": {}
879+ };
880+ module.domUpdate(this.table, data);
881+ Y.ArrayAssert.itemsAreEqual(
882+ ["build-4", "build-3", "build-2", "build-1"],
883+ this.tbody.get("children").get("id"));
884+ this.assert_node_matches({
885+ "tag": "tr",
886+ "attrs": {"id": "build-3"},
887+ "children": [
888+ {
889+ "tag": "td",
890+ "attrs": {"class": "build_status NEEDSBUILD"},
891+ "children": [
892+ {
893+ "tag": "img",
894+ "attrs": {
895+ "alt": "[NEEDSBUILD]",
896+ "title": "Needs building",
897+ "src": "/@@/build-needed",
898+ "width": "14"
899+ }
900+ },
901+ {
902+ "tag": "a",
903+ "attrs": {"href": "/~max/+snap/snap/+build/3"},
904+ "text": "Needs building"
905+ }
906+ ]
907+ },
908+ {
909+ "tag": "td",
910+ "attrs": {"class": "datebuilt"},
911+ "text": "",
912+ "children": []
913+ },
914+ {
915+ "tag": "td",
916+ "children": [{
917+ "tag": "a",
918+ "attrs": {
919+ "class": "sprite distribution",
920+ "href": "/ubuntu/hoary/amd64"
921+ },
922+ "text": "amd64"
923+ }]
924+ },
925+ {
926+ "tag": "td",
927+ "children": [{
928+ "tag": "a",
929+ "attrs": {
930+ "class": "sprite distribution",
931+ "href": "/ubuntu"
932+ },
933+ "text": "Primary Archive for Ubuntu Linux"
934+ }]
935+ }
936+ ]
937+ }, this.tbody.one("tr#build-3"));
938+ this.assert_node_matches({
939+ "tag": "tr",
940+ "attrs": {"id": "build-4"},
941+ "children": [
942+ {
943+ "tag": "td",
944+ "attrs": {"class": "build_status BUILDING"},
945+ "children": [
946+ {
947+ "tag": "img",
948+ "attrs": {
949+ "alt": "[BUILDING]",
950+ "title": "Currently building",
951+ "src": "/@@/processing",
952+ "width": "14"
953+ }
954+ },
955+ {
956+ "tag": "a",
957+ "attrs": {"href": "/~max/+snap/snap/+build/4"},
958+ "text": "Currently building"
959+ }
960+ ]
961+ },
962+ {
963+ "tag": "td",
964+ "attrs": {"class": "datebuilt"},
965+ "text": "in 1 minute (estimated)",
966+ "children": []
967+ },
968+ {
969+ "tag": "td",
970+ "children": [{
971+ "tag": "a",
972+ "attrs": {
973+ "class": "sprite distribution",
974+ "href": "/ubuntu/hoary/i386"
975+ },
976+ "text": "i386"
977+ }]
978+ },
979+ {
980+ "tag": "td",
981+ "children": [{
982+ "tag": "a",
983+ "attrs": {
984+ "class": "sprite distribution",
985+ "href": "/ubuntu"
986+ },
987+ "text": "Primary Archive for Ubuntu Linux"
988+ }]
989+ }
990+ ]
991+ }, this.tbody.one("tr#build-4"));
992+ },
993+
994+ test_update_build_request_status_dom_failed: function() {
995+ var data = {
996+ "requests": {
997+ "1": {
998+ "status": "FAILED",
999+ "error_message": "Something went wrong",
1000+ "builds": []
1001+ }
1002+ },
1003+ "builds": {}
1004+ };
1005+ module.domUpdate(this.table, data);
1006+ Y.ArrayAssert.itemsAreEqual(
1007+ ["build-2", "build-1"], this.tbody.get("children").get("id"));
1008+ },
1009+
1010+ test_update_build_status_dom_building: function() {
1011+ var original_a_href = this.td_status.one("a").getAttribute("href");
1012+ var data = {
1013+ "requests": {},
1014+ "builds": {
1015+ "1": {
1016+ "status": "BUILDING",
1017+ "build_log_url": null,
1018+ "when_complete_estimate": true,
1019+ "buildstate": "Currently building",
1020+ "build_log_size": null,
1021+ "when_complete": "in 1 minute"
1022+ }
1023+ }
1024+ };
1025+ module.domUpdate(this.table, data);
1026+ this.assert_node_matches({
1027+ "attrs": {"class": "build_status BUILDING"},
1028+ "text": "Currently building",
1029+ "children": [
1030+ {
1031+ "tag": "img",
1032+ "attrs": {
1033+ "alt": "[BUILDING]",
1034+ "title": "Currently building",
1035+ "src": "/@@/processing",
1036+ "width": "14"
1037+ }
1038+ },
1039+ {
1040+ "tag": "a",
1041+ "attrs": {"href": original_a_href}
1042+ }
1043+ ]
1044+ }, this.td_status);
1045 },
1046
1047 test_update_build_status_dom_failedtobuild: function() {
1048- var original_a_href = this.td_status_a.get("href");
1049+ var original_a_href = this.td_status.one("a").getAttribute("href");
1050 var data = {
1051- "1": {
1052- "status": "FAILEDTOBUILD",
1053- "build_log_url": null,
1054- "when_complete_estimate": false,
1055- "buildstate": "Failed to build",
1056- "build_log_size": null,
1057- "when_complete": "1 minute ago"
1058+ "requests": {},
1059+ "builds": {
1060+ "1": {
1061+ "status": "FAILEDTOBUILD",
1062+ "build_log_url": null,
1063+ "when_complete_estimate": false,
1064+ "buildstate": "Failed to build",
1065+ "build_log_size": null,
1066+ "when_complete": "1 minute ago"
1067+ }
1068 }
1069 };
1070 module.domUpdate(this.table, data);
1071- Y.Assert.areEqual(
1072- "build_status FAILEDTOBUILD",
1073- this.td_status.getAttribute("class"));
1074- Y.Assert.areEqual(
1075- "Failed to build", this.td_status.get("text").trim());
1076- Y.Assert.areEqual(
1077- "[FAILEDTOBUILD]", this.td_status_img.get("alt"));
1078- Y.Assert.areEqual(
1079- "Failed to build", this.td_status_img.get("title"));
1080- Y.Assert.areEqual(
1081- "file:///@@/build-failed", this.td_status_img.get("src"));
1082- Y.Assert.areEqual("16", this.td_status_img.get("width"));
1083- Y.Assert.areEqual(original_a_href, this.td_status_a.get("href"));
1084+ this.assert_node_matches({
1085+ "attrs": {"class": "build_status FAILEDTOBUILD"},
1086+ "text": "Failed to build",
1087+ "children": [
1088+ {
1089+ "tag": "img",
1090+ "attrs": {
1091+ "alt": "[FAILEDTOBUILD]",
1092+ "title": "Failed to build",
1093+ "src": "/@@/build-failed",
1094+ "width": "16"
1095+ }
1096+ },
1097+ {
1098+ "tag": "a",
1099+ "attrs": {"href": original_a_href}
1100+ }
1101+ ]
1102+ }, this.td_status);
1103 },
1104
1105 test_update_build_status_dom_chrootwait: function() {
1106- var original_a_href = this.td_status_a.get("href");
1107+ var original_a_href = this.td_status.one("a").getAttribute("href");
1108 var data = {
1109- "1": {
1110- "status": "CHROOTWAIT",
1111- "build_log_url": null,
1112- "when_complete_estimate": false,
1113- "buildstate": "Chroot problem",
1114- "build_log_size": null,
1115- "when_complete": "1 minute ago"
1116+ "requests": {},
1117+ "builds": {
1118+ "1": {
1119+ "status": "CHROOTWAIT",
1120+ "build_log_url": null,
1121+ "when_complete_estimate": false,
1122+ "buildstate": "Chroot problem",
1123+ "build_log_size": null,
1124+ "when_complete": "1 minute ago"
1125+ }
1126 }
1127 };
1128 module.domUpdate(this.table, data);
1129- Y.Assert.areEqual(
1130- "build_status CHROOTWAIT",
1131- this.td_status.getAttribute("class"));
1132- Y.Assert.areEqual(
1133- "Chroot problem", this.td_status.get("text").trim());
1134- Y.Assert.areEqual("[CHROOTWAIT]", this.td_status_img.get("alt"));
1135- Y.Assert.areEqual(
1136- "Chroot problem", this.td_status_img.get("title"));
1137- Y.Assert.areEqual(
1138- "file:///@@/build-chrootwait", this.td_status_img.get("src"));
1139- Y.Assert.areEqual("14", this.td_status_img.get("width"));
1140- Y.Assert.areEqual(original_a_href, this.td_status_a.get("href"));
1141+ this.assert_node_matches({
1142+ "attrs": {"class": "build_status CHROOTWAIT"},
1143+ "text": "Chroot problem",
1144+ "children": [
1145+ {
1146+ "tag": "img",
1147+ "attrs": {
1148+ "alt": "[CHROOTWAIT]",
1149+ "title": "Chroot problem",
1150+ "src": "/@@/build-chrootwait",
1151+ "width": "14"
1152+ }
1153+ },
1154+ {
1155+ "tag": "a",
1156+ "attrs": {"href": original_a_href}
1157+ }
1158+ ]
1159+ }, this.td_status);
1160 },
1161
1162 test_update_build_date_dom: function() {
1163 var data = {
1164- "1": {
1165- "status": "NEEDSBUILD",
1166- "build_log_url": "/+build/1/+files/build1.txt.gz",
1167- "when_complete_estimate": true,
1168- "buildstate": "Needs building",
1169- "build_log_size": 12345,
1170- "when_complete": "in 30 seconds"
1171+ "requests": {},
1172+ "builds": {
1173+ "1": {
1174+ "status": "NEEDSBUILD",
1175+ "build_log_url": "/+build/1/+files/build1.txt.gz",
1176+ "when_complete_estimate": true,
1177+ "buildstate": "Needs building",
1178+ "build_log_size": 12345,
1179+ "when_complete": "in 30 seconds"
1180+ }
1181 }
1182 };
1183 module.domUpdate(this.table, data);
1184- Y.Assert.areEqual(
1185- "in 30 seconds (estimated) buildlog (12345 bytes)",
1186- this.td_datebuilt.get("text").trim());
1187- var td_datebuilt_a = this.td_datebuilt.one("a");
1188- Y.Assert.isNotNull(td_datebuilt_a);
1189- Y.Assert.areEqual("buildlog", td_datebuilt_a.get("text").trim());
1190- Y.Assert.areEqual(
1191- "sprite download", td_datebuilt_a.getAttribute("class"));
1192- Y.Assert.areEqual(
1193- "file://" + data["1"].build_log_url,
1194- td_datebuilt_a.get("href"));
1195+ this.assert_node_matches({
1196+ "text": "in 30 seconds (estimated) buildlog (12345 bytes)",
1197+ "children": [{
1198+ "tag": "a",
1199+ "attrs": {
1200+ "class": "sprite download",
1201+ "href": data["builds"]["1"].build_log_url
1202+ },
1203+ "text": "buildlog"
1204+ }]
1205+ }, this.td_datebuilt);
1206 }
1207 }));
1208
1209
1210=== modified file 'lib/lp/snappy/model/snap.py'
1211--- lib/lp/snappy/model/snap.py 2018-09-10 11:18:42 +0000
1212+++ lib/lp/snappy/model/snap.py 2018-09-13 15:15:44 +0000
1213@@ -45,7 +45,10 @@
1214 from zope.security.interfaces import Unauthorized
1215 from zope.security.proxy import removeSecurityProxy
1216
1217-from lp.app.browser.tales import DateTimeFormatterAPI
1218+from lp.app.browser.tales import (
1219+ ArchiveFormatterAPI,
1220+ DateTimeFormatterAPI,
1221+ )
1222 from lp.app.enums import PRIVATE_INFORMATION_TYPES
1223 from lp.app.errors import (
1224 IncompatibleArguments,
1225@@ -119,7 +122,13 @@
1226 LibraryFileContent,
1227 )
1228 from lp.services.openid.adapters.openid import CurrentOpenIDEndPoint
1229+from lp.services.propertycache import (
1230+ cachedproperty,
1231+ get_property_cache,
1232+ )
1233+from lp.services.webapp.authorization import precache_permission_for_objects
1234 from lp.services.webapp.interfaces import ILaunchBag
1235+from lp.services.webapp.publisher import canonical_url
1236 from lp.services.webhooks.interfaces import IWebhookSet
1237 from lp.services.webhooks.model import WebhookTargetMixin
1238 from lp.snappy.adapters.buildarch import determine_architectures_to_build
1239@@ -178,11 +187,21 @@
1240 """
1241
1242 def __init__(self, snap, id):
1243- self._job = getUtility(ISnapRequestBuildsJobSource).getBySnapAndID(
1244- snap, id)
1245 self.snap = snap
1246 self.id = id
1247
1248+ @classmethod
1249+ def fromJob(cls, job):
1250+ """See `ISnapBuildRequest`."""
1251+ request = cls(job.snap, job.job_id)
1252+ get_property_cache(request)._job = job
1253+ return request
1254+
1255+ @cachedproperty
1256+ def _job(self):
1257+ job_source = getUtility(ISnapRequestBuildsJobSource)
1258+ return job_source.getBySnapAndID(self.snap, self.id)
1259+
1260 @property
1261 def date_requested(self):
1262 """See `ISnapBuildRequest`."""
1263@@ -215,6 +234,11 @@
1264 """See `ISnapBuildRequest`."""
1265 return self._job.builds
1266
1267+ @property
1268+ def archive(self):
1269+ """See `ISnapBuildRequest`."""
1270+ return self._job.archive
1271+
1272
1273 @implementer(ISnap, IHasOwner)
1274 class Snap(Storm, WebhookTargetMixin):
1275@@ -643,6 +667,15 @@
1276 """See `ISnap`."""
1277 return SnapBuildRequest(self, job_id)
1278
1279+ @property
1280+ def pending_build_requests(self):
1281+ """See `ISnap`."""
1282+ job_source = getUtility(ISnapRequestBuildsJobSource)
1283+ # The returned jobs are ordered by descending ID.
1284+ jobs = job_source.findBySnap(
1285+ self, statuses=(JobStatus.WAITING, JobStatus.RUNNING))
1286+ return [SnapBuildRequest.fromJob(job) for job in jobs]
1287+
1288 def _getBuilds(self, filter_term, order_by):
1289 """The actual query to get the builds."""
1290 query_args = [
1291@@ -673,6 +706,10 @@
1292 order_by = Desc(SnapBuild.id)
1293 builds = self._getBuilds(filter_term, order_by)
1294
1295+ # The user can obviously see this snap, and Snap._getBuilds ensures
1296+ # that they can see the relevant archive for each build as well.
1297+ precache_permission_for_objects(None, "launchpad.View", builds)
1298+
1299 # Prefetch data to keep DB query count constant
1300 lfas = load_related(LibraryFileAlias, builds, ["log_id"])
1301 load_related(LibraryFileContent, lfas, ["contentID"])
1302@@ -698,6 +735,66 @@
1303 }
1304 return result
1305
1306+ def getBuildSummaries(self, request_ids=None, build_ids=None, user=None):
1307+ """See `ISnap`."""
1308+ all_build_ids = []
1309+ result = {"requests": {}, "builds": {}}
1310+
1311+ if request_ids:
1312+ job_source = getUtility(ISnapRequestBuildsJobSource)
1313+ jobs = job_source.findBySnap(self, job_ids=request_ids)
1314+ requests = [SnapBuildRequest.fromJob(job) for job in jobs]
1315+ builds_by_request = job_source.findBuildsForJobs(jobs, user=user)
1316+ for builds in builds_by_request.values():
1317+ # It's safe to remove the proxy here, because the IDs will
1318+ # go through Snap._getBuilds which checks visibility. This
1319+ # saves an Archive query per build in the security adapter.
1320+ all_build_ids.extend(
1321+ [removeSecurityProxy(build).id for build in builds])
1322+ else:
1323+ requests = []
1324+
1325+ if build_ids:
1326+ all_build_ids.extend(build_ids)
1327+
1328+ all_build_summaries = self.getBuildSummariesForSnapBuildIds(
1329+ all_build_ids)
1330+
1331+ for request in requests:
1332+ build_summaries = []
1333+ for build in sorted(
1334+ builds_by_request[request.id], key=attrgetter("id"),
1335+ reverse=True):
1336+ if build.id in all_build_summaries:
1337+ # Include enough information for
1338+ # snap.update_build_statuses.js to populate new build
1339+ # rows.
1340+ build_summary = {
1341+ "self_link": canonical_url(
1342+ build, path_only_if_possible=True),
1343+ "id": build.id,
1344+ "distro_arch_series_link": canonical_url(
1345+ build.distro_arch_series,
1346+ path_only_if_possible=True),
1347+ "architecture_tag": (
1348+ build.distro_arch_series.architecturetag),
1349+ "archive_link": ArchiveFormatterAPI(
1350+ build.archive).link(None),
1351+ }
1352+ build_summary.update(all_build_summaries[build.id])
1353+ build_summaries.append(build_summary)
1354+ result["requests"][request.id] = {
1355+ "status": request.status.name,
1356+ "error_message": request.error_message,
1357+ "builds": build_summaries,
1358+ }
1359+
1360+ for build_id in (build_ids or []):
1361+ if build_id in all_build_summaries:
1362+ result["builds"][build_id] = all_build_summaries[build_id]
1363+
1364+ return result
1365+
1366 @property
1367 def builds(self):
1368 """See `ISnap`."""
1369
1370=== modified file 'lib/lp/snappy/model/snapjob.py'
1371--- lib/lp/snappy/model/snapjob.py 2018-09-10 11:18:42 +0000
1372+++ lib/lp/snappy/model/snapjob.py 2018-09-13 15:15:44 +0000
1373@@ -12,12 +12,15 @@
1374 'SnapRequestBuildsJob',
1375 ]
1376
1377+from itertools import chain
1378+
1379 from lazr.delegates import delegate_to
1380 from lazr.enum import (
1381 DBEnumeratedType,
1382 DBItem,
1383 )
1384 from storm.locals import (
1385+ Desc,
1386 Int,
1387 JSON,
1388 Reference,
1389@@ -29,11 +32,14 @@
1390 implementer,
1391 provider,
1392 )
1393+from zope.security.proxy import removeSecurityProxy
1394
1395 from lp.app.errors import NotFoundError
1396 from lp.registry.interfaces.person import IPersonSet
1397 from lp.registry.interfaces.pocket import PackagePublishingPocket
1398 from lp.services.config import config
1399+from lp.services.database.bulk import load_related
1400+from lp.services.database.decoratedresultset import DecoratedResultSet
1401 from lp.services.database.enumcol import EnumCol
1402 from lp.services.database.interfaces import (
1403 IMasterStore,
1404@@ -58,7 +64,10 @@
1405 ISnapRequestBuildsJobSource,
1406 )
1407 from lp.snappy.model.snapbuild import SnapBuild
1408-from lp.soyuz.model.archive import Archive
1409+from lp.soyuz.model.archive import (
1410+ Archive,
1411+ get_enabled_archive_filter,
1412+ )
1413
1414
1415 class SnapJobType(DBEnumeratedType):
1416@@ -188,6 +197,30 @@
1417 return job
1418
1419 @classmethod
1420+ def findBySnap(cls, snap, statuses=None, job_ids=None):
1421+ """See `ISnapRequestBuildsJobSource`."""
1422+ clauses = [
1423+ SnapJob.snap == snap,
1424+ SnapJob.job_type == cls.class_job_type,
1425+ ]
1426+ if statuses is not None:
1427+ clauses.extend([
1428+ SnapJob.job == Job.id,
1429+ Job._status.is_in(statuses),
1430+ ])
1431+ if job_ids is not None:
1432+ clauses.append(SnapJob.job_id.is_in(job_ids))
1433+ snap_jobs = IStore(SnapJob).find(SnapJob, *clauses).order_by(
1434+ Desc(SnapJob.job_id))
1435+
1436+ def preload_jobs(rows):
1437+ load_related(Job, rows, ["job_id"])
1438+
1439+ return DecoratedResultSet(
1440+ snap_jobs, lambda snap_job: cls(snap_job),
1441+ pre_iter_hook=preload_jobs)
1442+
1443+ @classmethod
1444 def getBySnapAndID(cls, snap, job_id):
1445 """See `ISnapRequestBuildsJobSource`."""
1446 snap_job = IStore(SnapJob).find(
1447@@ -201,6 +234,31 @@
1448 (job_id, snap))
1449 return cls(snap_job)
1450
1451+ @classmethod
1452+ def findBuildsForJobs(cls, jobs, user=None):
1453+ """See `ISnapRequestBuildsJobSource`."""
1454+ build_ids = {
1455+ job.job_id: removeSecurityProxy(job).metadata.get("builds") or []
1456+ for job in jobs}
1457+ all_build_ids = set(chain.from_iterable(build_ids.values()))
1458+ if all_build_ids:
1459+ all_builds = {
1460+ build.id: build for build in IStore(SnapBuild).find(
1461+ SnapBuild,
1462+ SnapBuild.id.is_in(all_build_ids),
1463+ SnapBuild.archive_id == Archive.id,
1464+ Archive._enabled == True,
1465+ get_enabled_archive_filter(
1466+ user, include_public=True, include_subscribed=True))
1467+ }
1468+ else:
1469+ all_builds = {}
1470+ return {
1471+ job.job_id: [
1472+ all_builds[build_id] for build_id in build_ids[job.job_id]
1473+ if build_id in all_builds]
1474+ for job in jobs}
1475+
1476 def getOperationDescription(self):
1477 return "requesting builds of %s" % self.snap.name
1478
1479@@ -261,11 +319,11 @@
1480 def builds(self):
1481 """See `ISnapRequestBuildsJob`."""
1482 build_ids = self.metadata.get("builds")
1483- if build_ids is None:
1484- return EmptyResultSet()
1485- else:
1486+ if build_ids:
1487 return IStore(SnapBuild).find(
1488 SnapBuild, SnapBuild.id.is_in(build_ids))
1489+ else:
1490+ return EmptyResultSet()
1491
1492 @builds.setter
1493 def builds(self, builds):
1494
1495=== modified file 'lib/lp/snappy/templates/snap-index.pt'
1496--- lib/lp/snappy/templates/snap-index.pt 2018-04-30 16:48:47 +0000
1497+++ lib/lp/snappy/templates/snap-index.pt 2018-09-13 15:15:44 +0000
1498@@ -156,6 +156,18 @@
1499 </tr>
1500 </thead>
1501 <tbody>
1502+ <tal:snap-build-requests repeat="request context/pending_build_requests">
1503+ <tr tal:attributes="id string:request-${request/id}">
1504+ <td colspan="3"
1505+ tal:attributes="class string:request_status ${request/status/name}">
1506+ <span tal:replace="structure request/image:icon"/>
1507+ <tal:title replace="request/status/title"/> build request
1508+ </td>
1509+ <td>
1510+ <tal:archive replace="structure request/archive/fmt:link"/>
1511+ </td>
1512+ </tr>
1513+ </tal:snap-build-requests>
1514 <tal:snap-builds repeat="build view/builds">
1515 <tr tal:attributes="id string:build-${build/id}">
1516 <td tal:attributes="class string:build_status ${build/status/name}">
1517
1518=== modified file 'lib/lp/snappy/tests/test_snap.py'
1519--- lib/lp/snappy/tests/test_snap.py 2018-09-10 11:18:42 +0000
1520+++ lib/lp/snappy/tests/test_snap.py 2018-09-13 15:15:44 +0000
1521@@ -12,6 +12,7 @@
1522 timedelta,
1523 )
1524 import json
1525+from operator import attrgetter
1526 from textwrap import dedent
1527 from urlparse import urlsplit
1528
1529@@ -176,7 +177,9 @@
1530 self.assertThat(
1531 self.factory.makeSnap(),
1532 DoesNotSnapshot(
1533- ["builds", "completed_builds", "pending_builds"], ISnapView))
1534+ ["pending_build_requests",
1535+ "builds", "completed_builds", "pending_builds"],
1536+ ISnapView))
1537
1538 def test_initial_date_last_modified(self):
1539 # The initial value of date_last_modified is date_created.
1540@@ -424,7 +427,8 @@
1541 snap=Equals(snap),
1542 status=Equals(SnapBuildRequestStatus.PENDING),
1543 error_message=Is(None),
1544- builds=AfterPreprocessing(set, MatchesSetwise())))
1545+ builds=AfterPreprocessing(set, MatchesSetwise()),
1546+ archive=Equals(snap.distro_series.main_archive)))
1547 [job] = getUtility(ISnapRequestBuildsJobSource).iterReady()
1548 self.assertThat(job, MatchesStructure(
1549 job_id=Equals(request.id),
1550@@ -744,6 +748,172 @@
1551 1, 5)
1552 self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
1553
1554+ def test_getBuildSummaries(self):
1555+ snap1 = self.factory.makeSnap()
1556+ snap2 = self.factory.makeSnap()
1557+ request11 = self.factory.makeSnapBuildRequest(snap=snap1)
1558+ request12 = self.factory.makeSnapBuildRequest(snap=snap1)
1559+ request2 = self.factory.makeSnapBuildRequest(snap=snap2)
1560+ self.factory.makeSnapBuildRequest()
1561+ build11 = self.factory.makeSnapBuild(snap=snap1)
1562+ build12 = self.factory.makeSnapBuild(snap=snap1)
1563+ build2 = self.factory.makeSnapBuild(snap=snap2)
1564+ self.factory.makeSnapBuild()
1565+ summary1 = snap1.getBuildSummaries(
1566+ request_ids=[request11.id, request12.id],
1567+ build_ids=[build11.id, build12.id])
1568+ summary2 = snap2.getBuildSummaries(
1569+ request_ids=[request2.id], build_ids=[build2.id])
1570+ request_summary_matcher = MatchesDict({
1571+ "status": Equals("PENDING"),
1572+ "error_message": Is(None),
1573+ "builds": Equals([]),
1574+ })
1575+ build_summary_matcher = MatchesDict({
1576+ "status": Equals("NEEDSBUILD"),
1577+ "buildstate": Equals("Needs building"),
1578+ "when_complete": Is(None),
1579+ "when_complete_estimate": Is(False),
1580+ "build_log_url": Is(None),
1581+ "build_log_size": Is(None),
1582+ })
1583+ self.assertThat(summary1, MatchesDict({
1584+ "requests": MatchesDict({
1585+ request11.id: request_summary_matcher,
1586+ request12.id: request_summary_matcher,
1587+ }),
1588+ "builds": MatchesDict({
1589+ build11.id: build_summary_matcher,
1590+ build12.id: build_summary_matcher,
1591+ }),
1592+ }))
1593+ self.assertThat(summary2, MatchesDict({
1594+ "requests": MatchesDict({request2.id: request_summary_matcher}),
1595+ "builds": MatchesDict({build2.id: build_summary_matcher}),
1596+ }))
1597+
1598+ def test_getBuildSummaries_empty_input(self):
1599+ snap = self.factory.makeSnap()
1600+ self.factory.makeSnapBuildRequest(snap=snap)
1601+ self.assertEqual(
1602+ {"requests": {}, "builds": {}},
1603+ snap.getBuildSummaries(request_ids=None, build_ids=None))
1604+ self.assertEqual(
1605+ {"requests": {}, "builds": {}},
1606+ snap.getBuildSummaries(request_ids=[], build_ids=[]))
1607+ self.assertEqual(
1608+ {"requests": {}, "builds": {}},
1609+ snap.getBuildSummaries(request_ids=(), build_ids=()))
1610+
1611+ def test_getBuildSummaries_not_matching_snap(self):
1612+ # getBuildSummaries does not return information for other snaps.
1613+ snap1 = self.factory.makeSnap()
1614+ snap2 = self.factory.makeSnap()
1615+ self.factory.makeSnapBuildRequest(snap=snap1)
1616+ self.factory.makeSnapBuild(snap=snap1)
1617+ request2 = self.factory.makeSnapBuildRequest(snap=snap2)
1618+ build2 = self.factory.makeSnapBuild(snap=snap2)
1619+ summary1 = snap1.getBuildSummaries(
1620+ request_ids=[request2.id], build_ids=[build2.id])
1621+ self.assertEqual({"requests": {}, "builds": {}}, summary1)
1622+
1623+ def test_getBuildSummaries_request_error_message_field(self):
1624+ # The error_message field for a build request should be None unless
1625+ # the build request failed.
1626+ snap = self.factory.makeSnap()
1627+ request = self.factory.makeSnapBuildRequest(snap=snap)
1628+ self.assertIsNone(request.error_message)
1629+ summary = snap.getBuildSummaries(request_ids=[request.id])
1630+ self.assertIsNone(summary["requests"][request.id]["error_message"])
1631+ job = removeSecurityProxy(request)._job
1632+ removeSecurityProxy(job).error_message = "Boom"
1633+ summary = snap.getBuildSummaries(request_ids=[request.id])
1634+ self.assertEqual(
1635+ "Boom", summary["requests"][request.id]["error_message"])
1636+
1637+ def test_getBuildSummaries_request_builds_field(self):
1638+ # The builds field should be an empty list unless the build request
1639+ # has completed and produced builds.
1640+ self.useFixture(GitHostingFixture(blob=dedent("""\
1641+ architectures:
1642+ - build-on: mips64el
1643+ - build-on: riscv64
1644+ """)))
1645+ job = self.makeRequestBuildsJob(["mips64el", "riscv64", "sh4"])
1646+ snap = job.snap
1647+ request = snap.getBuildRequest(job.job_id)
1648+ self.assertEqual([], list(request.builds))
1649+ summary = snap.getBuildSummaries(request_ids=[request.id])
1650+ self.assertEqual([], summary["requests"][request.id]["builds"])
1651+ with person_logged_in(job.requester):
1652+ with dbuser(config.ISnapRequestBuildsJobSource.dbuser):
1653+ JobRunner([job]).runAll()
1654+ summary = snap.getBuildSummaries(request_ids=[request.id])
1655+ expected_snap_url = "/~%s/+snap/%s" % (snap.owner.name, snap.name)
1656+ builds = sorted(request.builds, key=attrgetter("id"), reverse=True)
1657+ expected_builds = [
1658+ {
1659+ "self_link": expected_snap_url + "/+build/%d" % build.id,
1660+ "id": build.id,
1661+ "distro_arch_series_link": "/%s/%s/%s" % (
1662+ snap.distro_series.distribution.name,
1663+ snap.distro_series.name,
1664+ build.distro_arch_series.architecturetag),
1665+ "architecture_tag": build.distro_arch_series.architecturetag,
1666+ "archive_link": (
1667+ '<a href="/%s" class="sprite distribution">%s</a>' % (
1668+ build.archive.distribution.name,
1669+ build.archive.displayname)),
1670+ "status": "NEEDSBUILD",
1671+ "buildstate": "Needs building",
1672+ "when_complete": None,
1673+ "when_complete_estimate": False,
1674+ "build_log_url": None,
1675+ "build_log_size": None,
1676+ } for build in builds]
1677+ self.assertEqual(
1678+ expected_builds, summary["requests"][request.id]["builds"])
1679+
1680+ def test_getBuildSummaries_query_count(self):
1681+ # The DB query count remains constant regardless of the number of
1682+ # requests and the number of builds resulting from them.
1683+ self.useFixture(GitHostingFixture(blob=dedent("""\
1684+ architectures:
1685+ - build-on: mips64el
1686+ - build-on: riscv64
1687+ """)))
1688+ job = self.makeRequestBuildsJob(["mips64el", "riscv64", "sh4"])
1689+ snap = job.snap
1690+ request_ids = []
1691+ build_ids = []
1692+
1693+ def create_items():
1694+ request = self.factory.makeSnapBuildRequest(
1695+ snap=snap, archive=self.factory.makeArchive())
1696+ request_ids.append(request.id)
1697+ job = removeSecurityProxy(request)._job
1698+ with person_logged_in(snap.owner.teamowner):
1699+ # Using the normal job runner interferes with SQL statement
1700+ # recording, so we run the job by hand.
1701+ job.start()
1702+ job.run()
1703+ job.complete()
1704+ # XXX cjwatson 2018-06-20: Queued builds with
1705+ # BuildQueueStatus.WAITING incur extra queries per build due to
1706+ # estimating start times. For the moment, we dodge this by
1707+ # starting the builds.
1708+ for build in job.builds:
1709+ build.buildqueue_record.markAsBuilding(
1710+ self.factory.makeBuilder())
1711+ build_ids.append(self.factory.makeSnapBuild(
1712+ snap=snap, archive=self.factory.makeArchive()).id)
1713+
1714+ recorder1, recorder2 = record_two_runs(
1715+ lambda: snap.getBuildSummaries(
1716+ request_ids=request_ids, build_ids=build_ids),
1717+ create_items, 1, 5)
1718+ self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
1719+
1720
1721 class TestSnapDeleteWithBuilds(TestCaseWithFactory):
1722
1723
1724=== modified file 'lib/lp/testing/factory.py'
1725--- lib/lp/testing/factory.py 2018-08-23 09:30:24 +0000
1726+++ lib/lp/testing/factory.py 2018-09-13 15:15:44 +0000
1727@@ -4728,6 +4728,19 @@
1728 IStore(snap).flush()
1729 return snap
1730
1731+ def makeSnapBuildRequest(self, snap=None, requester=None, archive=None,
1732+ pocket=PackagePublishingPocket.UPDATES,
1733+ channels=None):
1734+ """Make a new SnapBuildRequest."""
1735+ if snap is None:
1736+ snap = self.makeSnap()
1737+ if requester is None:
1738+ requester = snap.owner.teamowner
1739+ if archive is None:
1740+ archive = snap.distro_series.main_archive
1741+ return snap.requestBuilds(
1742+ requester, archive, pocket, channels=channels)
1743+
1744 def makeSnapBuild(self, requester=None, registrant=None, snap=None,
1745 archive=None, distroarchseries=None, pocket=None,
1746 channels=None, date_created=DEFAULT,