← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-basic-browser into lp:launchpad.

Commit message:
Add basic browser views for Snap and SnapBuild.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1476405 in Launchpad itself: "Add support for building snaps"
  https://bugs.launchpad.net/launchpad/+bug/1476405

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snap-basic-browser/+merge/267324

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.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-basic-browser into lp:launchpad.
=== modified file 'lib/lp/app/browser/configure.zcml'
--- lib/lp/app/browser/configure.zcml	2015-07-23 14:32:50 +0000
+++ lib/lp/app/browser/configure.zcml	2015-08-07 10:52:53 +0000
@@ -570,6 +570,12 @@
       factory="lp.app.browser.tales.BuildImageDisplayAPI"
       name="image"
       />
+  <adapter
+      for="lp.snappy.interfaces.snapbuild.ISnapBuild"
+      provides="zope.traversing.interfaces.IPathAdapter"
+      factory="lp.app.browser.tales.BuildImageDisplayAPI"
+      name="image"
+      />
 
   <adapter
       for="lp.soyuz.interfaces.archive.IArchive"

=== modified file 'lib/lp/snappy/browser/configure.zcml'
--- lib/lp/snappy/browser/configure.zcml	2015-07-23 16:41:12 +0000
+++ lib/lp/snappy/browser/configure.zcml	2015-08-07 10:52:53 +0000
@@ -13,9 +13,23 @@
             for="lp.snappy.interfaces.snap.ISnap"
             path_expression="string:+snap/${name}"
             attribute_to_parent="owner" />
+        <browser:defaultView
+            for="lp.snappy.interfaces.snap.ISnap"
+            name="+index" />
+        <browser:page
+            for="lp.snappy.interfaces.snap.ISnap"
+            class="lp.snappy.browser.snap.SnapView"
+            permission="launchpad.View"
+            name="+index"
+            template="../templates/snap-index.pt" />
         <browser:navigation
             module="lp.snappy.browser.snap"
             classes="SnapNavigation" />
+        <adapter
+            provides="lp.services.webapp.interfaces.IBreadcrumb"
+            for="lp.snappy.interfaces.snap.ISnap"
+            factory="lp.snappy.browser.snap.SnapBreadcrumb"
+            permission="zope.Public" />
         <browser:url
             for="lp.snappy.interfaces.snap.ISnapSet"
             path_expression="string:+snaps"
@@ -24,8 +38,37 @@
             for="lp.snappy.interfaces.snapbuild.ISnapBuild"
             path_expression="string:+build/${id}"
             attribute_to_parent="snap" />
+        <browser:menus
+            module="lp.snappy.browser.snapbuild"
+            classes="SnapBuildContextMenu" />
         <browser:navigation
             module="lp.snappy.browser.snapbuild"
             classes="SnapBuildNavigation" />
+        <browser:defaultView
+            for="lp.snappy.interfaces.snapbuild.ISnapBuild"
+            name="+index" />
+        <browser:page
+            for="lp.snappy.interfaces.snapbuild.ISnapBuild"
+            class="lp.snappy.browser.snapbuild.SnapBuildView"
+            permission="launchpad.View"
+            name="+index"
+            template="../templates/snapbuild-index.pt" />
+        <browser:page
+            for="lp.snappy.interfaces.snapbuild.ISnapBuild"
+            class="lp.snappy.browser.snapbuild.SnapBuildCancelView"
+            permission="launchpad.Edit"
+            name="+cancel"
+            template="../../app/templates/generic-edit.pt" />
+        <browser:page
+            for="lp.snappy.interfaces.snapbuild.ISnapBuild"
+            class="lp.snappy.browser.snapbuild.SnapBuildRescoreView"
+            permission="launchpad.Admin"
+            name="+rescore"
+            template="../../app/templates/generic-edit.pt" />
+        <adapter
+            provides="lp.services.webapp.interfaces.IBreadcrumb"
+            for="lp.snappy.interfaces.snapbuild.ISnapBuild"
+            factory="lp.services.webapp.breadcrumb.TitleBreadcrumb"
+            permission="zope.Public" />
     </facet>
 </configure>

=== modified file 'lib/lp/snappy/browser/snap.py'
--- lib/lp/snappy/browser/snap.py	2015-07-23 16:02:58 +0000
+++ lib/lp/snappy/browser/snap.py	2015-08-07 10:52:53 +0000
@@ -6,12 +6,20 @@
 __metaclass__ = type
 __all__ = [
     'SnapNavigation',
+    'SnapView',
     ]
 
 from lp.services.webapp import (
+    canonical_url,
+    LaunchpadView,
     Navigation,
     stepthrough,
     )
+from lp.services.webapp.authorization import check_permission
+from lp.services.webapp.breadcrumb import (
+    Breadcrumb,
+    NameBreadcrumb,
+    )
 from lp.snappy.interfaces.snap import ISnap
 from lp.snappy.interfaces.snapbuild import ISnapBuildSet
 from lp.soyuz.browser.build import get_build_by_id_str
