← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/livefs-browser into lp:launchpad with lp:~cjwatson/launchpad/livefs as a prerequisite.

Commit message:
Add browser code for live filesystems.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1247461 in Launchpad itself: "Move live filesystem building into Launchpad"
  https://bugs.launchpad.net/launchpad/+bug/1247461

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/livefs-browser/+merge/219505

== Summary ==

Here's the browser code for live filesystems, following up on:

  https://code.launchpad.net/~cjwatson/launchpad/livefs/+merge/217261

== Implementation details ==

While I wrote a Person:+new-livefs page mainly because it was convenient to have one for my local testing (and I'm open to moving that somewhere else, although I'm not sure how the webservice approach with a "livefses" top-level collection could straightforwardly be translated into the web UI), I didn't link it anywhere.  This was partly YAGNI, and partly because I didn't see anywhere it could obviously go on Person:+index.  Suggestions welcome.
-- 
https://code.launchpad.net/~cjwatson/launchpad/livefs-browser/+merge/219505
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/livefs-browser into lp:launchpad.
=== modified file 'lib/lp/app/browser/configure.zcml'
--- lib/lp/app/browser/configure.zcml	2013-04-17 11:07:52 +0000
+++ lib/lp/app/browser/configure.zcml	2014-05-14 11:44:26 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2009-2014 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -564,6 +564,12 @@
       factory="lp.app.browser.tales.BuildImageDisplayAPI"
       name="image"
       />
+  <adapter
+      for="lp.soyuz.interfaces.livefsbuild.ILiveFSBuild"
+      provides="zope.traversing.interfaces.IPathAdapter"
+      factory="lp.app.browser.tales.BuildImageDisplayAPI"
+      name="image"
+      />
 
   <adapter
       for="lp.soyuz.interfaces.archive.IArchive"
@@ -815,6 +821,12 @@
       name="fmt"
       />
   <adapter
+      for="lp.soyuz.interfaces.livefs.ILiveFS"
+      provides="zope.traversing.interfaces.IPathAdapter"
+      factory="lp.app.browser.tales.LiveFSFormatterAPI"
+      name="fmt"
+      />
+  <adapter
       for="lp.blueprints.interfaces.specification.ISpecification"
       provides="zope.traversing.interfaces.IPathAdapter"
       factory="lp.app.browser.tales.SpecificationFormatterAPI"

