← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Review: Approve code



Diff comments:

> === 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-06-17 15:19:20 +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-06-17 15:19:20 +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-06-17 15:19:18 +0000
> +++ lib/lp/soyuz/browser/configure.zcml	2014-06-17 15:19:20 +0000
> @@ -733,10 +733,57 @@
>          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.LiveFSAdminView"
> +        permission="launchpad.Admin"
> +        name="+admin"
> +        template="../../app/templates/generic-edit.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 +794,44 @@
>          path_expression="string:+build/${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-06-17 15:19:18 +0000
> +++ lib/lp/soyuz/browser/livefs.py	2014-06-17 15:19:20 +0000
> @@ -1,17 +1,73 @@
>  # 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,
> +    NoSuchLiveFS,
> +    )
>  from lp.soyuz.interfaces.livefsbuild import ILiveFSBuildSet
>  
>  
> @@ -24,3 +80,285 @@
>          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 = ('admin', 'edit',)
> +
> +    @enabled_with_permission('launchpad.Admin')
> +    def admin(self):
> +        return Link('+admin', 'Administer live filesystem', icon='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,
> +            }

This might want to include the distroseries.

> +
> +    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):
> +        if self.context.metadata is None:
> +            return []
> +        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',
> +        'require_virtualized',
> +        ])
> +    distro_series = Choice(
> +        vocabulary='BuildableDistroSeries', title=u'Distribution series')

This will need to be revisited with derived distros, but it'll do for now.