@@ -26,3 +34,54 @@
         if build is None or build.snap != self.context:
             return None
         return build
+
+
+class SnapBreadcrumb(NameBreadcrumb):
+
+    @property
+    def inside(self):
+        return Breadcrumb(
+            self.context.owner,
+            url=canonical_url(self.context.owner, view_name="+snap"),
+            text="Snap packages", inside=self.context.owner)
+
+
+class SnapView(LaunchpadView):
+    """Default view of a Snap."""
+
+    @property
+    def page_title(self):
+        return "%(name)s's %(snap_name)s snap package" % {
+            'name': self.context.owner.displayname,
+            'snap_name': self.context.name,
+            }
+
+    label = page_title
+
+    @property
+    def builds(self):
+        return builds_for_snap(self.context)
+
+
+def builds_for_snap(snap):
+    """A list of interesting builds.
+
+    All pending builds are shown, as well as 1-10 recent builds.  Recent
+    builds are ordered by date finished (if completed) or date_started (if
+    date finished is not set due to an error building or other circumstance
+    which resulted in the build not being completed).  This allows started
+    but unfinished builds to show up in the view but be discarded as more
+    recent builds become available.
+
+    Builds that the user does not have permission to see are excluded.
+    """
+    builds = [
+        build for build in snap.pending_builds
+        if check_permission('launchpad.View', build)]
+    for build in snap.completed_builds:
+        if not check_permission('launchpad.View', build):
+            continue
+        builds.append(build)
+        if len(builds) >= 10:
+            break
+    return builds

=== modified file 'lib/lp/snappy/browser/snapbuild.py'
--- lib/lp/snappy/browser/snapbuild.py	2015-07-23 16:02:58 +0000
+++ lib/lp/snappy/browser/snapbuild.py	2015-08-07 10:52:53 +0000
@@ -5,13 +5,163 @@
 
 __metaclass__ = type
 __all__ = [
+    'SnapBuildContextMenu',
     'SnapBuildNavigation',
+    'SnapBuildView',
     ]
 
-from lp.services.librarian.browser import FileNavigationMixin
-from lp.services.webapp import Navigation
+from zope.interface import Interface
+
+from lp.app.browser.launchpadform import (
+    action,
+    LaunchpadFormView,
+    )
+from lp.buildmaster.enums import BuildQueueStatus
+from lp.services.librarian.browser import (
+    FileNavigationMixin,
+    ProxiedLibraryFileAlias,
+    )
+from lp.services.propertycache import cachedproperty
+from lp.services.webapp import (
+    canonical_url,
+    ContextMenu,
+    enabled_with_permission,
+    LaunchpadView,
+    Link,
+    Navigation,
+    )
 from lp.snappy.interfaces.snapbuild import ISnapBuild
+from lp.soyuz.interfaces.binarypackagebuild import IBuildRescoreForm
 
 
 class SnapBuildNavigation(Navigation, FileNavigationMixin):
     usedfor = ISnapBuild
+
+
+class SnapBuildContextMenu(ContextMenu):
+    """Context menu for snap package builds."""
+
+    usedfor = ISnapBuild
+
+    facet = 'overview'
+
+    links = ('cancel', 'rescore')
+
+    @enabled_with_permission('launchpad.Edit')
+    def cancel(self):
+        return Link(
+            '+cancel', 'Cancel build', icon='remove',
+            enabled=self.context.can_be_cancelled)
+
+    @enabled_with_permission('launchpad.Admin')
+    def rescore(self):
+        return Link(
+            '+rescore', 'Rescore build', icon='edit',
+            enabled=self.context.can_be_rescored)
+
+
+class SnapBuildView(LaunchpadView):
+    """Default view of a SnapBuild."""
+
+    @property
+    def label(self):
+        return self.context.title
+
+    page_title = label
+
+    @cachedproperty
+    def eta(self):
+        """The datetime when the build job is estimated to complete.
+
+        This is the BuildQueue.estimated_duration plus the
+        Job.date_started or BuildQueue.getEstimatedJobStartTime.
+        """
+        if self.context.buildqueue_record is None:
+            return None
+        queue_record = self.context.buildqueue_record
+        if queue_record.status == BuildQueueStatus.WAITING:
+            start_time = queue_record.getEstimatedJobStartTime()
+        else:
+            start_time = queue_record.date_started
+        if start_time is None:
+            return None
+        duration = queue_record.estimated_duration
+        return start_time + duration
+
+    @cachedproperty
+    def estimate(self):
+        """If true, the date value is an estimate."""
+        if self.context.date_finished is not None:
+            return False
+        return self.eta is not None
+
+    @cachedproperty
+    def date(self):
+        """The date when the build completed or is estimated to complete."""
+        if self.estimate:
+            return self.eta
+        return self.context.date_finished
+
+    @cachedproperty
+    def files(self):
+        """Return `LibraryFileAlias`es for files produced by this build."""
+        if not self.context.was_built:
+            return None
+
+        return [
+            ProxiedLibraryFileAlias(alias, self.context)
+            for _, alias, _ in self.context.getFiles() if not alias.deleted]
+
+    @cachedproperty
+    def has_files(self):
+        return bool(self.files)
+
+
+class SnapBuildCancelView(LaunchpadFormView):
+    """View for cancelling a snap package build."""
+
+    class schema(Interface):
+        """Schema for cancelling a build."""
+
+    page_title = label = 'Cancel build'
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+    next_url = cancel_url
+
+    @action('Cancel build', name='cancel')
+    def request_action(self, action, data):
+        """Cancel the build."""
+        self.context.cancel()
+
+
+class SnapBuildRescoreView(LaunchpadFormView):
+    """View for rescoring a snap package build."""
+
+    schema = IBuildRescoreForm
+
+    page_title = label = 'Rescore build'
+
+    def __call__(self):
+        if self.context.can_be_rescored:
+            return super(SnapBuildRescoreView, self).__call__()
+        self.request.response.addWarningNotification(
+            "Cannot rescore this build because it is not queued.")
+        self.request.response.redirect(canonical_url(self.context))
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+    next_url = cancel_url
+
+    @action('Rescore build', name='rescore')
+    def request_action(self, action, data):
+        """Rescore the build."""
+        score = data.get('priority')
+        self.context.rescore(score)
+        self.request.response.addNotification('Build rescored to %s.' % score)
+
+    @property
+    def initial_values(self):
+        return {'score': str(self.context.buildqueue_record.lastscore)}