=== modified file 'lib/lp/app/browser/tales.py'
--- lib/lp/app/browser/tales.py	2013-06-22 08:37:21 +0000
+++ lib/lp/app/browser/tales.py	2014-05-14 11:44:26 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2013 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2014 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Implementation of the lp: htmlform: fmt: namespaces in TALES."""
@@ -1830,6 +1830,18 @@
                 'owner': self._context.owner.displayname}
 
 
+class LiveFSFormatterAPI(CustomizableFormatter):
+    """Adapter providing fmt support for ILiveFS objects."""
+
+    _link_summary_template = _(
+        'Live filesystem %(distroseries)s %(name)s for %(owner)s')
+
+    def _link_summary_values(self):
+        return {'distroseries': self._context.distro_series.name,
+                'name': self._context.name,
+                'owner': self._context.owner.displayname}
+
+
 class SpecificationFormatterAPI(CustomizableFormatter):
     """Adapter providing fmt support for Specification objects"""
 

=== modified file 'lib/lp/soyuz/browser/configure.zcml'
--- lib/lp/soyuz/browser/configure.zcml	2014-05-14 11:44:25 +0000
+++ lib/lp/soyuz/browser/configure.zcml	2014-05-14 11:44:26 +0000
@@ -733,10 +733,51 @@
         path_expression="string:+livefs/${distro_series/distribution/name}/${distro_series/name}/${name}"
         attribute_to_parent="owner"
         />
+    <browser:defaultView
+        for="lp.soyuz.interfaces.livefs.ILiveFS"
+        name="+index"
+        />
+    <browser:page
+        for="lp.soyuz.interfaces.livefs.ILiveFS"
+        class="lp.soyuz.browser.livefs.LiveFSView"
+        permission="launchpad.View"
+        name="+index"
+        template="../templates/livefs-index.pt"
+        />
+    <browser:menus
+        module="lp.soyuz.browser.livefs"
+        classes="LiveFSNavigationMenu"
+        />
     <browser:navigation
         module="lp.soyuz.browser.livefs"
         classes="LiveFSNavigation"
         />
+    <browser:page
+        for="lp.registry.interfaces.person.IPerson"
+        class="lp.soyuz.browser.livefs.LiveFSAddView"
+        permission="launchpad.Edit"
+        name="+new-livefs"
+        template="../templates/livefs-new.pt"
+        />
+    <browser:page
+        for="lp.soyuz.interfaces.livefs.ILiveFS"
+        class="lp.soyuz.browser.livefs.LiveFSEditView"
+        permission="launchpad.Edit"
+        name="+edit"
+        template="../../app/templates/generic-edit.pt"/>
+    <browser:page
+        for="lp.soyuz.interfaces.livefs.ILiveFS"
+        class="lp.soyuz.browser.livefs.LiveFSHierarchy"
+        permission="zope.Public"
+        name="+hierarchy"
+        template="../../app/templates/launchpad-hierarchy.pt"
+        />
+    <adapter
+        provides="lp.services.webapp.interfaces.IBreadcrumb"
+        for="lp.soyuz.interfaces.livefs.ILiveFS"
+        factory="lp.services.webapp.breadcrumb.NameBreadcrumb"
+        permission="zope.Public"
+        />
     <browser:url
         for="lp.soyuz.interfaces.livefs.ILiveFSSet"
         path_expression="string:livefses"
@@ -747,9 +788,44 @@
         path_expression="string:+livefsbuild/${id}"
         attribute_to_parent="livefs"
         />
+    <browser:menus
+        module="lp.soyuz.browser.livefsbuild"
+        classes="LiveFSBuildContextMenu"
+        />
     <browser:navigation
         module="lp.soyuz.browser.livefsbuild"
         classes="LiveFSBuildNavigation"
         />
+    <browser:defaultView
+        for="lp.soyuz.interfaces.livefsbuild.ILiveFSBuild"
+        name="+index"
+        />
+    <browser:page
+        for="lp.soyuz.interfaces.livefsbuild.ILiveFSBuild"
+        class="lp.soyuz.browser.livefsbuild.LiveFSBuildView"
+        permission="launchpad.View"
+        name="+index"
+        template="../templates/livefsbuild-index.pt"
+        />
+    <browser:page
+        for="lp.soyuz.interfaces.livefsbuild.ILiveFSBuild"
+        class="lp.soyuz.browser.livefsbuild.LiveFSBuildCancelView"
+        permission="launchpad.Edit"
+        name="+cancel"
+        template="../../app/templates/generic-edit.pt"
+        />
+    <browser:page
+        for="lp.soyuz.interfaces.livefsbuild.ILiveFSBuild"
+        class="lp.soyuz.browser.livefsbuild.LiveFSBuildRescoreView"
+        permission="launchpad.Admin"
+        name="+rescore"
+        template="../../app/templates/generic-edit.pt"
+        />
+    <adapter
+        provides="lp.services.webapp.interfaces.IBreadcrumb"
+        for="lp.soyuz.interfaces.livefsbuild.ILiveFSBuild"
+        factory="lp.services.webapp.breadcrumb.TitleBreadcrumb"
+        permission="zope.Public"
+        />
     </facet>
 </configure>

=== modified file 'lib/lp/soyuz/browser/livefs.py'
--- lib/lp/soyuz/browser/livefs.py	2014-05-14 11:44:25 +0000
+++ lib/lp/soyuz/browser/livefs.py	2014-05-14 11:44:26 +0000
@@ -1,17 +1,72 @@
 # Copyright 2014 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
+"""LiveFS views."""
+
 __metaclass__ = type
 __all__ = [
+    'LiveFSAddView',
+    'LiveFSEditView',
     'LiveFSNavigation',
+    'LiveFSNavigationMenu',
+    'LiveFSView',
     ]
 
+import json
+
+from lazr.lifecycle.event import ObjectModifiedEvent
+from lazr.lifecycle.snapshot import Snapshot
+from lazr.restful import ResourceJSONEncoder
+from lazr.restful.interface import (
+    copy_field,
+    use_template,
+    )
+from zope.component import getUtility
+from zope.event import notify
+from zope.interface import (
+    implements,
+    Interface,
+    providedBy,
+    )
+from zope.schema import (
+    Choice,
+    Text,
+    )
+
+from lp.app.browser.launchpad import Hierarchy
+from lp.app.browser.launchpadform import (
+    action,
+    custom_widget,
+    LaunchpadEditFormView,
+    LaunchpadFormView,
+    )
+from lp.app.browser.lazrjs import (
+    InlinePersonEditPickerWidget,
+    TextLineEditorWidget,
+    )
+from lp.app.browser.tales import format_link
+from lp.app.widgets.itemswidgets import LaunchpadRadioWidget
+from lp.code.vocabularies.sourcepackagerecipe import BuildableDistroSeries
+from lp.registry.interfaces.series import SeriesStatus
+from lp.services.features import getFeatureFlag
 from lp.services.webapp import (
+    canonical_url,
+    enabled_with_permission,
+    LaunchpadView,
+    Link,
     Navigation,
+    NavigationMenu,
     stepthrough,
     )
+from lp.services.webapp.authorization import check_permission
+from lp.services.webapp.breadcrumb import Breadcrumb
 from lp.soyuz.browser.build import get_build_by_id_str
-from lp.soyuz.interfaces.livefs import ILiveFS
+from lp.soyuz.interfaces.livefs import (
+    ILiveFS,
+    ILiveFSSet,
+    LIVEFS_FEATURE_FLAG,
+    LiveFSFeatureDisabled,
+    )
 from lp.soyuz.interfaces.livefsbuild import ILiveFSBuildSet
 
 
@@ -24,3 +79,262 @@
         if build is None or build.livefs != self.context:
             return None
         return build
+
+
+class ILiveFSesForPerson(Interface):
+    """A marker interface for live filesystem sets."""
+
+
+class LiveFSesForPersonBreadcrumb(Breadcrumb):
+    """A Breadcrumb to handle the "Live filesystems" link."""
+
+    rootsite = None
+    text = 'Live filesystems'
+
+    implements(ILiveFSesForPerson)
+
+    @property
+    def url(self):
+        return canonical_url(self.context, view_name="+livefs")
+
+
+class LiveFSHierarchy(Hierarchy):
+    """Hierarchy for live filesystems."""
+
+    @property
+    def objects(self):
+        """See `Hierarchy`."""
+        traversed = list(self.request.traversed_objects)
+        # Pop the root object.
+        yield traversed.pop(0)
+        # Pop until we find the live filesystem.
+        livefs = traversed.pop(0)
+        while not ILiveFS.providedBy(livefs):
+            yield livefs
+            livefs = traversed.pop(0)
+        # Pop in the "Live filesystems" link.
+        yield LiveFSesForPersonBreadcrumb(livefs.owner)
+        yield livefs
+        for item in traversed:
+            yield item
+
+
+class LiveFSNavigationMenu(NavigationMenu):
+    """Navigation menu for live filesystems."""
+
+    usedfor = ILiveFS
+
+    facet = 'overview'
+
+    links = ('edit',)
+
+    @enabled_with_permission('launchpad.Edit')
+    def edit(self):
+        return Link('+edit', 'Edit live filesystem', icon='edit')
+
+
+class LiveFSView(LaunchpadView):
+    """Default view of a LiveFS."""
+
+    @property
+    def page_title(self):
+        return "%(name)s's %(livefs_name) live filesystem" % {
+            'name': self.context.owner.displayname,
+            'livefs_name': self.context.name,
+            }
+
+    label = page_title
+
+    @property
+    def builds(self):
+        return builds_for_livefs(self.context)
+
+    @property
+    def person_picker(self):
+        field = copy_field(
+            ILiveFS['owner'],
+            vocabularyName='UserTeamsParticipationPlusSelfSimpleDisplay')
+        return InlinePersonEditPickerWidget(
+            self.context, field, format_link(self.context.owner),
+            header='Change owner', step_title='Select a new owner')
+
+    @property
+    def name_widget(self):
+        name = ILiveFS['name']
+        title = "Edit the live filesystem name"
+        return TextLineEditorWidget(
+            self.context, name, title, 'h1', max_width='95%', truncate_lines=1)
+
+    @property
+    def sorted_metadata_items(self):
+        return sorted(self.context.metadata.items())
+
+
+def builds_for_livefs(livefs):
+    """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 livefs.pending_builds
+        if check_permission('launchpad.View', build)]
+    for build in livefs.completed_builds:
+        if not check_permission('launchpad.View', build):
+            continue
+        builds.append(build)
+        if len(builds) >= 10:
+            break
+    return builds
+
+
+class ILiveFSEditSchema(Interface):
+    """Schema for adding or editing a live filesystem."""
+
+    use_template(ILiveFS, include=[
+        'owner',
+        'name',
+        ])
+    distro_series = Choice(
+        vocabulary='BuildableDistroSeries', title=u'Distribution series')
+    metadata = Text(
+        title=u'Live filesystem build metadata',
+        description=(
+            u'A JSON dictionary of data about the image.  Entries here will '
+             'be passed to the builder slave.'))
+
+
+class LiveFSMetadataValidatorMixin:
+    """Class to validate that live filesystem properties are valid."""
+
+    def validate(self, data):
+        if data['metadata']:
+            try:
+                json.loads(data['metadata'])
+            except Exception as e:
+                self.setFieldError('metadata', str(e))
+
+
+class LiveFSAddView(LiveFSMetadataValidatorMixin, LaunchpadFormView):
+    """View for creating live filesystems."""
+
+    title = label = 'Create a new live filesystem'
+
+    schema = ILiveFSEditSchema
+    custom_widget('distro_series', LaunchpadRadioWidget)
+
+    def initialize(self):
+        """See `LaunchpadView`."""
+        if not getFeatureFlag(LIVEFS_FEATURE_FLAG):
+            raise LiveFSFeatureDisabled
+        super(LiveFSAddView, self).initialize()
+
+    @property
+    def initial_values(self):
+        series = [
+            term.value for term in BuildableDistroSeries()
+            if term.value.status in (
+                SeriesStatus.CURRENT, SeriesStatus.DEVELOPMENT)][0]
+        return {
+            'owner': self.user,
+            'distro_series': series,
+            'metadata': '{}',
+            }
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
+    @action('Create live filesystem', name='create')
+    def request_action(self, action, data):
+        livefs = getUtility(ILiveFSSet).new(
+            self.user, data['owner'], data['distro_series'], data['name'],
+            json.loads(data['metadata']))
+        self.next_url = canonical_url(livefs)
+
+    def validate(self, data):
+        super(LiveFSAddView, self).validate(data)
+        owner = data.get('owner', None)
+        distro_series = data['distro_series']
+        name = data.get('name', None)
+        if owner and name:
+            if getUtility(ILiveFSSet).exists(owner, distro_series, name):
+                self.setFieldError(
+                    'name',
+                    'There is already a live filesystem for %s owned by %s '
+                    'with this name.' % (
+                        distro_series.displayname, owner.displayname))
+
+
+class LiveFSEditView(LiveFSMetadataValidatorMixin, LaunchpadEditFormView):
+    """View for editing live filesystems."""
+
+    @property
+    def title(self):
+        return 'Edit %s live filesystem' % self.context.name
+
+    label = title
+
+    schema = ILiveFSEditSchema
+    custom_widget('distro_series', LaunchpadRadioWidget)
+
+    @property
+    def initial_values(self):
+        return {
+            'distro_series': self.context.distro_series,
+            'metadata': json.dumps(
+                self.context.metadata, ensure_ascii=False,
+                cls=ResourceJSONEncoder),
+            }
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
+    @action('Update live filesystem', name='update')
+    def request_action(self, action, data):
+        changed = False
+        livefs_before_modification = Snapshot(
+            self.context, providing=providedBy(self.context))
+
+        metadata = json.loads(data.pop('metadata'))
+        if self.context.metadata != metadata:
+            self.context.metadata = metadata
+            changed = True
+
+        if self.updateContextFromData(data, notify_modified=False):
+            changed = True
+
+        if changed:
+            field_names = [
+                form_field.__name__ for form_field in self.form_fields]
+            notify(ObjectModifiedEvent(
+                self.context, livefs_before_modification, field_names))
+
+        self.next_url = canonical_url(self.context)
+
+    @property
+    def adapters(self):
+        """See `LaunchpadFormView`."""
+        return {ILiveFSEditSchema: self.context}
+
+    def validate(self, data):
+        super(LiveFSEditView, self).validate(data)
+        owner = data.get('owner', None)
+        distro_series = data['distro_series']
+        name = data.get('name', None)
+        if owner and name:
+            livefs = getUtility(ILiveFSSet).getByName(
+                owner, distro_series, name)
+            if livefs is not None and livefs != self.context:
+                self.setFieldError(
+                    'name',
+                    'There is already a live filesystem for %s owned by %s '
+                    'with this name.' % (
+                        distro_series.displayname, owner.displayname))