> +    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
> +    field_names = ['owner', 'name', 'distro_series', 'metadata']
> +    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 BaseLiveFSEditView(LaunchpadEditFormView):
> +
> +    schema = ILiveFSEditSchema
> +
> +    @property
> +    def cancel_url(self):
> +        return canonical_url(self.context)
> +
> +    @action('Update live filesystem', name='update')
> +    def request_action(self, action, data):
> +        self.updateContextFromData(data)
> +        self.next_url = canonical_url(self.context)
> +
> +    @property
> +    def adapters(self):
> +        """See `LaunchpadFormView`."""
> +        return {ILiveFSEditSchema: self.context}
> +
> +
> +class LiveFSAdminView(BaseLiveFSEditView):
> +    """View for administering live filesystems."""
> +
> +    @property
> +    def title(self):
> +        return 'Administer %s live filesystem' % self.context.name
> +
> +    label = title
> +
> +    field_names = ['require_virtualized']
> +
> +    @property
> +    def initial_values(self):
> +        return {'require_virtualized': self.context.require_virtualized}
> +
> +
> +
> +class LiveFSEditView(LiveFSMetadataValidatorMixin, BaseLiveFSEditView):
> +    """View for editing live filesystems."""
> +
> +    @property
> +    def title(self):
> +        return 'Edit %s live filesystem' % self.context.name
> +
> +    label = title
> +
> +    field_names = ['owner', 'name', 'distro_series', 'metadata']
> +    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),
> +            }
> +
> +    def updateContextFromData(self, data, context=None, notify_modified=True):
> +        """See `LaunchpadEditFormView`."""
> +        if 'metadata' in data:
> +            data['metadata'] = json.loads(data['metadata'])
> +        super(LiveFSEditView, self).updateContextFromData(
> +            data, context=context, notify_modified=notify_modified)
> +
> +    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:
> +            try:
> +                livefs = getUtility(ILiveFSSet).getByName(
> +                    owner, distro_series, name)
> +                if 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))
> +            except NoSuchLiveFS:
> +                pass
> 
> === modified file 'lib/lp/soyuz/browser/livefsbuild.py'
> --- lib/lp/soyuz/browser/livefsbuild.py	2014-06-17 15:19:18 +0000
> +++ lib/lp/soyuz/browser/livefsbuild.py	2014-06-17 15:19:20 +0000
> @@ -1,15 +1,173 @@
>  # 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)
> +
> +    @property
> +    def sorted_metadata_override_items(self):
> +        if self.context.metadata_override is None:
> +            return []
> +        return sorted(self.context.metadata_override.items())
> +
> +
> +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-06-17 15:19:18 +0000
> +++ lib/lp/soyuz/browser/tests/test_livefs.py	2014-06-17 15:19:20 +0000
> @@ -1,15 +1,63 @@
>  # 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
> +from mechanize import LinkNotFoundError
> +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 (
> +    LiveFSAdminView,
> +    LiveFSEditView,
> +    LiveFSView,
> +    )
> +from lp.soyuz.interfaces.livefs import (
> +    LIVEFS_FEATURE_FLAG,
> +    LiveFSFeatureDisabled,
> +    )
> +from lp.soyuz.interfaces.processor import IProcessorSet
> +from lp.testing import (
> +    BrowserTestCase,
> +    login,
> +    login_person,
> +    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 +68,368 @@
>          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 TestLiveFSAdminView(BrowserTestCase):
> +
> +    layer = DatabaseFunctionalLayer
> +
> +    def setUp(self):
> +        super(TestLiveFSAdminView, 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_unauthorized(self):
> +        # A non-admin user cannot administer a live filesystem.
> +        login_person(self.person)
> +        livefs = self.factory.makeLiveFS(registrant=self.person)
> +        livefs_url = canonical_url(livefs)
> +        browser = self.getViewBrowser(livefs, user=self.person)
> +        self.assertRaises(
> +            LinkNotFoundError, browser.getLink, "Administer live filesystem")
> +        self.assertRaises(
> +            Unauthorized, self.getUserBrowser, livefs_url + "/+admin",
> +            user=self.person)
> +
> +    def test_admin_livefs(self):
> +        # Admins can change require_virtualized.
> +        login("admin@xxxxxxxxxxxxx")
> +        admins = getUtility(ILaunchpadCelebrities).commercial_admin
> +        admins.addMember(self.person, admins)
> +        login_person(self.person)
> +        livefs = self.factory.makeLiveFS(registrant=self.person)
> +        self.assertTrue(livefs.require_virtualized)
> +        browser = self.getViewBrowser(livefs, user=self.person)
> +        browser.getLink("Administer live filesystem").click()
> +        browser.getControl("Require virtualized builders").selected = False
> +        browser.getControl("Update live filesystem").click()
> +        login_person(self.person)
> +        self.assertFalse(livefs.require_virtualized)
> +
> +    def test_admin_livefs_sets_date_last_modified(self):
> +        # Administering a live filesystem sets the date_last_modified property.
> +        login("admin@xxxxxxxxxxxxx")
> +        admins = getUtility(ILaunchpadCelebrities).commercial_admin
> +        admins.addMember(self.person, admins)
> +        login_person(self.person)
> +        date_created = datetime(2000, 1, 1, tzinfo=pytz.UTC)
> +        livefs = self.factory.makeLiveFS(
> +            registrant=self.person, date_created=date_created)
> +        view = LiveFSAdminView(livefs, LaunchpadTestRequest())
> +        view.initialize()
> +        view.request_action.success({"require_virtualized": False})
> +        self.assertSqlAttributeEqualsDate(
> +            livefs, "date_last_modified", UTC_NOW)
> +
> +
> +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")
> +        self.factory.makeBuilder(virtualized=True)
> +
> +    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-06-17 15:19:20 +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/";
> +                "+build/"))
> +
> +
> +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-06-17 15:19:20 +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-06-17 15:19:20 +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-06-17 15:19:20 +0000
> @@ -0,0 +1,217 @@
> +<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>
> +      <dl>
> +        <dt>Metadata overrides:</dt>
> +          <dd>
> +            <table class="listing compressed">
> +              <tbody>
> +                <tr tal:repeat="pair view/sorted_metadata_override_items">
> +                  <td tal:repeat="value pair" tal:content="value"/>
> +                </tr>
> +              </tbody>
> +            </table>
> +          </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>
> 


-- 
https://code.launchpad.net/~cjwatson/launchpad/livefs-browser/+merge/219505
Your team Launchpad code reviewers is subscribed to branch lp:launchpad.


References