Merge lp:~cjwatson/launchpad/snap-basic-browser into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 17685
Proposed branch: lp:~cjwatson/launchpad/snap-basic-browser
Merge into: lp:launchpad
Diff against target: 1103 lines (+989/-3)
9 files modified
lib/lp/app/browser/configure.zcml (+6/-0)
lib/lp/snappy/browser/configure.zcml (+43/-0)
lib/lp/snappy/browser/snap.py (+59/-0)
lib/lp/snappy/browser/snapbuild.py (+152/-2)
lib/lp/snappy/browser/tests/test_snap.py (+180/-0)
lib/lp/snappy/browser/tests/test_snapbuild.py (+246/-0)
lib/lp/snappy/model/snap.py (+2/-1)
lib/lp/snappy/templates/snap-index.pt (+96/-0)
lib/lp/snappy/templates/snapbuild-index.pt (+205/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/snap-basic-browser
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+267324@code.launchpad.net

Commit message

Add basic browser views for Snap and SnapBuild.

Description of the change

Add basic browser views for Snap and SnapBuild.

This is mostly read-only right now: there are no admin, edit, or delete views, and the only way to add things is using the webservice. But with this branch we have enough to be able to see builds in action: there's a temporary demo at https://dogfood.paddev.net/~cjwatson/+snap/wget.

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 2015-07-23 14:32:50 +0000
3+++ lib/lp/app/browser/configure.zcml 2015-08-07 10:52:53 +0000
4@@ -570,6 +570,12 @@
5 factory="lp.app.browser.tales.BuildImageDisplayAPI"
6 name="image"
7 />
8+ <adapter
9+ for="lp.snappy.interfaces.snapbuild.ISnapBuild"
10+ provides="zope.traversing.interfaces.IPathAdapter"
11+ factory="lp.app.browser.tales.BuildImageDisplayAPI"
12+ name="image"
13+ />
14
15 <adapter
16 for="lp.soyuz.interfaces.archive.IArchive"
17
18=== modified file 'lib/lp/snappy/browser/configure.zcml'
19--- lib/lp/snappy/browser/configure.zcml 2015-07-23 16:41:12 +0000
20+++ lib/lp/snappy/browser/configure.zcml 2015-08-07 10:52:53 +0000
21@@ -13,9 +13,23 @@
22 for="lp.snappy.interfaces.snap.ISnap"
23 path_expression="string:+snap/${name}"
24 attribute_to_parent="owner" />
25+ <browser:defaultView
26+ for="lp.snappy.interfaces.snap.ISnap"
27+ name="+index" />
28+ <browser:page
29+ for="lp.snappy.interfaces.snap.ISnap"
30+ class="lp.snappy.browser.snap.SnapView"
31+ permission="launchpad.View"
32+ name="+index"
33+ template="../templates/snap-index.pt" />
34 <browser:navigation
35 module="lp.snappy.browser.snap"
36 classes="SnapNavigation" />
37+ <adapter
38+ provides="lp.services.webapp.interfaces.IBreadcrumb"
39+ for="lp.snappy.interfaces.snap.ISnap"
40+ factory="lp.snappy.browser.snap.SnapBreadcrumb"
41+ permission="zope.Public" />
42 <browser:url
43 for="lp.snappy.interfaces.snap.ISnapSet"
44 path_expression="string:+snaps"
45@@ -24,8 +38,37 @@
46 for="lp.snappy.interfaces.snapbuild.ISnapBuild"
47 path_expression="string:+build/${id}"
48 attribute_to_parent="snap" />
49+ <browser:menus
50+ module="lp.snappy.browser.snapbuild"
51+ classes="SnapBuildContextMenu" />
52 <browser:navigation
53 module="lp.snappy.browser.snapbuild"
54 classes="SnapBuildNavigation" />
55+ <browser:defaultView
56+ for="lp.snappy.interfaces.snapbuild.ISnapBuild"
57+ name="+index" />
58+ <browser:page
59+ for="lp.snappy.interfaces.snapbuild.ISnapBuild"
60+ class="lp.snappy.browser.snapbuild.SnapBuildView"
61+ permission="launchpad.View"
62+ name="+index"
63+ template="../templates/snapbuild-index.pt" />
64+ <browser:page
65+ for="lp.snappy.interfaces.snapbuild.ISnapBuild"
66+ class="lp.snappy.browser.snapbuild.SnapBuildCancelView"
67+ permission="launchpad.Edit"
68+ name="+cancel"
69+ template="../../app/templates/generic-edit.pt" />
70+ <browser:page
71+ for="lp.snappy.interfaces.snapbuild.ISnapBuild"
72+ class="lp.snappy.browser.snapbuild.SnapBuildRescoreView"
73+ permission="launchpad.Admin"
74+ name="+rescore"
75+ template="../../app/templates/generic-edit.pt" />
76+ <adapter
77+ provides="lp.services.webapp.interfaces.IBreadcrumb"
78+ for="lp.snappy.interfaces.snapbuild.ISnapBuild"
79+ factory="lp.services.webapp.breadcrumb.TitleBreadcrumb"
80+ permission="zope.Public" />
81 </facet>
82 </configure>
83
84=== modified file 'lib/lp/snappy/browser/snap.py'
85--- lib/lp/snappy/browser/snap.py 2015-07-23 16:02:58 +0000
86+++ lib/lp/snappy/browser/snap.py 2015-08-07 10:52:53 +0000
87@@ -6,12 +6,20 @@
88 __metaclass__ = type
89 __all__ = [
90 'SnapNavigation',
91+ 'SnapView',
92 ]
93
94 from lp.services.webapp import (
95+ canonical_url,
96+ LaunchpadView,
97 Navigation,
98 stepthrough,
99 )
100+from lp.services.webapp.authorization import check_permission
101+from lp.services.webapp.breadcrumb import (
102+ Breadcrumb,
103+ NameBreadcrumb,
104+ )
105 from lp.snappy.interfaces.snap import ISnap
106 from lp.snappy.interfaces.snapbuild import ISnapBuildSet
107 from lp.soyuz.browser.build import get_build_by_id_str
108@@ -26,3 +34,54 @@
109 if build is None or build.snap != self.context:
110 return None
111 return build
112+
113+
114+class SnapBreadcrumb(NameBreadcrumb):
115+
116+ @property
117+ def inside(self):
118+ return Breadcrumb(
119+ self.context.owner,
120+ url=canonical_url(self.context.owner, view_name="+snap"),
121+ text="Snap packages", inside=self.context.owner)
122+
123+
124+class SnapView(LaunchpadView):
125+ """Default view of a Snap."""
126+
127+ @property
128+ def page_title(self):
129+ return "%(name)s's %(snap_name)s snap package" % {
130+ 'name': self.context.owner.displayname,
131+ 'snap_name': self.context.name,
132+ }
133+
134+ label = page_title
135+
136+ @property
137+ def builds(self):
138+ return builds_for_snap(self.context)
139+
140+
141+def builds_for_snap(snap):
142+ """A list of interesting builds.
143+
144+ All pending builds are shown, as well as 1-10 recent builds. Recent
145+ builds are ordered by date finished (if completed) or date_started (if
146+ date finished is not set due to an error building or other circumstance
147+ which resulted in the build not being completed). This allows started
148+ but unfinished builds to show up in the view but be discarded as more
149+ recent builds become available.
150+
151+ Builds that the user does not have permission to see are excluded.
152+ """
153+ builds = [
154+ build for build in snap.pending_builds
155+ if check_permission('launchpad.View', build)]
156+ for build in snap.completed_builds:
157+ if not check_permission('launchpad.View', build):
158+ continue
159+ builds.append(build)
160+ if len(builds) >= 10:
161+ break
162+ return builds
163
164=== modified file 'lib/lp/snappy/browser/snapbuild.py'
165--- lib/lp/snappy/browser/snapbuild.py 2015-07-23 16:02:58 +0000
166+++ lib/lp/snappy/browser/snapbuild.py 2015-08-07 10:52:53 +0000
167@@ -5,13 +5,163 @@
168
169 __metaclass__ = type
170 __all__ = [
171+ 'SnapBuildContextMenu',
172 'SnapBuildNavigation',
173+ 'SnapBuildView',
174 ]
175
176-from lp.services.librarian.browser import FileNavigationMixin
177-from lp.services.webapp import Navigation
178+from zope.interface import Interface
179+
180+from lp.app.browser.launchpadform import (
181+ action,
182+ LaunchpadFormView,
183+ )
184+from lp.buildmaster.enums import BuildQueueStatus
185+from lp.services.librarian.browser import (
186+ FileNavigationMixin,
187+ ProxiedLibraryFileAlias,
188+ )
189+from lp.services.propertycache import cachedproperty
190+from lp.services.webapp import (
191+ canonical_url,
192+ ContextMenu,
193+ enabled_with_permission,
194+ LaunchpadView,
195+ Link,
196+ Navigation,
197+ )
198 from lp.snappy.interfaces.snapbuild import ISnapBuild
199+from lp.soyuz.interfaces.binarypackagebuild import IBuildRescoreForm
200
201
202 class SnapBuildNavigation(Navigation, FileNavigationMixin):
203 usedfor = ISnapBuild
204+
205+
206+class SnapBuildContextMenu(ContextMenu):
207+ """Context menu for snap package builds."""
208+
209+ usedfor = ISnapBuild
210+
211+ facet = 'overview'
212+
213+ links = ('cancel', 'rescore')
214+
215+ @enabled_with_permission('launchpad.Edit')
216+ def cancel(self):
217+ return Link(
218+ '+cancel', 'Cancel build', icon='remove',
219+ enabled=self.context.can_be_cancelled)
220+
221+ @enabled_with_permission('launchpad.Admin')
222+ def rescore(self):
223+ return Link(
224+ '+rescore', 'Rescore build', icon='edit',
225+ enabled=self.context.can_be_rescored)
226+
227+
228+class SnapBuildView(LaunchpadView):
229+ """Default view of a SnapBuild."""
230+
231+ @property
232+ def label(self):
233+ return self.context.title
234+
235+ page_title = label
236+
237+ @cachedproperty
238+ def eta(self):
239+ """The datetime when the build job is estimated to complete.
240+
241+ This is the BuildQueue.estimated_duration plus the
242+ Job.date_started or BuildQueue.getEstimatedJobStartTime.
243+ """
244+ if self.context.buildqueue_record is None:
245+ return None
246+ queue_record = self.context.buildqueue_record
247+ if queue_record.status == BuildQueueStatus.WAITING:
248+ start_time = queue_record.getEstimatedJobStartTime()
249+ else:
250+ start_time = queue_record.date_started
251+ if start_time is None:
252+ return None
253+ duration = queue_record.estimated_duration
254+ return start_time + duration
255+
256+ @cachedproperty
257+ def estimate(self):
258+ """If true, the date value is an estimate."""
259+ if self.context.date_finished is not None:
260+ return False
261+ return self.eta is not None
262+
263+ @cachedproperty
264+ def date(self):
265+ """The date when the build completed or is estimated to complete."""
266+ if self.estimate:
267+ return self.eta
268+ return self.context.date_finished
269+
270+ @cachedproperty
271+ def files(self):
272+ """Return `LibraryFileAlias`es for files produced by this build."""
273+ if not self.context.was_built:
274+ return None
275+
276+ return [
277+ ProxiedLibraryFileAlias(alias, self.context)
278+ for _, alias, _ in self.context.getFiles() if not alias.deleted]
279+
280+ @cachedproperty
281+ def has_files(self):
282+ return bool(self.files)
283+
284+
285+class SnapBuildCancelView(LaunchpadFormView):
286+ """View for cancelling a snap package build."""
287+
288+ class schema(Interface):
289+ """Schema for cancelling a build."""
290+
291+ page_title = label = 'Cancel build'
292+
293+ @property
294+ def cancel_url(self):
295+ return canonical_url(self.context)
296+ next_url = cancel_url
297+
298+ @action('Cancel build', name='cancel')
299+ def request_action(self, action, data):
300+ """Cancel the build."""
301+ self.context.cancel()
302+
303+
304+class SnapBuildRescoreView(LaunchpadFormView):
305+ """View for rescoring a snap package build."""
306+
307+ schema = IBuildRescoreForm
308+
309+ page_title = label = 'Rescore build'
310+
311+ def __call__(self):
312+ if self.context.can_be_rescored:
313+ return super(SnapBuildRescoreView, self).__call__()
314+ self.request.response.addWarningNotification(
315+ "Cannot rescore this build because it is not queued.")
316+ self.request.response.redirect(canonical_url(self.context))
317+
318+ @property
319+ def cancel_url(self):
320+ return canonical_url(self.context)
321+ next_url = cancel_url
322+
323+ @action('Rescore build', name='rescore')
324+ def request_action(self, action, data):
325+ """Rescore the build."""
326+ score = data.get('priority')
327+ self.context.rescore(score)
328+ self.request.response.addNotification('Build rescored to %s.' % score)
329+
330+ @property
331+ def initial_values(self):
332+ return {'score': str(self.context.buildqueue_record.lastscore)}
333
334=== added directory 'lib/lp/snappy/browser/tests'
335=== added file 'lib/lp/snappy/browser/tests/__init__.py'
336=== added file 'lib/lp/snappy/browser/tests/test_snap.py'
337--- lib/lp/snappy/browser/tests/test_snap.py 1970-01-01 00:00:00 +0000
338+++ lib/lp/snappy/browser/tests/test_snap.py 2015-08-07 10:52:53 +0000
339@@ -0,0 +1,180 @@
340+# Copyright 2015 Canonical Ltd. This software is licensed under the
341+# GNU Affero General Public License version 3 (see the file LICENSE).
342+
343+"""Test snap package views."""
344+
345+__metaclass__ = type
346+
347+from datetime import (
348+ datetime,
349+ timedelta,
350+ )
351+
352+import pytz
353+from zope.component import getUtility
354+
355+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
356+from lp.buildmaster.enums import BuildStatus
357+from lp.buildmaster.interfaces.processor import IProcessorSet
358+from lp.services.features.testing import FeatureFixture
359+from lp.services.webapp import canonical_url
360+from lp.snappy.browser.snap import SnapView
361+from lp.snappy.interfaces.snap import SNAP_FEATURE_FLAG
362+from lp.testing import (
363+ BrowserTestCase,
364+ person_logged_in,
365+ TestCaseWithFactory,
366+ time_counter,
367+ )
368+from lp.testing.layers import (
369+ DatabaseFunctionalLayer,
370+ LaunchpadFunctionalLayer,
371+ )
372+from lp.testing.publication import test_traverse
373+
374+
375+class TestSnapNavigation(TestCaseWithFactory):
376+
377+ layer = DatabaseFunctionalLayer
378+
379+ def setUp(self):
380+ super(TestSnapNavigation, self).setUp()
381+ self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
382+
383+ def test_canonical_url(self):
384+ owner = self.factory.makePerson(name="person")
385+ snap = self.factory.makeSnap(
386+ registrant=owner, owner=owner, name=u"snap")
387+ self.assertEqual(
388+ "http://launchpad.dev/~person/+snap/snap", canonical_url(snap))
389+
390+ def test_snap(self):
391+ snap = self.factory.makeSnap()
392+ obj, _, _ = test_traverse(
393+ "http://launchpad.dev/~%s/+snap/%s" % (snap.owner.name, snap.name))
394+ self.assertEqual(snap, obj)
395+
396+
397+class TestSnapView(BrowserTestCase):
398+
399+ layer = LaunchpadFunctionalLayer
400+
401+ def setUp(self):
402+ super(TestSnapView, self).setUp()
403+ self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
404+ self.ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
405+ self.distroseries = self.factory.makeDistroSeries(
406+ distribution=self.ubuntu, name="shiny", displayname="Shiny")
407+ processor = getUtility(IProcessorSet).getByName("386")
408+ self.distroarchseries = self.factory.makeDistroArchSeries(
409+ distroseries=self.distroseries, architecturetag="i386",
410+ processor=processor)
411+ self.person = self.factory.makePerson(
412+ name="test-person", displayname="Test Person")
413+ self.factory.makeBuilder(virtualized=True)
414+
415+ def makeSnap(self, branch=None, git_ref=None):
416+ kwargs = {}
417+ if branch is None and git_ref is None:
418+ branch = self.factory.makeAnyBranch()
419+ if branch is not None:
420+ kwargs["branch"] = branch
421+ else:
422+ kwargs["git_repository"] = git_ref.repository
423+ kwargs["git_path"] = git_ref.path
424+ return self.factory.makeSnap(
425+ registrant=self.person, owner=self.person,
426+ distroseries=self.distroseries, name=u"snap-name", **kwargs)
427+
428+ def makeBuild(self, snap=None, archive=None, date_created=None, **kwargs):
429+ if snap is None:
430+ snap = self.makeSnap()
431+ if archive is None:
432+ archive = self.ubuntu.main_archive
433+ if date_created is None:
434+ date_created = datetime.now(pytz.UTC) - timedelta(hours=1)
435+ return self.factory.makeSnapBuild(
436+ requester=self.person, snap=snap, archive=archive,
437+ distroarchseries=self.distroarchseries, date_created=date_created,
438+ **kwargs)
439+
440+ def test_index(self):
441+ build = self.makeBuild(
442+ status=BuildStatus.FULLYBUILT, duration=timedelta(minutes=30))
443+ self.assertTextMatchesExpressionIgnoreWhitespace("""\
444+ Snap packages snap-name
445+ .*
446+ Snap package information
447+ Owner: Test Person
448+ Distribution series: Ubuntu Shiny
449+ Latest builds
450+ Status When complete Architecture Archive
451+ Successfully built 30 minutes ago i386
452+ Primary Archive for Ubuntu Linux
453+ """, self.getMainText(build.snap))
454+
455+ def test_index_success_with_buildlog(self):
456+ # The build log is shown if it is there.
457+ build = self.makeBuild(
458+ status=BuildStatus.FULLYBUILT, duration=timedelta(minutes=30))
459+ build.setLog(self.factory.makeLibraryFileAlias())
460+ self.assertTextMatchesExpressionIgnoreWhitespace("""\
461+ Latest builds
462+ Status When complete Architecture Archive
463+ Successfully built 30 minutes ago buildlog \(.*\) i386
464+ Primary Archive for Ubuntu Linux
465+ """, self.getMainText(build.snap))
466+
467+ def test_index_hides_builds_into_private_archive(self):
468+ # The index page hides builds into archives the user can't view.
469+ archive = self.factory.makeArchive(private=True)
470+ with person_logged_in(archive.owner):
471+ snap = self.makeBuild(archive=archive).snap
472+ self.assertIn(
473+ "This snap package has not been built yet.",
474+ self.getMainText(snap))
475+
476+ def test_index_no_builds(self):
477+ # A message is shown when there are no builds.
478+ snap = self.factory.makeSnap()
479+ self.assertIn(
480+ "This snap package has not been built yet.",
481+ self.getMainText(snap))
482+
483+ def test_index_pending(self):
484+ # A pending build is listed as such.
485+ build = self.makeBuild()
486+ build.queueBuild()
487+ self.assertTextMatchesExpressionIgnoreWhitespace("""\
488+ Latest builds
489+ Status When complete Architecture Archive
490+ Needs building in .* \(estimated\) i386
491+ Primary Archive for Ubuntu Linux
492+ """, self.getMainText(build.snap))
493+
494+ def setStatus(self, build, status):
495+ build.updateStatus(
496+ BuildStatus.BUILDING, date_started=build.date_created)
497+ build.updateStatus(
498+ status, date_finished=build.date_started + timedelta(minutes=30))
499+
500+ def test_builds(self):
501+ # SnapView.builds produces reasonable results.
502+ snap = self.makeSnap()
503+ # Create oldest builds first so that they sort properly by id.
504+ date_gen = time_counter(
505+ datetime(2000, 1, 1, tzinfo=pytz.UTC), timedelta(days=1))
506+ builds = [
507+ self.makeBuild(snap=snap, date_created=next(date_gen))
508+ for i in range(11)]
509+ view = SnapView(snap, None)
510+ self.assertEqual(list(reversed(builds)), view.builds)
511+ self.setStatus(builds[10], BuildStatus.FULLYBUILT)
512+ self.setStatus(builds[9], BuildStatus.FAILEDTOBUILD)
513+ # When there are >= 9 pending builds, only the most recent of any
514+ # completed builds is returned.
515+ self.assertEqual(
516+ list(reversed(builds[:9])) + [builds[10]], view.builds)
517+ for build in builds[:9]:
518+ self.setStatus(build, BuildStatus.FULLYBUILT)
519+ self.assertEqual(list(reversed(builds[1:])), view.builds)
520
521=== added file 'lib/lp/snappy/browser/tests/test_snapbuild.py'
522--- lib/lp/snappy/browser/tests/test_snapbuild.py 1970-01-01 00:00:00 +0000
523+++ lib/lp/snappy/browser/tests/test_snapbuild.py 2015-08-07 10:52:53 +0000
524@@ -0,0 +1,246 @@
525+# Copyright 2015 Canonical Ltd. This software is licensed under the
526+# GNU Affero General Public License version 3 (see the file LICENSE).
527+
528+"""Test snap package build views."""
529+
530+__metaclass__ = type
531+
532+from fixtures import FakeLogger
533+from mechanize import LinkNotFoundError
534+from storm.locals import Store
535+from testtools.matchers import StartsWith
536+import transaction
537+from zope.component import getUtility
538+from zope.security.interfaces import Unauthorized
539+from zope.security.proxy import removeSecurityProxy
540+
541+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
542+from lp.buildmaster.enums import BuildStatus
543+from lp.services.features.testing import FeatureFixture
544+from lp.services.webapp import canonical_url
545+from lp.snappy.interfaces.snap import SNAP_FEATURE_FLAG
546+from lp.testing import (
547+ admin_logged_in,
548+ ANONYMOUS,
549+ BrowserTestCase,
550+ login,
551+ logout,
552+ person_logged_in,
553+ TestCaseWithFactory,
554+ )
555+from lp.testing.layers import (
556+ DatabaseFunctionalLayer,
557+ LaunchpadFunctionalLayer,
558+ )
559+from lp.testing.pages import (
560+ extract_text,
561+ find_main_content,
562+ find_tags_by_class,
563+ )
564+from lp.testing.views import create_initialized_view
565+
566+
567+class TestCanonicalUrlForSnapBuild(TestCaseWithFactory):
568+
569+ layer = DatabaseFunctionalLayer
570+
571+ def setUp(self):
572+ super(TestCanonicalUrlForSnapBuild, self).setUp()
573+ self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
574+
575+ def test_canonical_url(self):
576+ owner = self.factory.makePerson(name="person")
577+ snap = self.factory.makeSnap(
578+ registrant=owner, owner=owner, name=u"snap")
579+ build = self.factory.makeSnapBuild(requester=owner, snap=snap)
580+ self.assertThat(
581+ canonical_url(build),
582+ StartsWith("http://launchpad.dev/~person/+snap/snap/+build/"))
583+
584+
585+class TestSnapBuildView(TestCaseWithFactory):
586+
587+ layer = LaunchpadFunctionalLayer
588+
589+ def setUp(self):
590+ super(TestSnapBuildView, self).setUp()
591+ self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
592+
593+ def test_files(self):
594+ # SnapBuildView.files returns all the associated files.
595+ build = self.factory.makeSnapBuild(status=BuildStatus.FULLYBUILT)
596+ snapfile = self.factory.makeSnapFile(snapbuild=build)
597+ build_view = create_initialized_view(build, "+index")
598+ self.assertEqual(
599+ [snapfile.libraryfile.filename],
600+ [lfa.filename for lfa in build_view.files])
601+ # Deleted files won't be included.
602+ self.assertFalse(snapfile.libraryfile.deleted)
603+ removeSecurityProxy(snapfile.libraryfile).content = None
604+ self.assertTrue(snapfile.libraryfile.deleted)
605+ build_view = create_initialized_view(build, "+index")
606+ self.assertEqual([], build_view.files)
607+
608+ def test_eta(self):
609+ # SnapBuildView.eta returns a non-None value when it should, or None
610+ # when there's no start time.
611+ build = self.factory.makeSnapBuild()
612+ build.queueBuild()
613+ self.assertIsNone(create_initialized_view(build, "+index").eta)
614+ self.factory.makeBuilder(processors=[build.processor])
615+ self.assertIsNotNone(create_initialized_view(build, "+index").eta)
616+
617+ def test_estimate(self):
618+ # SnapBuildView.estimate returns True until the job is completed.
619+ build = self.factory.makeSnapBuild()
620+ build.queueBuild()
621+ self.factory.makeBuilder(processors=[build.processor])
622+ build.updateStatus(BuildStatus.BUILDING)
623+ self.assertTrue(create_initialized_view(build, "+index").estimate)
624+ build.updateStatus(BuildStatus.FULLYBUILT)
625+ self.assertFalse(create_initialized_view(build, "+index").estimate)
626+
627+
628+class TestSnapBuildOperations(BrowserTestCase):
629+
630+ layer = DatabaseFunctionalLayer
631+
632+ def setUp(self):
633+ super(TestSnapBuildOperations, self).setUp()
634+ self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
635+ self.useFixture(FakeLogger())
636+ self.build = self.factory.makeSnapBuild()
637+ self.build_url = canonical_url(self.build)
638+ self.requester = self.build.requester
639+ self.buildd_admin = self.factory.makePerson(
640+ member_of=[getUtility(ILaunchpadCelebrities).buildd_admin])
641+
642+ def test_cancel_build(self):
643+ # The requester of a build can cancel it.
644+ self.build.queueBuild()
645+ transaction.commit()
646+ browser = self.getViewBrowser(self.build, user=self.requester)
647+ browser.getLink("Cancel build").click()
648+ self.assertEqual(self.build_url, browser.getLink("Cancel").url)
649+ browser.getControl("Cancel build").click()
650+ self.assertEqual(self.build_url, browser.url)
651+ login(ANONYMOUS)
652+ self.assertEqual(BuildStatus.CANCELLED, self.build.status)
653+
654+ def test_cancel_build_random_user(self):
655+ # An unrelated non-admin user cannot cancel a build.
656+ self.build.queueBuild()
657+ transaction.commit()
658+ user = self.factory.makePerson()
659+ browser = self.getViewBrowser(self.build, user=user)
660+ self.assertRaises(LinkNotFoundError, browser.getLink, "Cancel build")
661+ self.assertRaises(
662+ Unauthorized, self.getUserBrowser, self.build_url + "/+cancel",
663+ user=user)
664+
665+ def test_cancel_build_wrong_state(self):
666+ # If the build isn't queued, you can't cancel it.
667+ browser = self.getViewBrowser(self.build, user=self.requester)
668+ self.assertRaises(LinkNotFoundError, browser.getLink, "Cancel build")
669+
670+ def test_rescore_build(self):
671+ # A buildd admin can rescore a build.
672+ self.build.queueBuild()
673+ transaction.commit()
674+ browser = self.getViewBrowser(self.build, user=self.buildd_admin)
675+ browser.getLink("Rescore build").click()
676+ self.assertEqual(self.build_url, browser.getLink("Cancel").url)
677+ browser.getControl("Priority").value = "1024"
678+ browser.getControl("Rescore build").click()
679+ self.assertEqual(self.build_url, browser.url)
680+ login(ANONYMOUS)
681+ self.assertEqual(1024, self.build.buildqueue_record.lastscore)
682+
683+ def test_rescore_build_invalid_score(self):
684+ # Build scores can only take numbers.
685+ self.build.queueBuild()
686+ transaction.commit()
687+ browser = self.getViewBrowser(self.build, user=self.buildd_admin)
688+ browser.getLink("Rescore build").click()
689+ self.assertEqual(self.build_url, browser.getLink("Cancel").url)
690+ browser.getControl("Priority").value = "tentwentyfour"
691+ browser.getControl("Rescore build").click()
692+ self.assertEqual(
693+ "Invalid integer data",
694+ extract_text(find_tags_by_class(browser.contents, "message")[1]))
695+
696+ def test_rescore_build_not_admin(self):
697+ # A non-admin user cannot cancel a build.
698+ self.build.queueBuild()
699+ transaction.commit()
700+ user = self.factory.makePerson()
701+ browser = self.getViewBrowser(self.build, user=user)
702+ self.assertRaises(LinkNotFoundError, browser.getLink, "Rescore build")
703+ self.assertRaises(
704+ Unauthorized, self.getUserBrowser, self.build_url + "/+rescore",
705+ user=user)
706+
707+ def test_rescore_build_wrong_state(self):
708+ # If the build isn't NEEDSBUILD, you can't rescore it.
709+ self.build.queueBuild()
710+ with person_logged_in(self.requester):
711+ self.build.cancel()
712+ browser = self.getViewBrowser(self.build, user=self.buildd_admin)
713+ self.assertRaises(LinkNotFoundError, browser.getLink, "Rescore build")
714+
715+ def test_rescore_build_wrong_state_stale_link(self):
716+ # An attempt to rescore a non-queued build from a stale link shows a
717+ # sensible error message.
718+ self.build.queueBuild()
719+ with person_logged_in(self.requester):
720+ self.build.cancel()
721+ browser = self.getViewBrowser(
722+ self.build, "+rescore", user=self.buildd_admin)
723+ self.assertEqual(self.build_url, browser.url)
724+ self.assertIn(
725+ "Cannot rescore this build because it is not queued.",
726+ browser.contents)
727+
728+ def test_builder_history(self):
729+ Store.of(self.build).flush()
730+ self.build.updateStatus(
731+ BuildStatus.FULLYBUILT, builder=self.factory.makeBuilder())
732+ title = self.build.title
733+ browser = self.getViewBrowser(self.build.builder, "+history")
734+ self.assertTextMatchesExpressionIgnoreWhitespace(
735+ "Build history.*%s" % title,
736+ extract_text(find_main_content(browser.contents)))
737+ self.assertEqual(self.build_url, browser.getLink(title).url)
738+
739+ def makeBuildingSnap(self, archive=None):
740+ builder = self.factory.makeBuilder()
741+ build = self.factory.makeSnapBuild(archive=archive)
742+ build.updateStatus(BuildStatus.BUILDING, builder=builder)
743+ build.queueBuild()
744+ build.buildqueue_record.builder = builder
745+ build.buildqueue_record.logtail = "tail of the log"
746+ return build
747+
748+ def test_builder_index_public(self):
749+ build = self.makeBuildingSnap()
750+ builder_url = canonical_url(build.builder)
751+ logout()
752+ browser = self.getNonRedirectingBrowser(
753+ url=builder_url, user=ANONYMOUS)
754+ self.assertIn("tail of the log", browser.contents)
755+
756+ def test_builder_index_private(self):
757+ archive = self.factory.makeArchive(private=True)
758+ with admin_logged_in():
759+ build = self.makeBuildingSnap(archive=archive)
760+ builder_url = canonical_url(build.builder)
761+ logout()
762+
763+ # An unrelated user can't see the logtail of a private build.
764+ browser = self.getNonRedirectingBrowser(url=builder_url)
765+ self.assertNotIn("tail of the log", browser.contents)
766+
767+ # But someone who can see the archive can.
768+ browser = self.getNonRedirectingBrowser(
769+ url=builder_url, user=archive.owner)
770+ self.assertIn("tail of the log", browser.contents)
771
772=== modified file 'lib/lp/snappy/model/snap.py'
773--- lib/lp/snappy/model/snap.py 2015-08-05 10:52:13 +0000
774+++ lib/lp/snappy/model/snap.py 2015-08-07 10:52:53 +0000
775@@ -21,6 +21,7 @@
776 )
777 from zope.component import getUtility
778 from zope.interface import implementer
779+from zope.security.proxy import removeSecurityProxy
780
781 from lp.buildmaster.enums import BuildStatus
782 from lp.buildmaster.interfaces.processor import IProcessorSet
783@@ -69,7 +70,7 @@
784 This method is registered as a subscriber to `IObjectModifiedEvent`
785 events on snap packages.
786 """
787- snap.date_last_modified = UTC_NOW
788+ removeSecurityProxy(snap).date_last_modified = UTC_NOW
789
790
791 @implementer(ISnap, IHasOwner)
792
793=== added directory 'lib/lp/snappy/templates'
794=== added file 'lib/lp/snappy/templates/snap-index.pt'
795--- lib/lp/snappy/templates/snap-index.pt 1970-01-01 00:00:00 +0000
796+++ lib/lp/snappy/templates/snap-index.pt 2015-08-07 10:52:53 +0000
797@@ -0,0 +1,96 @@
798+<html
799+ xmlns="http://www.w3.org/1999/xhtml"
800+ xmlns:tal="http://xml.zope.org/namespaces/tal"
801+ xmlns:metal="http://xml.zope.org/namespaces/metal"
802+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
803+ metal:use-macro="view/macro:page/main_side"
804+ i18n:domain="launchpad"
805+>
806+
807+<body>
808+ <metal:registering fill-slot="registering">
809+ Created by
810+ <tal:registrant replace="structure context/registrant/fmt:link"/>
811+ on
812+ <tal:created-on replace="structure context/date_created/fmt:date"/>
813+ and last modified on
814+ <tal:last-modified replace="structure context/date_last_modified/fmt:date"/>
815+ </metal:registering>
816+
817+ <metal:side fill-slot="side">
818+ <div tal:replace="structure context/@@+global-actions"/>
819+ </metal:side>
820+
821+ <metal:heading fill-slot="heading">
822+ <h1 tal:content="context/name"/>
823+ </metal:heading>
824+
825+ <div metal:fill-slot="main">
826+ <h2>Snap package information</h2>
827+ <div class="two-column-list">
828+ <dl id="owner">
829+ <dt>Owner:</dt>
830+ <dd tal:content="structure context/owner/fmt:link"/>
831+ </dl>
832+ <dl id="distro_series">
833+ <dt>Distribution series:</dt>
834+ <dd tal:define="distro_series context/distro_series">
835+ <a tal:attributes="href distro_series/fmt:url"
836+ tal:content="distro_series/fullseriesname"/>
837+ </dd>
838+ </dl>
839+ </div>
840+
841+ <h2>Latest builds</h2>
842+ <table id="latest-builds-listing" class="listing"
843+ style="margin-bottom: 1em;">
844+ <thead>
845+ <tr>
846+ <th>Status</th>
847+ <th>When complete</th>
848+ <th>Architecture</th>
849+ <th>Archive</th>
850+ </tr>
851+ </thead>
852+ <tbody>
853+ <tal:snap-builds repeat="build view/builds">
854+ <tal:build-view define="buildview nocall:build/@@+index">
855+ <tr tal:attributes="id string:build-${build/id}">
856+ <td>
857+ <span tal:replace="structure build/image:icon"/>
858+ <a tal:content="build/status/title"
859+ tal:attributes="href build/fmt:url"/>
860+ </td>
861+ <td>
862+ <tal:date replace="buildview/date/fmt:displaydate"/>
863+ <tal:estimate condition="buildview/estimate">
864+ (estimated)
865+ </tal:estimate>
866+
867+ <tal:build-log define="file build/log" tal:condition="file">
868+ <a class="sprite download"
869+ tal:attributes="href build/log_url">buildlog</a>
870+ (<span tal:replace="file/content/filesize/fmt:bytes"/>)
871+ </tal:build-log>
872+ </td>
873+ <td>
874+ <a class="sprite distribution"
875+ tal:define="archseries build/distro_arch_series"
876+ tal:attributes="href archseries/fmt:url"
877+ tal:content="archseries/architecturetag"/>
878+ </td>
879+ <td>
880+ <tal:archive replace="structure build/archive/fmt:link"/>
881+ </td>
882+ </tr>
883+ </tal:build-view>
884+ </tal:snap-builds>
885+ </tbody>
886+ </table>
887+ <p tal:condition="not: view/builds">
888+ This snap package has not been built yet.
889+ </p>
890+ </div>
891+
892+</body>
893+</html>
894
895=== added file 'lib/lp/snappy/templates/snapbuild-index.pt'
896--- lib/lp/snappy/templates/snapbuild-index.pt 1970-01-01 00:00:00 +0000
897+++ lib/lp/snappy/templates/snapbuild-index.pt 2015-08-07 10:52:53 +0000
898@@ -0,0 +1,205 @@
899+<html
900+ xmlns="http://www.w3.org/1999/xhtml"
901+ xmlns:tal="http://xml.zope.org/namespaces/tal"
902+ xmlns:metal="http://xml.zope.org/namespaces/metal"
903+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
904+ metal:use-macro="view/macro:page/main_only"
905+ i18n:domain="launchpad"
906+>
907+
908+ <body>
909+
910+ <tal:registering metal:fill-slot="registering">
911+ created
912+ <span tal:content="context/date_created/fmt:displaydate"
913+ tal:attributes="title context/date_created/fmt:datetime"/>
914+ </tal:registering>
915+
916+ <div metal:fill-slot="main">
917+
918+ <div class="yui-g">
919+
920+ <div id="status" class="yui-u first">
921+ <div class="portlet">
922+ <div metal:use-macro="template/macros/status"/>
923+ </div>
924+ </div>
925+
926+ <div id="details" class="yui-u">
927+ <div class="portlet">
928+ <div metal:use-macro="template/macros/details"/>
929+ </div>
930+ </div>
931+
932+ </div> <!-- yui-g -->
933+
934+ <div id="files" class="portlet" tal:condition="view/has_files">
935+ <div metal:use-macro="template/macros/files"/>
936+ </div>
937+
938+ <div id="buildlog" class="portlet"
939+ tal:condition="context/status/enumvalue:BUILDING">
940+ <div metal:use-macro="template/macros/buildlog"/>
941+ </div>
942+
943+ </div> <!-- main -->
944+
945+
946+<metal:macros fill-slot="bogus">
947+
948+ <metal:macro define-macro="details">
949+ <tal:comment replace="nothing">
950+ Details section.
951+ </tal:comment>
952+ <h2>Build details</h2>
953+ <div class="two-column-list">
954+ <dl>
955+ <dt>Snap:</dt>
956+ <dd>
957+ <tal:snap replace="structure context/snap/fmt:link"/>
958+ </dd>
959+ </dl>
960+ <dl>
961+ <dt>Archive:</dt>
962+ <dd>
963+ <span tal:replace="structure context/archive/fmt:link"/>
964+ </dd>
965+ </dl>
966+ <dl>
967+ <dt>Series:</dt>
968+ <dd><a class="sprite distribution"
969+ tal:define="series context/distro_series"
970+ tal:attributes="href series/fmt:url"
971+ tal:content="series/displayname"/>
972+ </dd>
973+ </dl>
974+ <dl>
975+ <dt>Architecture:</dt>
976+ <dd><a class="sprite distribution"
977+ tal:define="archseries context/distro_arch_series"
978+ tal:attributes="href archseries/fmt:url"
979+ tal:content="archseries/architecturetag"/>
980+ </dd>
981+ </dl>
982+ <dl>
983+ <dt>Pocket:</dt>
984+ <dd><span tal:replace="context/pocket/title"/></dd>
985+ </dl>
986+ </div>
987+ </metal:macro>
988+
989+ <metal:macro define-macro="status">
990+ <tal:comment replace="nothing">
991+ Status section.
992+ </tal:comment>
993+ <h2>Build status</h2>
994+ <p>
995+ <span tal:replace="structure context/image:icon" />
996+ <span tal:attributes="
997+ class string:buildstatus${context/status/name};"
998+ tal:content="context/status/title"/>
999+ <tal:building condition="context/status/enumvalue:BUILDING">
1000+ on <a tal:content="context/buildqueue_record/builder/title"
1001+ tal:attributes="href context/buildqueue_record/builder/fmt:url"/>
1002+ </tal:building>
1003+ <tal:built condition="context/builder">
1004+ on <a tal:content="context/builder/title"
1005+ tal:attributes="href context/builder/fmt:url"/>
1006+ </tal:built>
1007+ <tal:cancel define="link context/menu:context/cancel"
1008+ condition="link/enabled"
1009+ replace="structure link/fmt:link" />
1010+ </p>
1011+
1012+ <ul>
1013+ <li tal:condition="context/dependencies">
1014+ Missing build dependencies: <em tal:content="context/dependencies"/>
1015+ </li>
1016+ <tal:reallypending condition="context/buildqueue_record">
1017+ <tal:pending condition="context/buildqueue_record/status/enumvalue:WAITING">
1018+ <li tal:define="eta context/buildqueue_record/getEstimatedJobStartTime">
1019+ Start <tal:eta replace="eta/fmt:approximatedate"/>
1020+ (<span tal:replace="context/buildqueue_record/lastscore"/>)
1021+ <a href="https://help.launchpad.net/Packaging/BuildScores"
1022+ target="_blank">What's this?</a>
1023+ </li>
1024+ </tal:pending>
1025+ </tal:reallypending>
1026+ <tal:started condition="context/date_started">
1027+ <li tal:condition="context/date_started">
1028+ Started <span
1029+ tal:define="start context/date_started"
1030+ tal:attributes="title start/fmt:datetime"
1031+ tal:content="start/fmt:displaydate"/>
1032+ </li>
1033+ </tal:started>
1034+ <tal:finish condition="not: context/date_finished">
1035+ <li tal:define="eta view/eta" tal:condition="view/eta">
1036+ Estimated finish <tal:eta replace="eta/fmt:approximatedate"/>
1037+ </li>
1038+ </tal:finish>
1039+
1040+ <li tal:condition="context/date_finished">
1041+ Finished <span
1042+ tal:attributes="title context/date_finished/fmt:datetime"
1043+ tal:content="context/date_finished/fmt:displaydate"/>
1044+ <tal:duration condition="context/duration">
1045+ (took <span tal:replace="context/duration/fmt:exactduration"/>)
1046+ </tal:duration>
1047+ </li>
1048+ <li tal:define="file context/log"
1049+ tal:condition="file">
1050+ <a class="sprite download"
1051+ tal:attributes="href context/log_url">buildlog</a>
1052+ (<span tal:replace="file/content/filesize/fmt:bytes" />)
1053+ </li>
1054+ <li tal:define="file context/upload_log"
1055+ tal:condition="file">
1056+ <a class="sprite download"
1057+ tal:attributes="href context/upload_log_url">uploadlog</a>
1058+ (<span tal:replace="file/content/filesize/fmt:bytes" />)
1059+ </li>
1060+ </ul>
1061+
1062+ <div
1063+ style="margin-top: 1.5em"
1064+ tal:define="link context/menu:context/rescore"
1065+ tal:condition="link/enabled"
1066+ >
1067+ <a tal:replace="structure link/fmt:link"/>
1068+ </div>
1069+ </metal:macro>
1070+
1071+ <metal:macro define-macro="files">
1072+ <tal:comment replace="nothing">
1073+ Files section.
1074+ </tal:comment>
1075+ <h2>Built files</h2>
1076+ <p>Files resulting from this build:</p>
1077+ <ul>
1078+ <li tal:repeat="file view/files">
1079+ <a class="sprite download"
1080+ tal:content="file/filename"
1081+ tal:attributes="href file/http_url"/>
1082+ (<span tal:replace="file/content/filesize/fmt:bytes"/>)
1083+ </li>
1084+ </ul>
1085+ </metal:macro>
1086+
1087+ <metal:macro define-macro="buildlog">
1088+ <tal:comment replace="nothing">
1089+ Buildlog section.
1090+ </tal:comment>
1091+ <h2>Buildlog</h2>
1092+ <div id="buildlog-tail" class="logtail"
1093+ tal:define="logtail context/buildqueue_record/logtail"
1094+ tal:content="structure logtail/fmt:text-to-html"/>
1095+ <p class="lesser" tal:condition="view/user">
1096+ Updated on <span tal:replace="structure view/user/fmt:local-time"/>
1097+ </p>
1098+ </metal:macro>
1099+
1100+</metal:macros>
1101+
1102+ </body>
1103+</html>