=== added directory 'lib/lp/snappy/browser/tests'
=== added file 'lib/lp/snappy/browser/tests/__init__.py'
=== added file 'lib/lp/snappy/browser/tests/test_snap.py'
--- lib/lp/snappy/browser/tests/test_snap.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/browser/tests/test_snap.py	2015-08-07 10:52:53 +0000
@@ -0,0 +1,180 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test snap package views."""
+
+__metaclass__ = type
+
+from datetime import (
+    datetime,
+    timedelta,
+    )
+
+import pytz
+from zope.component import getUtility
+
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.buildmaster.enums import BuildStatus
+from lp.buildmaster.interfaces.processor import IProcessorSet
+from lp.services.features.testing import FeatureFixture
+from lp.services.webapp import canonical_url
+from lp.snappy.browser.snap import SnapView
+from lp.snappy.interfaces.snap import SNAP_FEATURE_FLAG
+from lp.testing import (
+    BrowserTestCase,
+    person_logged_in,
+    TestCaseWithFactory,
+    time_counter,
+    )
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    LaunchpadFunctionalLayer,
+    )
+from lp.testing.publication import test_traverse
+
+
+class TestSnapNavigation(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestSnapNavigation, self).setUp()
+        self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
+
+    def test_canonical_url(self):
+        owner = self.factory.makePerson(name="person")
+        snap = self.factory.makeSnap(
+            registrant=owner, owner=owner, name=u"snap")
+        self.assertEqual(
+            "http://launchpad.dev/~person/+snap/snap";, canonical_url(snap))
+
+    def test_snap(self):
+        snap = self.factory.makeSnap()
+        obj, _, _ = test_traverse(
+            "http://launchpad.dev/~%s/+snap/%s"; % (snap.owner.name, snap.name))
+        self.assertEqual(snap, obj)
+
+
+class TestSnapView(BrowserTestCase):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super(TestSnapView, self).setUp()
+        self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
+        self.ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+        self.distroseries = self.factory.makeDistroSeries(
+            distribution=self.ubuntu, name="shiny", displayname="Shiny")
+        processor = getUtility(IProcessorSet).getByName("386")
+        self.distroarchseries = self.factory.makeDistroArchSeries(
+            distroseries=self.distroseries, architecturetag="i386",
+            processor=processor)
+        self.person = self.factory.makePerson(
+            name="test-person", displayname="Test Person")
+        self.factory.makeBuilder(virtualized=True)
+
+    def makeSnap(self, branch=None, git_ref=None):
+        kwargs = {}
+        if branch is None and git_ref is None:
+            branch = self.factory.makeAnyBranch()
+        if branch is not None:
+            kwargs["branch"] = branch
+        else:
+            kwargs["git_repository"] = git_ref.repository
+            kwargs["git_path"] = git_ref.path
+        return self.factory.makeSnap(
+            registrant=self.person, owner=self.person,
+            distroseries=self.distroseries, name=u"snap-name", **kwargs)
+
+    def makeBuild(self, snap=None, archive=None, date_created=None, **kwargs):
+        if snap is None:
+            snap = self.makeSnap()
+        if archive is None:
+            archive = self.ubuntu.main_archive
+        if date_created is None:
+            date_created = datetime.now(pytz.UTC) - timedelta(hours=1)
+        return self.factory.makeSnapBuild(
+            requester=self.person, snap=snap, archive=archive,
+            distroarchseries=self.distroarchseries, date_created=date_created,
+            **kwargs)
+
+    def test_index(self):
+        build = self.makeBuild(
+            status=BuildStatus.FULLYBUILT, duration=timedelta(minutes=30))
+        self.assertTextMatchesExpressionIgnoreWhitespace("""\
+            Snap packages snap-name
+            .*
+            Snap package information
+            Owner: Test Person
+            Distribution series: Ubuntu Shiny
+            Latest builds
+            Status When complete Architecture Archive
+            Successfully built 30 minutes ago i386
+            Primary Archive for Ubuntu Linux
+            """, self.getMainText(build.snap))
+
+    def test_index_success_with_buildlog(self):
+        # The build log is shown if it is there.
+        build = self.makeBuild(
+            status=BuildStatus.FULLYBUILT, duration=timedelta(minutes=30))
+        build.setLog(self.factory.makeLibraryFileAlias())
+        self.assertTextMatchesExpressionIgnoreWhitespace("""\
+            Latest builds
+            Status When complete Architecture Archive
+            Successfully built 30 minutes ago buildlog \(.*\) i386
+            Primary Archive for Ubuntu Linux
+            """, self.getMainText(build.snap))
+
+    def test_index_hides_builds_into_private_archive(self):
+        # The index page hides builds into archives the user can't view.
+        archive = self.factory.makeArchive(private=True)
+        with person_logged_in(archive.owner):
+            snap = self.makeBuild(archive=archive).snap
+        self.assertIn(
+            "This snap package has not been built yet.",
+            self.getMainText(snap))
+
+    def test_index_no_builds(self):
+        # A message is shown when there are no builds.
+        snap = self.factory.makeSnap()
+        self.assertIn(
+            "This snap package has not been built yet.",
+            self.getMainText(snap))
+
+    def test_index_pending(self):
+        # A pending build is listed as such.
+        build = self.makeBuild()
+        build.queueBuild()
+        self.assertTextMatchesExpressionIgnoreWhitespace("""\
+            Latest builds
+            Status When complete Architecture Archive
+            Needs building in .* \(estimated\) i386
+            Primary Archive for Ubuntu Linux
+            """, self.getMainText(build.snap))
+
+    def setStatus(self, build, status):
+        build.updateStatus(
+            BuildStatus.BUILDING, date_started=build.date_created)
+        build.updateStatus(
+            status, date_finished=build.date_started + timedelta(minutes=30))
+
+    def test_builds(self):
+        # SnapView.builds produces reasonable results.
+        snap = self.makeSnap()
+        # Create oldest builds first so that they sort properly by id.
+        date_gen = time_counter(
+            datetime(2000, 1, 1, tzinfo=pytz.UTC), timedelta(days=1))
+        builds = [
+            self.makeBuild(snap=snap, date_created=next(date_gen))
+            for i in range(11)]
+        view = SnapView(snap, None)
+        self.assertEqual(list(reversed(builds)), view.builds)
+        self.setStatus(builds[10], BuildStatus.FULLYBUILT)
+        self.setStatus(builds[9], BuildStatus.FAILEDTOBUILD)
+        # When there are >= 9 pending builds, only the most recent of any
+        # completed builds is returned.
+        self.assertEqual(
+            list(reversed(builds[:9])) + [builds[10]], view.builds)
+        for build in builds[:9]:
+            self.setStatus(build, BuildStatus.FULLYBUILT)
+        self.assertEqual(list(reversed(builds[1:])), view.builds)

=== added file 'lib/lp/snappy/browser/tests/test_snapbuild.py'
--- lib/lp/snappy/browser/tests/test_snapbuild.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/browser/tests/test_snapbuild.py	2015-08-07 10:52:53 +0000
@@ -0,0 +1,246 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test snap package build views."""
+
+__metaclass__ = type
+
+from fixtures import FakeLogger
+from mechanize import LinkNotFoundError
+from storm.locals import Store
+from testtools.matchers import StartsWith
+import transaction
+from zope.component import getUtility
+from zope.security.interfaces import Unauthorized
+from zope.security.proxy import removeSecurityProxy
+
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.buildmaster.enums import BuildStatus
+from lp.services.features.testing import FeatureFixture
+from lp.services.webapp import canonical_url
+from lp.snappy.interfaces.snap import SNAP_FEATURE_FLAG
+from lp.testing import (
+    admin_logged_in,
+    ANONYMOUS,
+    BrowserTestCase,
+    login,
+    logout,
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    LaunchpadFunctionalLayer,
+    )
+from lp.testing.pages import (
+    extract_text,
+    find_main_content,
+    find_tags_by_class,
+    )
+from lp.testing.views import create_initialized_view
+
+
+class TestCanonicalUrlForSnapBuild(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestCanonicalUrlForSnapBuild, self).setUp()
+        self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
+
+    def test_canonical_url(self):
+        owner = self.factory.makePerson(name="person")
+        snap = self.factory.makeSnap(
+            registrant=owner, owner=owner, name=u"snap")
+        build = self.factory.makeSnapBuild(requester=owner, snap=snap)
+        self.assertThat(
+            canonical_url(build),
+            StartsWith("http://launchpad.dev/~person/+snap/snap/+build/";))
+
+
+class TestSnapBuildView(TestCaseWithFactory):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super(TestSnapBuildView, self).setUp()
+        self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
+
+    def test_files(self):
+        # SnapBuildView.files returns all the associated files.
+        build = self.factory.makeSnapBuild(status=BuildStatus.FULLYBUILT)
+        snapfile = self.factory.makeSnapFile(snapbuild=build)
+        build_view = create_initialized_view(build, "+index")
+        self.assertEqual(
+            [snapfile.libraryfile.filename],
+            [lfa.filename for lfa in build_view.files])
+        # Deleted files won't be included.
+        self.assertFalse(snapfile.libraryfile.deleted)
+        removeSecurityProxy(snapfile.libraryfile).content = None
+        self.assertTrue(snapfile.libraryfile.deleted)
+        build_view = create_initialized_view(build, "+index")
+        self.assertEqual([], build_view.files)
+
+    def test_eta(self):
+        # SnapBuildView.eta returns a non-None value when it should, or None
+        # when there's no start time.
+        build = self.factory.makeSnapBuild()
+        build.queueBuild()
+        self.assertIsNone(create_initialized_view(build, "+index").eta)
+        self.factory.makeBuilder(processors=[build.processor])
+        self.assertIsNotNone(create_initialized_view(build, "+index").eta)
+
+    def test_estimate(self):
+        # SnapBuildView.estimate returns True until the job is completed.
+        build = self.factory.makeSnapBuild()
+        build.queueBuild()
+        self.factory.makeBuilder(processors=[build.processor])
+        build.updateStatus(BuildStatus.BUILDING)
+        self.assertTrue(create_initialized_view(build, "+index").estimate)
+        build.updateStatus(BuildStatus.FULLYBUILT)
+        self.assertFalse(create_initialized_view(build, "+index").estimate)
+
+
+class TestSnapBuildOperations(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestSnapBuildOperations, self).setUp()
+        self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
+        self.useFixture(FakeLogger())
+        self.build = self.factory.makeSnapBuild()
+        self.build_url = canonical_url(self.build)
+        self.requester = self.build.requester
+        self.buildd_admin = self.factory.makePerson(
+            member_of=[getUtility(ILaunchpadCelebrities).buildd_admin])
+
+    def test_cancel_build(self):
+        # The requester of a build can cancel it.
+        self.build.queueBuild()
+        transaction.commit()
+        browser = self.getViewBrowser(self.build, user=self.requester)
+        browser.getLink("Cancel build").click()
+        self.assertEqual(self.build_url, browser.getLink("Cancel").url)
+        browser.getControl("Cancel build").click()
+        self.assertEqual(self.build_url, browser.url)
+        login(ANONYMOUS)
+        self.assertEqual(BuildStatus.CANCELLED, self.build.status)
+
+    def test_cancel_build_random_user(self):
+        # An unrelated non-admin user cannot cancel a build.
+        self.build.queueBuild()
+        transaction.commit()
+        user = self.factory.makePerson()
+        browser = self.getViewBrowser(self.build, user=user)
+        self.assertRaises(LinkNotFoundError, browser.getLink, "Cancel build")
+        self.assertRaises(
+            Unauthorized, self.getUserBrowser, self.build_url + "/+cancel",
+            user=user)
+
+    def test_cancel_build_wrong_state(self):
+        # If the build isn't queued, you can't cancel it.
+        browser = self.getViewBrowser(self.build, user=self.requester)
+        self.assertRaises(LinkNotFoundError, browser.getLink, "Cancel build")
+
+    def test_rescore_build(self):
+        # A buildd admin can rescore a build.
+        self.build.queueBuild()
+        transaction.commit()
+        browser = self.getViewBrowser(self.build, user=self.buildd_admin)
+        browser.getLink("Rescore build").click()
+        self.assertEqual(self.build_url, browser.getLink("Cancel").url)
+        browser.getControl("Priority").value = "1024"
+        browser.getControl("Rescore build").click()
+        self.assertEqual(self.build_url, browser.url)
+        login(ANONYMOUS)
+        self.assertEqual(1024, self.build.buildqueue_record.lastscore)
+
+    def test_rescore_build_invalid_score(self):
+        # Build scores can only take numbers.
+        self.build.queueBuild()
+        transaction.commit()
+        browser = self.getViewBrowser(self.build, user=self.buildd_admin)
+        browser.getLink("Rescore build").click()
+        self.assertEqual(self.build_url, browser.getLink("Cancel").url)
+        browser.getControl("Priority").value = "tentwentyfour"
+        browser.getControl("Rescore build").click()
+        self.assertEqual(
+            "Invalid integer data",
+            extract_text(find_tags_by_class(browser.contents, "message")[1]))
+
+    def test_rescore_build_not_admin(self):
+        # A non-admin user cannot cancel a build.
+        self.build.queueBuild()
+        transaction.commit()
+        user = self.factory.makePerson()
+        browser = self.getViewBrowser(self.build, user=user)
+        self.assertRaises(LinkNotFoundError, browser.getLink, "Rescore build")
+        self.assertRaises(
+            Unauthorized, self.getUserBrowser, self.build_url + "/+rescore",
+            user=user)
+
+    def test_rescore_build_wrong_state(self):
+        # If the build isn't NEEDSBUILD, you can't rescore it.
+        self.build.queueBuild()
+        with person_logged_in(self.requester):
+            self.build.cancel()
+        browser = self.getViewBrowser(self.build, user=self.buildd_admin)
+        self.assertRaises(LinkNotFoundError, browser.getLink, "Rescore build")
+
+    def test_rescore_build_wrong_state_stale_link(self):
+        # An attempt to rescore a non-queued build from a stale link shows a
+        # sensible error message.
+        self.build.queueBuild()
+        with person_logged_in(self.requester):
+            self.build.cancel()
+        browser = self.getViewBrowser(
+            self.build, "+rescore", user=self.buildd_admin)
+        self.assertEqual(self.build_url, browser.url)
+        self.assertIn(
+            "Cannot rescore this build because it is not queued.",
+            browser.contents)
+
+    def test_builder_history(self):
+        Store.of(self.build).flush()
+        self.build.updateStatus(
+            BuildStatus.FULLYBUILT, builder=self.factory.makeBuilder())
+        title = self.build.title
+        browser = self.getViewBrowser(self.build.builder, "+history")
+        self.assertTextMatchesExpressionIgnoreWhitespace(
+            "Build history.*%s" % title,
+            extract_text(find_main_content(browser.contents)))
+        self.assertEqual(self.build_url, browser.getLink(title).url)
+
+    def makeBuildingSnap(self, archive=None):
+        builder = self.factory.makeBuilder()
+        build = self.factory.makeSnapBuild(archive=archive)
+        build.updateStatus(BuildStatus.BUILDING, builder=builder)
+        build.queueBuild()
+        build.buildqueue_record.builder = builder
+        build.buildqueue_record.logtail = "tail of the log"
+        return build
+
+    def test_builder_index_public(self):
+        build = self.makeBuildingSnap()
+        builder_url = canonical_url(build.builder)
+        logout()
+        browser = self.getNonRedirectingBrowser(
+            url=builder_url, user=ANONYMOUS)
+        self.assertIn("tail of the log", browser.contents)
+
+    def test_builder_index_private(self):
+        archive = self.factory.makeArchive(private=True)
+        with admin_logged_in():
+            build = self.makeBuildingSnap(archive=archive)
+            builder_url = canonical_url(build.builder)
+        logout()
+
+        # An unrelated user can't see the logtail of a private build.
+        browser = self.getNonRedirectingBrowser(url=builder_url)
+        self.assertNotIn("tail of the log", browser.contents)
+
+        # But someone who can see the archive can.
+        browser = self.getNonRedirectingBrowser(
+            url=builder_url, user=archive.owner)
+        self.assertIn("tail of the log", browser.contents)