=== modified file 'lib/lp/soyuz/browser/livefsbuild.py'
--- lib/lp/soyuz/browser/livefsbuild.py	2014-05-14 11:44:25 +0000
+++ lib/lp/soyuz/browser/livefsbuild.py	2014-05-14 11:44:26 +0000
@@ -1,15 +1,167 @@
 # Copyright 2014 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
+"""LiveFSBuild views."""
+
 __metaclass__ = type
 __all__ = [
+    'LiveFSBuildContextMenu',
     'LiveFSBuildNavigation',
+    'LiveFSBuildView',
     ]
 
-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.soyuz.interfaces.binarypackagebuild import IBuildRescoreForm
 from lp.soyuz.interfaces.livefsbuild import ILiveFSBuild
 
 
 class LiveFSBuildNavigation(Navigation, FileNavigationMixin):
     usedfor = ILiveFSBuild
+
+
+class LiveFSBuildContextMenu(ContextMenu):
+    """Context menu for live filesystem builds."""
+
+    usedfor = ILiveFSBuild
+
+    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 LiveFSBuildView(LaunchpadView):
+    """Default view of a LiveFSBuild."""
+
+    @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 LiveFSBuildCancelView(LaunchpadFormView):
+    """View for cancelling a live filesystem 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 LiveFSBuildRescoreView(LaunchpadFormView):
+    """View for rescoring a live filesystem build."""
+
+    schema = IBuildRescoreForm
+
+    page_title = label = 'Rescore build'
+
+    def __call__(self):
+        if self.context.can_be_rescored:
+            return super(LiveFSBuildRescoreView, 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)}

=== modified file 'lib/lp/soyuz/browser/tests/test_livefs.py'
--- lib/lp/soyuz/browser/tests/test_livefs.py	2014-05-14 11:44:25 +0000
+++ lib/lp/soyuz/browser/tests/test_livefs.py	2014-05-14 11:44:26 +0000
@@ -1,15 +1,59 @@
 # Copyright 2014 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
-"""Test live filesystem navigation."""
+"""Test live filesystem views."""
 
 __metaclass__ = type
 
+from datetime import (
+    datetime,
+    timedelta,
+    )
+import json
+
+from fixtures import FakeLogger
+import pytz
+from zope.component import getUtility
+from zope.security.interfaces import Unauthorized
+
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.buildmaster.enums import BuildStatus
+from lp.registry.interfaces.series import SeriesStatus
+from lp.services.database.constants import UTC_NOW
 from lp.services.features.testing import FeatureFixture
-from lp.soyuz.interfaces.livefs import LIVEFS_FEATURE_FLAG
-from lp.testing import TestCaseWithFactory
-from lp.testing.layers import DatabaseFunctionalLayer
+from lp.services.webapp import canonical_url
+from lp.services.webapp.servers import LaunchpadTestRequest
+from lp.soyuz.browser.livefs import (
+    LiveFSEditView,
+    LiveFSView,
+    )
+from lp.soyuz.interfaces.livefs import (
+    LIVEFS_FEATURE_FLAG,
+    LiveFSFeatureDisabled,
+    )
+from lp.soyuz.interfaces.processor import IProcessorSet
+from lp.testing import (
+    BrowserTestCase,
+    person_logged_in,
+    TestCaseWithFactory,
+    time_counter,
+    )
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    LaunchpadFunctionalLayer,
+    )
+from lp.testing.matchers import (
+    MatchesPickerText,
+    MatchesTagText,
+    )
+from lp.testing.pages import (
+    extract_text,
+    find_main_content,
+    find_tags_by_class,
+    get_feedback_messages,
+    )
 from lp.testing.publication import test_traverse
+from lp.testing.views import create_initialized_view
 
 
 class TestLiveFSNavigation(TestCaseWithFactory):
@@ -20,10 +64,313 @@
         super(TestLiveFSNavigation, self).setUp()
         self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
 
+    def test_canonical_url(self):
+        owner = self.factory.makePerson(name="person")
+        distribution = self.factory.makeDistribution(
+            name="distro", owner=owner)
+        distroseries = self.factory.makeDistroSeries(
+            distribution=distribution, name="unstable")
+        livefs = self.factory.makeLiveFS(
+            registrant=owner, owner=owner, distroseries=distroseries,
+            name=u"livefs")
+        self.assertEqual(
+            "http://launchpad.dev/~person/+livefs/distro/unstable/livefs";,
+            canonical_url(livefs))
+
     def test_livefs(self):
         livefs = self.factory.makeLiveFS()
         obj, _, _ = test_traverse(
-            "http://api.launchpad.dev/devel/~%s/+livefs/%s/%s/%s"; % (
+            "http://launchpad.dev/~%s/+livefs/%s/%s/%s"; % (
                 livefs.owner.name, livefs.distro_series.distribution.name,
                 livefs.distro_series.name, livefs.name))
         self.assertEqual(livefs, obj)
+
+
+class TestLiveFSViewsFeatureFlag(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_feature_flag_disabled(self):
+        # Without a feature flag, we will not create new LiveFSes.
+        person = self.factory.makePerson()
+        self.assertRaises(
+            LiveFSFeatureDisabled, create_initialized_view,
+            person, "+new-livefs")
+
+
+class TestLiveFSAddView(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestLiveFSAddView, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+        self.useFixture(FakeLogger())
+        self.person = self.factory.makePerson(
+            name="test-person", displayname="Test Person")
+
+    def test_initial_distroseries(self):
+        # The initial distroseries is the newest that is current or in
+        # development.
+        archive = self.factory.makeArchive(owner=self.person)
+        self.factory.makeDistroSeries(
+            distribution=archive.distribution, version="14.04",
+            status=SeriesStatus.DEVELOPMENT)
+        development = self.factory.makeDistroSeries(
+            distribution=archive.distribution, version="14.10",
+            status=SeriesStatus.DEVELOPMENT)
+        self.factory.makeDistroSeries(
+            distribution=archive.distribution, version="15.04",
+            status=SeriesStatus.EXPERIMENTAL)
+        with person_logged_in(self.person):
+            view = create_initialized_view(self.person, "+new-livefs")
+        self.assertEqual(development, view.initial_values["distro_series"])
+
+    def test_create_new_livefs_not_logged_in(self):
+        self.assertRaises(
+            Unauthorized, self.getViewBrowser, self.person,
+            view_name="+new-livefs", no_login=True)
+
+    def test_create_new_livefs(self):
+        archive = self.factory.makeArchive()
+        distroseries = self.factory.makeDistroSeries(
+            distribution=archive.distribution, status=SeriesStatus.DEVELOPMENT)
+        browser = self.getViewBrowser(
+            self.person, view_name="+new-livefs", user=self.person)
+        browser.getControl("Name").value = "ubuntu-core"
+        browser.getControl("Live filesystem build metadata").value = (
+            '{"product": "ubuntu-core", "image_format": "plain"}')
+        browser.getControl("Create live filesystem").click()
+
+        content = find_main_content(browser.contents)
+        self.assertEqual("ubuntu-core\nEdit", extract_text(content.h1))
+        self.assertThat(
+            "Test Person", MatchesPickerText(content, "edit-owner"))
+        self.assertThat(
+            "Distribution series:\n%s\nEdit live filesystem" %
+            distroseries.fullseriesname,
+            MatchesTagText(content, "distro_series"))
+        self.assertThat(
+            "Metadata:\nimage_format\nplain\nproduct\nubuntu-core",
+            MatchesTagText(content, "metadata"))
+
+    def test_create_new_livefs_users_teams_as_owner_options(self):
+        # Teams that the user is in are options for the live filesystem owner.
+        self.factory.makeTeam(
+            name="test-team", displayname="Test Team", members=[self.person])
+        browser = self.getViewBrowser(
+            self.person, view_name="+new-livefs", user=self.person)
+        options = browser.getControl("Owner").displayOptions
+        self.assertEqual(
+            ["Test Person (test-person)", "Test Team (test-team)"],
+            sorted(str(option) for option in options))
+
+    def test_create_new_livefs_invalid_metadata(self):
+        # The metadata field must contain valid JSON.
+        browser = self.getViewBrowser(
+            self.person, view_name="+new-livefs", user=self.person)
+        browser.getControl("Name").value = "ubuntu-core"
+        browser.getControl("Live filesystem build metadata").value = "{"
+        browser.getControl("Create live filesystem").click()
+        json_error = str(self.assertRaises(ValueError, json.loads, "{"))
+        self.assertEqual(
+            json_error, get_feedback_messages(browser.contents)[1])
+
+
+class TestLiveFSEditView(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestLiveFSEditView, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+        self.useFixture(FakeLogger())
+        self.person = self.factory.makePerson(
+            name="test-person", displayname="Test Person")
+
+    def test_edit_livefs(self):
+        archive = self.factory.makeArchive()
+        old_series = self.factory.makeDistroSeries(
+            distribution=archive.distribution, status=SeriesStatus.CURRENT)
+        livefs = self.factory.makeLiveFS(
+            registrant=self.person, owner=self.person, distroseries=old_series)
+        self.factory.makeTeam(
+            name="new-team", displayname="New Team", members=[self.person])
+        new_series = self.factory.makeDistroSeries(
+            distribution=archive.distribution, status=SeriesStatus.DEVELOPMENT)
+
+        browser = self.getViewBrowser(livefs, user=self.person)
+        browser.getLink("Edit live filesystem").click()
+        browser.getControl("Owner").value = ["new-team"]
+        browser.getControl("Name").value = "new-name"
+        browser.getControl(name="field.distro_series").value = [
+            str(new_series.id)]
+        browser.getControl("Live filesystem build metadata").value = (
+            '{"product": "new-name"}')
+        browser.getControl("Update live filesystem").click()
+
+        content = find_main_content(browser.contents)
+        self.assertEqual("new-name\nEdit", extract_text(content.h1))
+        self.assertThat("New Team", MatchesPickerText(content, "edit-owner"))
+        self.assertThat(
+            "Distribution series:\n%s\nEdit live filesystem" %
+            new_series.fullseriesname,
+            MatchesTagText(content, "distro_series"))
+        self.assertThat(
+            "Metadata:\nproduct\nnew-name",
+            MatchesTagText(content, "metadata"))
+
+    def test_edit_livefs_sets_date_last_modified(self):
+        # Editing a live filesystem sets the date_last_modified property.
+        date_created = datetime(2000, 1, 1, tzinfo=pytz.UTC)
+        livefs = self.factory.makeLiveFS(
+            registrant=self.person, date_created=date_created)
+        with person_logged_in(self.person):
+            view = LiveFSEditView(livefs, LaunchpadTestRequest())
+            view.initialize()
+            view.request_action.success({
+                "owner": livefs.owner,
+                "name": u"changed",
+                "distro_series": livefs.distro_series,
+                "metadata": "{}",
+                })
+        self.assertSqlAttributeEqualsDate(
+            livefs, "date_last_modified", UTC_NOW)
+
+    def test_edit_livefs_already_exists(self):
+        distroseries = self.factory.makeDistroSeries(
+            distribution=getUtility(ILaunchpadCelebrities).ubuntu,
+            displayname="Grumpy")
+        livefs = self.factory.makeLiveFS(
+            registrant=self.person, owner=self.person,
+            distroseries=distroseries, name=u"one")
+        self.factory.makeLiveFS(
+            registrant=self.person, owner=self.person,
+            distroseries=distroseries, name=u"two")
+        browser = self.getViewBrowser(livefs, user=self.person)
+        browser.getLink("Edit live filesystem").click()
+        browser.getControl("Name").value = "two"
+        browser.getControl("Update live filesystem").click()
+        self.assertEqual(
+            "There is already a live filesystem for Grumpy owned by "
+            "Test Person with this name.",
+            extract_text(find_tags_by_class(browser.contents, "message")[1]))
+
+
+class TestLiveFSView(BrowserTestCase):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super(TestLiveFSView, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_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")
+
+    def makeLiveFS(self):
+        return self.factory.makeLiveFS(
+            registrant=self.person, owner=self.person,
+            distroseries=self.distroseries, name=u"livefs-name",
+            metadata={"project": "ubuntu-test"})
+
+    def makeBuild(self, livefs=None, archive=None, date_created=None,
+                  **kwargs):
+        if livefs is None:
+            livefs = self.makeLiveFS()
+        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.makeLiveFSBuild(
+            requester=self.person, livefs=livefs, 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("""\
+            Test Person Live filesystems livefs-name
+            .*
+            Live filesystem information
+            Owner: Test Person
+            Distribution series: Ubuntu Shiny
+            Metadata: project ubuntu-test
+            Latest builds
+            Status When complete Architecture Archive
+            Successfully built 30 minutes ago i386
+            Primary Archive for Ubuntu Linux
+            """, self.getMainText(build.livefs))
+
+    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.livefs))
+
+    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):
+            livefs = self.makeBuild(archive=archive).livefs
+        self.assertIn(
+            "This live filesystem has not been built yet.",
+            self.getMainText(livefs))
+
+    def test_index_no_builds(self):
+        # A message is shown when there are no builds.
+        livefs = self.factory.makeLiveFS()
+        self.assertIn(
+            "This live filesystem has not been built yet.",
+            self.getMainText(livefs))
+
+    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.livefs))
+
+    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):
+        # LiveFSView.builds produces reasonable results.
+        livefs = self.makeLiveFS()
+        # 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(livefs=livefs, date_created=next(date_gen))
+            for i in range(11)]
+        view = LiveFSView(livefs, 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/soyuz/browser/tests/test_livefsbuild.py'
--- lib/lp/soyuz/browser/tests/test_livefsbuild.py	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/browser/tests/test_livefsbuild.py	2014-05-14 11:44:26 +0000
@@ -0,0 +1,261 @@
+# Copyright 2014 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test live filesystem 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.soyuz.interfaces.livefs import LIVEFS_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,
+    setupBrowser,
+    setupBrowserForUser,
+    )
+from lp.testing.views import create_initialized_view
+
+
+class TestCanonicalUrlForLiveFSBuild(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestCanonicalUrlForLiveFSBuild, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+
+    def test_canonical_url(self):
+        owner = self.factory.makePerson(name="person")
+        distribution = self.factory.makeDistribution(
+            name="distro", owner=owner)
+        distroseries = self.factory.makeDistroSeries(
+            distribution=distribution, name="unstable")
+        livefs = self.factory.makeLiveFS(
+            registrant=owner, owner=owner, distroseries=distroseries,
+            name=u"livefs")
+        build = self.factory.makeLiveFSBuild(requester=owner, livefs=livefs)
+        self.assertThat(
+            canonical_url(build),
+            StartsWith(
+                "http://launchpad.dev/~person/+livefs/distro/unstable/livefs/";
+                "+livefsbuild/"))
+
+
+class TestLiveFSBuildView(TestCaseWithFactory):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super(TestLiveFSBuildView, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+
+    def test_files(self):
+        # LiveFSBuildView.files returns all the associated files.
+        build = self.factory.makeLiveFSBuild(status=BuildStatus.FULLYBUILT)
+        livefsfile = self.factory.makeLiveFSFile(livefsbuild=build)
+        build_view = create_initialized_view(build, "+index")
+        self.assertEqual(
+            [livefsfile.libraryfile.filename],
+            [lfa.filename for lfa in build_view.files])
+        # Deleted files won't be included.
+        self.assertFalse(livefsfile.libraryfile.deleted)
+        removeSecurityProxy(livefsfile.libraryfile).content = None
+        self.assertTrue(livefsfile.libraryfile.deleted)
+        build_view = create_initialized_view(build, "+index")
+        self.assertEqual([], build_view.files)
+
+    def test_eta(self):
+        # LiveFSBuildView.eta returns a non-None value when it should, or
+        # None when there's no start time.
+        build = self.factory.makeLiveFSBuild()
+        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):
+        # LiveFSBuildView.estimate returns True until the job is completed.
+        build = self.factory.makeLiveFSBuild()
+        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 TestLiveFSBuildOperations(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestLiveFSBuildOperations, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: u"on"}))
+        self.useFixture(FakeLogger())
+        self.build = self.factory.makeLiveFSBuild()
+        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 makeBuildingLiveFS(self, archive=None):
+        builder = self.factory.makeBuilder()
+        build = self.factory.makeLiveFSBuild(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 makeNonRedirectingBrowser(self, url, user=None):
+        browser = setupBrowserForUser(user) if user else setupBrowser()
+        browser.mech_browser.set_handle_equiv(False)
+        browser.open(url)
+        return browser
+
+    def test_builder_index_public(self):
+        build = self.makeBuildingLiveFS()
+        builder_url = canonical_url(build.builder)
+        logout()
+        browser = self.makeNonRedirectingBrowser(builder_url)
+        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.makeBuildingLiveFS(archive=archive)
+            builder_url = canonical_url(build.builder)
+        user = self.factory.makePerson()
+        logout()
+
+        # An unrelated user can't see the logtail of a private build.
+        browser = self.makeNonRedirectingBrowser(builder_url, user=user)
+        self.assertNotIn("tail of the log", browser.contents)
+
+        # But someone who can see the archive can.
+        browser = self.makeNonRedirectingBrowser(
+            builder_url, user=archive.owner)
+        self.assertIn("tail of the log", browser.contents)

=== added file 'lib/lp/soyuz/templates/livefs-index.pt'
--- lib/lp/soyuz/templates/livefs-index.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/templates/livefs-index.pt	2014-05-14 11:44:26 +0000
@@ -0,0 +1,109 @@
+<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:replace="structure view/name_widget"/>
+  </metal:heading>
+
+  <div metal:fill-slot="main">
+    <h2>Live filesystem information</h2>
+    <div class="two-column-list">
+      <dl id="owner">
+        <dt>Owner:</dt>
+        <dd tal:content="structure view/person_picker"/>
+      </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"/>
+          <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
+        </dd>
+      </dl>
+      <dl id="metadata">
+        <dt>Metadata:</dt>
+        <dd>
+          <table class="listing compressed">
+            <tbody>
+              <tr tal:repeat="pair view/sorted_metadata_items">
+                <td tal:repeat="value pair" tal:content="value"/>
+              </tr>
+            </tbody>
+          </table>
+        </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:livefs-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:livefs-builds>
+      </tbody>
+    </table>
+    <p tal:condition="not: view/builds">
+      This live filesystem has not been built yet.
+    </p>
+  </div>
+
+</body>
+</html>

=== added file 'lib/lp/soyuz/templates/livefs-new.pt'
--- lib/lp/soyuz/templates/livefs-new.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/templates/livefs-new.pt	2014-05-14 11:44:26 +0000
@@ -0,0 +1,45 @@
+<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>
+  <div metal:fill-slot="main">
+    <div>
+      <p>
+        A live filesystem image is a copy of an operating system that can
+        start from a removable medium such as a DVD or a USB drive, without
+        needing to be installed to a hard disk. It is typically used as part
+        of installation media released by distributions.
+      </p>
+      <p>
+        Launchpad can build a limited variety of live filesystem images
+        using <tt>live-build</tt> and <tt>livecd-rootfs</tt>.
+      </p>
+    </div>
+
+    <div metal:use-macro="context/@@launchpad_form/form">
+      <metal:formbody fill-slot="widgets">
+        <table class="form">
+          <tal:widget define="widget nocall:view/widgets/name">
+            <metal:block use-macro="context/@@launchpad_form/widget_row"/>
+          </tal:widget>
+          <tal:widget define="widget nocall:view/widgets/owner">
+            <metal:block use-macro="context/@@launchpad_form/widget_row"/>
+          </tal:widget>
+          <tal:widget define="widget nocall:view/widgets/distro_series">
+            <metal:block use-macro="context/@@launchpad_form/widget_row"/>
+          </tal:widget>
+          <tal:widget define="widget nocall:view/widgets/metadata">
+            <metal:block use-macro="context/@@launchpad_form/widget_row"/>
+          </tal:widget>
+        </table>
+      </metal:formbody>
+    </div>
+  </div>
+</body>
+</html>

=== added file 'lib/lp/soyuz/templates/livefsbuild-index.pt'
--- lib/lp/soyuz/templates/livefsbuild-index.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/soyuz/templates/livefsbuild-index.pt	2014-05-14 11:44:26 +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>Live filesystem:</dt>
+          <dd>
+            <tal:livefs replace="structure context/livefs/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>


References