=== modified file 'lib/lp/snappy/model/snap.py'
--- lib/lp/snappy/model/snap.py	2015-08-05 10:52:13 +0000
+++ lib/lp/snappy/model/snap.py	2015-08-07 10:52:53 +0000
@@ -21,6 +21,7 @@
     )
 from zope.component import getUtility
 from zope.interface import implementer
+from zope.security.proxy import removeSecurityProxy
 
 from lp.buildmaster.enums import BuildStatus
 from lp.buildmaster.interfaces.processor import IProcessorSet
@@ -69,7 +70,7 @@
     This method is registered as a subscriber to `IObjectModifiedEvent`
     events on snap packages.
     """
-    snap.date_last_modified = UTC_NOW
+    removeSecurityProxy(snap).date_last_modified = UTC_NOW
 
 
 @implementer(ISnap, IHasOwner)

=== added directory 'lib/lp/snappy/templates'
=== added file 'lib/lp/snappy/templates/snap-index.pt'
--- lib/lp/snappy/templates/snap-index.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/templates/snap-index.pt	2015-08-07 10:52:53 +0000
@@ -0,0 +1,96 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_side"
+  i18n:domain="launchpad"
+>
+
+<body>
+  <metal:registering fill-slot="registering">
+    Created by
+      <tal:registrant replace="structure context/registrant/fmt:link"/>
+    on
+      <tal:created-on replace="structure context/date_created/fmt:date"/>
+    and last modified on
+      <tal:last-modified replace="structure context/date_last_modified/fmt:date"/>
+  </metal:registering>
+
+  <metal:side fill-slot="side">
+    <div tal:replace="structure context/@@+global-actions"/>
+  </metal:side>
+
+  <metal:heading fill-slot="heading">
+    <h1 tal:content="context/name"/>
+  </metal:heading>
+
+  <div metal:fill-slot="main">
+    <h2>Snap package information</h2>
+    <div class="two-column-list">
+      <dl id="owner">
+        <dt>Owner:</dt>
+        <dd tal:content="structure context/owner/fmt:link"/>
+      </dl>
+      <dl id="distro_series">
+        <dt>Distribution series:</dt>
+        <dd tal:define="distro_series context/distro_series">
+          <a tal:attributes="href distro_series/fmt:url"
+             tal:content="distro_series/fullseriesname"/>
+        </dd>
+      </dl>
+    </div>
+
+    <h2>Latest builds</h2>
+    <table id="latest-builds-listing" class="listing"
+           style="margin-bottom: 1em;">
+      <thead>
+        <tr>
+          <th>Status</th>
+          <th>When complete</th>
+          <th>Architecture</th>
+          <th>Archive</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tal:snap-builds repeat="build view/builds">
+          <tal:build-view define="buildview nocall:build/@@+index">
+            <tr tal:attributes="id string:build-${build/id}">
+              <td>
+                <span tal:replace="structure build/image:icon"/>
+                <a tal:content="build/status/title"
+                   tal:attributes="href build/fmt:url"/>
+              </td>
+              <td>
+                <tal:date replace="buildview/date/fmt:displaydate"/>
+                <tal:estimate condition="buildview/estimate">
+                  (estimated)
+                </tal:estimate>
+
+                <tal:build-log define="file build/log" tal:condition="file">
+                  <a class="sprite download"
+                     tal:attributes="href build/log_url">buildlog</a>
+                  (<span tal:replace="file/content/filesize/fmt:bytes"/>)
+                </tal:build-log>
+              </td>
+              <td>
+                <a class="sprite distribution"
+                   tal:define="archseries build/distro_arch_series"
+                   tal:attributes="href archseries/fmt:url"
+                   tal:content="archseries/architecturetag"/>
+              </td>
+              <td>
+                <tal:archive replace="structure build/archive/fmt:link"/>
+              </td>
+            </tr>
+          </tal:build-view>
+        </tal:snap-builds>
+      </tbody>
+    </table>
+    <p tal:condition="not: view/builds">
+      This snap package has not been built yet.
+    </p>
+  </div>
+
+</body>
+</html>

=== added file 'lib/lp/snappy/templates/snapbuild-index.pt'
--- lib/lp/snappy/templates/snapbuild-index.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/templates/snapbuild-index.pt	2015-08-07 10:52:53 +0000
@@ -0,0 +1,205 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad"
+>
+
+  <body>
+
+    <tal:registering metal:fill-slot="registering">
+        created
+        <span tal:content="context/date_created/fmt:displaydate"
+              tal:attributes="title context/date_created/fmt:datetime"/>
+    </tal:registering>
+
+    <div metal:fill-slot="main">
+
+      <div class="yui-g">
+
+        <div id="status" class="yui-u first">
+          <div class="portlet">
+            <div metal:use-macro="template/macros/status"/>
+          </div>
+        </div>
+
+        <div id="details" class="yui-u">
+          <div class="portlet">
+            <div metal:use-macro="template/macros/details"/>
+          </div>
+        </div>
+
+      </div> <!-- yui-g  -->
+
+      <div id="files" class="portlet" tal:condition="view/has_files">
+        <div metal:use-macro="template/macros/files"/>
+      </div>
+
+      <div id="buildlog" class="portlet"
+           tal:condition="context/status/enumvalue:BUILDING">
+        <div metal:use-macro="template/macros/buildlog"/>
+      </div>
+
+   </div> <!-- main -->
+
+
+<metal:macros fill-slot="bogus">
+
+  <metal:macro define-macro="details">
+    <tal:comment replace="nothing">
+      Details section.
+    </tal:comment>
+    <h2>Build details</h2>
+    <div class="two-column-list">
+      <dl>
+        <dt>Snap:</dt>
+          <dd>
+            <tal:snap replace="structure context/snap/fmt:link"/>
+          </dd>
+      </dl>
+      <dl>
+        <dt>Archive:</dt>
+          <dd>
+            <span tal:replace="structure context/archive/fmt:link"/>
+          </dd>
+      </dl>
+      <dl>
+        <dt>Series:</dt>
+          <dd><a class="sprite distribution"
+                 tal:define="series context/distro_series"
+                 tal:attributes="href series/fmt:url"
+                 tal:content="series/displayname"/>
+          </dd>
+      </dl>
+      <dl>
+        <dt>Architecture:</dt>
+          <dd><a class="sprite distribution"
+                 tal:define="archseries context/distro_arch_series"
+                 tal:attributes="href archseries/fmt:url"
+                 tal:content="archseries/architecturetag"/>
+          </dd>
+      </dl>
+      <dl>
+        <dt>Pocket:</dt>
+          <dd><span tal:replace="context/pocket/title"/></dd>
+      </dl>
+    </div>
+  </metal:macro>
+
+  <metal:macro define-macro="status">
+    <tal:comment replace="nothing">
+      Status section.
+    </tal:comment>
+    <h2>Build status</h2>
+    <p>
+      <span tal:replace="structure context/image:icon" />
+      <span tal:attributes="
+            class string:buildstatus${context/status/name};"
+            tal:content="context/status/title"/>
+      <tal:building condition="context/status/enumvalue:BUILDING">
+        on <a tal:content="context/buildqueue_record/builder/title"
+              tal:attributes="href context/buildqueue_record/builder/fmt:url"/>
+      </tal:building>
+      <tal:built condition="context/builder">
+        on <a tal:content="context/builder/title"
+              tal:attributes="href context/builder/fmt:url"/>
+      </tal:built>
+      <tal:cancel define="link context/menu:context/cancel"
+                  condition="link/enabled"
+                  replace="structure link/fmt:link" />
+    </p>
+
+    <ul>
+      <li tal:condition="context/dependencies">
+        Missing build dependencies: <em tal:content="context/dependencies"/>
+     </li>
+      <tal:reallypending condition="context/buildqueue_record">
+      <tal:pending condition="context/buildqueue_record/status/enumvalue:WAITING">
+        <li tal:define="eta context/buildqueue_record/getEstimatedJobStartTime">
+          Start <tal:eta replace="eta/fmt:approximatedate"/>
+          (<span tal:replace="context/buildqueue_record/lastscore"/>)
+          <a href="https://help.launchpad.net/Packaging/BuildScores";
+             target="_blank">What's this?</a>
+        </li>
+      </tal:pending>
+      </tal:reallypending>
+      <tal:started condition="context/date_started">
+        <li tal:condition="context/date_started">
+          Started <span
+           tal:define="start context/date_started"
+           tal:attributes="title start/fmt:datetime"
+           tal:content="start/fmt:displaydate"/>
+        </li>
+      </tal:started>
+      <tal:finish condition="not: context/date_finished">
+        <li tal:define="eta view/eta" tal:condition="view/eta">
+          Estimated finish <tal:eta replace="eta/fmt:approximatedate"/>
+        </li>
+      </tal:finish>
+
+      <li tal:condition="context/date_finished">
+        Finished <span
+          tal:attributes="title context/date_finished/fmt:datetime"
+          tal:content="context/date_finished/fmt:displaydate"/>
+        <tal:duration condition="context/duration">
+          (took <span tal:replace="context/duration/fmt:exactduration"/>)
+        </tal:duration>
+      </li>
+      <li tal:define="file context/log"
+          tal:condition="file">
+        <a class="sprite download"
+           tal:attributes="href context/log_url">buildlog</a>
+        (<span tal:replace="file/content/filesize/fmt:bytes" />)
+      </li>
+      <li tal:define="file context/upload_log"
+          tal:condition="file">
+        <a class="sprite download"
+           tal:attributes="href context/upload_log_url">uploadlog</a>
+        (<span tal:replace="file/content/filesize/fmt:bytes" />)
+      </li>
+    </ul>
+
+    <div
+      style="margin-top: 1.5em"
+      tal:define="link context/menu:context/rescore"
+      tal:condition="link/enabled"
+      >
+      <a tal:replace="structure link/fmt:link"/>
+    </div>
+  </metal:macro>
+
+  <metal:macro define-macro="files">
+    <tal:comment replace="nothing">
+      Files section.
+    </tal:comment>
+    <h2>Built files</h2>
+    <p>Files resulting from this build:</p>
+    <ul>
+      <li tal:repeat="file view/files">
+        <a class="sprite download"
+           tal:content="file/filename"
+           tal:attributes="href file/http_url"/>
+        (<span tal:replace="file/content/filesize/fmt:bytes"/>)
+      </li>
+    </ul>
+  </metal:macro>
+
+  <metal:macro define-macro="buildlog">
+    <tal:comment replace="nothing">
+      Buildlog section.
+    </tal:comment>
+    <h2>Buildlog</h2>
+    <div id="buildlog-tail" class="logtail"
+         tal:define="logtail context/buildqueue_record/logtail"
+         tal:content="structure logtail/fmt:text-to-html"/>
+    <p class="lesser" tal:condition="view/user">
+      Updated on <span tal:replace="structure view/user/fmt:local-time"/>
+    </p>
+  </metal:macro>
+
+</metal:macros>
+
+  </body>
+</html>


Follow ups