← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/base-snap into lp:launchpad with lp:~cjwatson/launchpad/snap-only-available-processors as a prerequisite.

Commit message:
Add BaseSnap model.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1812985 in Launchpad itself: "Support snapcraft base snaps"
  https://bugs.launchpad.net/launchpad/+bug/1812985

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/base-snap/+merge/362729

The corresponding DB patch is in https://code.launchpad.net/~cjwatson/launchpad/db-base-snap/+merge/362727.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/base-snap into lp:launchpad.
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2018-12-03 13:42:36 +0000
+++ database/schema/security.cfg	2019-02-05 12:19:08 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 #
 # Possible permissions: SELECT, INSERT, UPDATE, EXECUTE
@@ -128,6 +128,7 @@
 public.archivejob                       = SELECT, INSERT, UPDATE, DELETE
 public.archivepermission                = SELECT, INSERT, UPDATE, DELETE
 public.archivesubscriber                = SELECT, INSERT, UPDATE
+public.basesnap                         = SELECT, INSERT, UPDATE, DELETE
 public.binaryandsourcepackagenameview   = SELECT
 public.binarypackagepublishinghistory   = SELECT
 public.binarypackagereleasedownloadcount= SELECT, INSERT, UPDATE
@@ -949,6 +950,7 @@
 public.archive                                = SELECT, UPDATE
 public.archivearch                            = SELECT, UPDATE
 public.archivedependency                      = SELECT
+public.basesnap                               = SELECT
 public.binarypackagebuild                     = SELECT, INSERT, UPDATE
 public.binarypackagefile                      = SELECT
 public.binarypackagename                      = SELECT
@@ -1343,6 +1345,7 @@
 public.archivefile                      = SELECT
 public.archivejob                       = SELECT, INSERT
 public.archivepermission                = SELECT
+public.basesnap                         = SELECT
 public.binarypackagebuild               = SELECT, INSERT, UPDATE
 public.binarypackagefile                = SELECT, INSERT
 public.binarypackagename                = SELECT, INSERT
@@ -2179,6 +2182,7 @@
 public.archiveauthtoken                 = SELECT, UPDATE
 public.archivepermission                = SELECT, UPDATE
 public.archivesubscriber                = SELECT, UPDATE
+public.basesnap                         = SELECT, UPDATE
 public.binarypackagepublishinghistory   = SELECT, UPDATE
 public.branch                           = SELECT, UPDATE
 public.branchmergeproposal              = SELECT, UPDATE
@@ -2316,6 +2320,7 @@
 public.accesspolicygrant                = SELECT, DELETE
 public.account                          = SELECT, DELETE
 public.answercontact                    = SELECT, DELETE
+public.basesnap                         = SELECT
 public.branch                           = SELECT, UPDATE
 public.branchjob                        = SELECT, DELETE
 public.binarypackagename                = SELECT
@@ -2553,6 +2558,7 @@
 groups=script
 public.account                          = SELECT
 public.archive                          = SELECT
+public.basesnap                         = SELECT
 public.branch                           = SELECT
 public.builder                          = SELECT
 public.buildfarmjob                     = SELECT, INSERT

=== modified file 'lib/lp/app/browser/launchpad.py'
--- lib/lp/app/browser/launchpad.py	2018-07-15 16:23:15 +0000
+++ lib/lp/app/browser/launchpad.py	2019-02-05 12:19:08 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Browser code for the launchpad application."""
@@ -157,6 +157,7 @@
 from lp.services.webapp.url import urlappend
 from lp.services.worlddata.interfaces.country import ICountrySet
 from lp.services.worlddata.interfaces.language import ILanguageSet
+from lp.snappy.interfaces.basesnap import IBaseSnapSet
 from lp.snappy.interfaces.snap import ISnapSet
 from lp.snappy.interfaces.snappyseries import ISnappySeriesSet
 from lp.soyuz.interfaces.archive import IArchiveSet
@@ -843,6 +844,7 @@
         '+announcements': IAnnouncementSet,
         'archives': IArchiveSet,
         '+services': IServiceFactory,
+        '+base-snaps': IBaseSnapSet,
         'binarypackagenames': IBinaryPackageNameSet,
         'branches': IBranchSet,
         'bugs': IMaloneApplication,

=== modified file 'lib/lp/security.py'
--- lib/lp/security.py	2018-11-18 18:30:00 +0000
+++ lib/lp/security.py	2019-02-05 12:19:08 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Security policies for using content objects."""
@@ -197,6 +197,10 @@
     ILanguage,
     ILanguageSet,
     )
+from lp.snappy.interfaces.basesnap import (
+    IBaseSnap,
+    IBaseSnapSet,
+    )
 from lp.snappy.interfaces.snap import (
     ISnap,
     ISnapBuildRequest,
@@ -3362,3 +3366,16 @@
 
 class EditSnappySeriesSet(EditByRegistryExpertsOrAdmins):
     usedfor = ISnappySeriesSet
+
+
+class ViewBaseSnap(AnonymousAuthorization):
+    """Anyone can view an `IBaseSnap`."""
+    usedfor = IBaseSnap
+
+
+class EditBaseSnap(EditByRegistryExpertsOrAdmins):
+    usedfor = IBaseSnap
+
+
+class EditBaseSnapSet(EditByRegistryExpertsOrAdmins):
+    usedfor = IBaseSnapSet

=== modified file 'lib/lp/services/webservice/wadl-to-refhtml.xsl'
--- lib/lp/services/webservice/wadl-to-refhtml.xsl	2018-08-06 15:41:10 +0000
+++ lib/lp/services/webservice/wadl-to-refhtml.xsl	2019-02-05 12:19:08 +0000
@@ -208,6 +208,10 @@
                 <xsl:text>.</xsl:text>
                 <xsl:text>[component or source package].name</xsl:text>
             </xsl:when>
+            <xsl:when test="@id = 'base_snap'">
+                <xsl:text>/+base-snaps/</xsl:text>
+                <var>&lt;name&gt;</var>
+            </xsl:when>
             <xsl:when test="@id = 'binary_package_publishing_history'">
                 <xsl:text>/</xsl:text>
                 <var>&lt;distribution.name&gt;</var>
@@ -531,6 +535,10 @@
                 <xsl:text>/+build-request/</xsl:text>
                 <var>&lt;id&gt;</var>
             </xsl:when>
+            <xsl:when test="@id = 'snappy_series'">
+                <xsl:text>/+snappy-series/</xsl:text>
+                <var>&lt;name&gt;</var>
+            </xsl:when>
             <xsl:when test="@id = 'source_package'">
                 <xsl:text>/</xsl:text>
                 <var>&lt;distribution.name&gt;</var>

=== added file 'lib/lp/snappy/browser/basesnap.py'
--- lib/lp/snappy/browser/basesnap.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/browser/basesnap.py	2019-02-05 12:19:08 +0000
@@ -0,0 +1,19 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Base snap views."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    "BaseSnapSetNavigation",
+    ]
+
+from lp.services.webapp import GetitemNavigation
+from lp.snappy.interfaces.basesnap import IBaseSnapSet
+
+
+class BaseSnapSetNavigation(GetitemNavigation):
+    """Navigation methods for `IBaseSnapSet`."""
+    usedfor = IBaseSnapSet

=== modified file 'lib/lp/snappy/browser/configure.zcml'
--- lib/lp/snappy/browser/configure.zcml	2018-06-15 13:21:14 +0000
+++ lib/lp/snappy/browser/configure.zcml	2019-02-05 12:19:08 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2015-2018 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2015-2019 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -138,6 +138,17 @@
         <browser:navigation
             module="lp.snappy.browser.snappyseries"
             classes="SnappySeriesSetNavigation" />
+        <browser:url
+            for="lp.snappy.interfaces.basesnap.IBaseSnap"
+            path_expression="name"
+            parent_utility="lp.snappy.interfaces.basesnap.IBaseSnapSet" />
+        <browser:url
+            for="lp.snappy.interfaces.basesnap.IBaseSnapSet"
+            path_expression="string:+base-snaps"
+            parent_utility="lp.services.webapp.interfaces.ILaunchpadRoot" />
+        <browser:navigation
+            module="lp.snappy.browser.basesnap"
+            classes="BaseSnapSetNavigation" />
 
         <browser:page
             for="*"

=== modified file 'lib/lp/snappy/configure.zcml'
--- lib/lp/snappy/configure.zcml	2018-08-03 13:53:20 +0000
+++ lib/lp/snappy/configure.zcml	2019-02-05 12:19:08 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2015-2018 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2015-2019 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -130,6 +130,26 @@
             interface="lp.snappy.interfaces.snappyseries.ISnappyDistroSeriesSet" />
     </securedutility>
 
+    <!-- Base snaps -->
+    <class class="lp.snappy.model.basesnap.BaseSnap">
+        <allow
+            interface="lp.snappy.interfaces.basesnap.IBaseSnapView
+                       lp.snappy.interfaces.basesnap.IBaseSnapEditableAttributes" />
+        <require
+            permission="launchpad.Edit"
+            interface="lp.snappy.interfaces.basesnap.IBaseSnapEdit"
+            set_schema="lp.snappy.interfaces.basesnap.IBaseSnapEditableAttributes" />
+    </class>
+    <securedutility
+        class="lp.snappy.model.basesnap.BaseSnapSet"
+        provides="lp.snappy.interfaces.basesnap.IBaseSnapSet">
+        <allow
+            interface="lp.snappy.interfaces.basesnap.IBaseSnapSet" />
+        <require
+            permission="launchpad.Edit"
+            interface="lp.snappy.interfaces.basesnap.IBaseSnapSetEdit" />
+    </securedutility>
+
     <!-- Store interaction -->
     <securedutility
         class="lp.snappy.model.snapstoreclient.SnapStoreClient"

=== added file 'lib/lp/snappy/interfaces/basesnap.py'
--- lib/lp/snappy/interfaces/basesnap.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/interfaces/basesnap.py	2019-02-05 12:19:08 +0000
@@ -0,0 +1,231 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Base snap interfaces."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    "BaseSnapDefaultConflict",
+    "CannotDeleteBaseSnap",
+    "IBaseSnap",
+    "IBaseSnapSet",
+    "NoSuchBaseSnap",
+    ]
+
+import httplib
+
+from lazr.restful.declarations import (
+    call_with,
+    collection_default_content,
+    error_status,
+    export_as_webservice_collection,
+    export_as_webservice_entry,
+    export_destructor_operation,
+    export_factory_operation,
+    export_read_operation,
+    export_write_operation,
+    exported,
+    operation_for_version,
+    operation_parameters,
+    operation_returns_entry,
+    REQUEST_USER,
+    )
+from lazr.restful.fields import Reference
+from zope.component import getUtility
+from zope.interface import Interface
+from zope.schema import (
+    Bool,
+    Datetime,
+    Dict,
+    Int,
+    TextLine,
+    )
+
+from lp import _
+from lp.app.errors import NameLookupFailed
+from lp.app.validators.name import name_validator
+from lp.registry.interfaces.distroseries import IDistroSeries
+from lp.services.fields import (
+    ContentNameField,
+    PublicPersonChoice,
+    Title,
+    )
+
+
+@error_status(httplib.CONFLICT)
+class BaseSnapDefaultConflict(Exception):
+    """A default base snap already exists."""
+
+
+class NoSuchBaseSnap(NameLookupFailed):
+    """The requested `BaseSnap` does not exist."""
+
+    _message_prefix = "No such base snap"
+
+
+@error_status(httplib.BAD_REQUEST)
+class CannotDeleteBaseSnap(Exception):
+    """The base snap cannot be deleted at this time."""
+
+
+class BaseSnapNameField(ContentNameField):
+    """Ensure that `IBaseSnap` has unique names."""
+
+    errormessage = _("%s is already in use by another base snap.")
+
+    @property
+    def _content_iface(self):
+        """See `UniqueField`."""
+        return IBaseSnap
+
+    def _getByName(self, name):
+        """See `ContentNameField`."""
+        try:
+            return getUtility(IBaseSnapSet).getByName(name)
+        except NoSuchBaseSnap:
+            return None
+
+
+class IBaseSnapView(Interface):
+    """`IBaseSnap` attributes that anyone can view."""
+
+    id = Int(title=_("ID"), required=True, readonly=True)
+
+    date_created = exported(Datetime(
+        title=_("Date created"), required=True, readonly=True))
+
+    registrant = exported(PublicPersonChoice(
+        title=_("Registrant"), required=True, readonly=True,
+        vocabulary="ValidPersonOrTeam",
+        description=_("The person who registered this base snap.")))
+
+    is_default = exported(Bool(
+        title=_("Is default?"), required=True, readonly=True,
+        description=_(
+            "Whether this base snap indicates the defaults used for snap "
+            "builds that do not specify a base snap.")))
+
+
+class IBaseSnapEditableAttributes(Interface):
+    """`IBaseSnap` attributes that can be edited.
+
+    Anyone can view these attributes, but they need launchpad.Edit to change.
+    """
+
+    name = exported(BaseSnapNameField(
+        title=_("Name"), required=True, readonly=False,
+        constraint=name_validator))
+
+    display_name = exported(TextLine(
+        title=_("Display name"), required=True, readonly=False))
+
+    title = Title(title=_("Title"), required=True, readonly=True)
+
+    distro_series = exported(Reference(
+        IDistroSeries, title=_("Distro series"),
+        required=True, readonly=False))
+
+    channels = exported(Dict(
+        title=_("Source snap channels"),
+        key_type=TextLine(), required=True, readonly=False,
+        description=_(
+            "A dictionary mapping snap names to channels to use when building "
+            "snaps that specify this base snap.")))
+
+
+class IBaseSnapEdit(Interface):
+    """`IBaseSnap` methods that require launchpad.Edit permission."""
+
+    def setDefault(value):
+        """Set whether this base snap is the default.
+
+        This is for internal use; the caller should ensure permission to
+        edit the base snap and should arrange to remove any existing default
+        first.  Most callers should use `IBaseSnapSet.setDefault` instead.
+
+        :param value: True if this base snap should be the default,
+            otherwise False.
+        """
+
+    @export_destructor_operation()
+    @operation_for_version("devel")
+    def destroySelf():
+        """Delete the specified base snap.
+
+        :raises CannotDeleteBaseSnap: if the base snap cannot be deleted.
+        """
+
+
+class IBaseSnap(IBaseSnapView, IBaseSnapEditableAttributes):
+    """A base snap."""
+
+    # XXX cjwatson 2019-01-28 bug=760849: "beta" is a lie to get WADL
+    # generation working.  Individual attributes must set their version to
+    # "devel".
+    export_as_webservice_entry(as_of="beta")
+
+
+class IBaseSnapSetEdit(Interface):
+    """`IBaseSnapSet` methods that require launchpad.Edit permission."""
+
+    @call_with(registrant=REQUEST_USER)
+    @export_factory_operation(
+        IBaseSnap, ["name", "display_name", "distro_series", "channels"])
+    @operation_for_version("devel")
+    def new(registrant, name, display_name, distro_series, channels,
+            date_created=None):
+        """Create an `IBaseSnap`."""
+
+    @operation_parameters(
+        base_snap=Reference(
+            title=_("Base snap"), required=True, schema=IBaseSnap))
+    @export_write_operation()
+    @operation_for_version("devel")
+    def setDefault(base_snap):
+        """Set the default base snap.
+
+        This will be used to pick the default distro series for snap builds
+        that do not specify a base.
+
+        :param base_snap: An `IBaseSnap`, or None to unset the default base
+            snap.
+        """
+
+
+class IBaseSnapSet(IBaseSnapSetEdit):
+    """Interface representing the set of base snaps."""
+
+    export_as_webservice_collection(IBaseSnap)
+
+    def __iter__():
+        """Iterate over `IBaseSnap`s."""
+
+    def __getitem__(name):
+        """Return the `IBaseSnap` with this name."""
+
+    @operation_parameters(
+        name=TextLine(title=_("Base snap name"), required=True))
+    @operation_returns_entry(IBaseSnap)
+    @export_read_operation()
+    @operation_for_version("devel")
+    def getByName(name):
+        """Return the `IBaseSnap` with this name.
+
+        :raises NoSuchBaseSnap: if no base snap exists with this name.
+        """
+
+    @operation_returns_entry(IBaseSnap)
+    @export_read_operation()
+    @operation_for_version("devel")
+    def getDefault():
+        """Get the default base snap.
+
+        This will be used to pick the default distro series for snap builds
+        that do not specify a base.
+        """
+
+    @collection_default_content()
+    def getAll():
+        """Return all `IBaseSnap`s."""

=== modified file 'lib/lp/snappy/interfaces/webservice.py'
--- lib/lp/snappy/interfaces/webservice.py	2018-06-15 13:21:14 +0000
+++ lib/lp/snappy/interfaces/webservice.py	2019-02-05 12:19:08 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """All the interfaces that are exposed through the webservice.
@@ -10,6 +10,8 @@
 """
 
 __all__ = [
+    'IBaseSnap',
+    'IBaseSnapSet',
     'ISnap',
     'ISnapBuild',
     'ISnapBuildRequest',
@@ -24,6 +26,10 @@
     patch_entry_return_type,
     patch_reference_property,
     )
+from lp.snappy.interfaces.basesnap import (
+    IBaseSnap,
+    IBaseSnapSet,
+    )
 from lp.snappy.interfaces.snap import (
     ISnap,
     ISnapBuildRequest,

=== added file 'lib/lp/snappy/model/basesnap.py'
--- lib/lp/snappy/model/basesnap.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/model/basesnap.py	2019-02-05 12:19:08 +0000
@@ -0,0 +1,147 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Base snaps."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    "BaseSnap",
+    ]
+
+import pytz
+from storm.locals import (
+    Bool,
+    DateTime,
+    Int,
+    JSON,
+    Reference,
+    Store,
+    Storm,
+    Unicode,
+    )
+from zope.component import getUtility
+from zope.interface import implementer
+
+from lp.services.database.constants import DEFAULT
+from lp.services.database.interfaces import (
+    IMasterStore,
+    IStore,
+    )
+from lp.snappy.interfaces.basesnap import (
+    BaseSnapDefaultConflict,
+    CannotDeleteBaseSnap,
+    IBaseSnap,
+    IBaseSnapSet,
+    NoSuchBaseSnap,
+    )
+
+
+@implementer(IBaseSnap)
+class BaseSnap(Storm):
+    """See `IBaseSnap`."""
+
+    __storm_table__ = "BaseSnap"
+
+    id = Int(primary=True)
+
+    date_created = DateTime(
+        name="date_created", tzinfo=pytz.UTC, allow_none=False)
+
+    registrant_id = Int(name="registrant", allow_none=False)
+    registrant = Reference(registrant_id, "Person.id")
+
+    name = Unicode(name="name", allow_none=False)
+
+    display_name = Unicode(name="display_name", allow_none=False)
+
+    distro_series_id = Int(name="distro_series", allow_none=False)
+    distro_series = Reference(distro_series_id, "DistroSeries.id")
+
+    channels = JSON(name="channels", allow_none=False)
+
+    is_default = Bool(name="is_default", allow_none=False)
+
+    def __init__(self, registrant, name, display_name, distro_series, channels,
+                 date_created=DEFAULT):
+        super(BaseSnap, self).__init__()
+        self.registrant = registrant
+        self.name = name
+        self.display_name = display_name
+        self.distro_series = distro_series
+        self.channels = channels
+        self.date_created = date_created
+        self.is_default = False
+
+    @property
+    def title(self):
+        """See `IBaseSnap`."""
+        return self.display_name
+
+    def setDefault(self, value):
+        """See `IBaseSnap`."""
+        if value:
+            # Check for an existing default.
+            existing = getUtility(IBaseSnapSet).getDefault()
+            if existing is not None and existing != self:
+                raise BaseSnapDefaultConflict(
+                    "The default base snap is already set to %s." %
+                    existing.name)
+        self.is_default = value
+
+    def destroySelf(self):
+        """See `IBaseSnap`."""
+        # Guard against unfortunate accidents.
+        if self.is_default:
+            raise CannotDeleteBaseSnap("Cannot delete the default base snap.")
+        Store.of(self).remove(self)
+
+
+@implementer(IBaseSnapSet)
+class BaseSnapSet:
+    """See `IBaseSnapSet`."""
+
+    def new(self, registrant, name, display_name, distro_series, channels,
+            date_created=DEFAULT):
+        """See `IBaseSnapSet`."""
+        store = IMasterStore(BaseSnap)
+        base_snap = BaseSnap(
+            registrant, name, display_name, distro_series, channels,
+            date_created=date_created)
+        store.add(base_snap)
+        return base_snap
+
+    def __iter__(self):
+        """See `IBaseSnapSet`."""
+        return iter(self.getAll())
+
+    def __getitem__(self, name):
+        """See `IBaseSnapSet`."""
+        return self.getByName(name)
+
+    def getByName(self, name):
+        """See `IBaseSnapSet`."""
+        base_snap = IStore(BaseSnap).find(
+            BaseSnap, BaseSnap.name == name).one()
+        if base_snap is None:
+            raise NoSuchBaseSnap(name)
+        return base_snap
+
+    def getDefault(self):
+        """See `IBaseSnapSet`."""
+        return IStore(BaseSnap).find(
+            BaseSnap, BaseSnap.is_default == True).one()
+
+    def setDefault(self, base_snap):
+        """See `IBaseSnapSet`."""
+        previous = self.getDefault()
+        if previous != base_snap:
+            if previous is not None:
+                previous.setDefault(False)
+            if base_snap is not None:
+                base_snap.setDefault(True)
+
+    def getAll(self):
+        """See `IBaseSnapSet`."""
+        return IStore(BaseSnap).find(BaseSnap).order_by(BaseSnap.name)

=== added file 'lib/lp/snappy/tests/test_basesnap.py'
--- lib/lp/snappy/tests/test_basesnap.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/tests/test_basesnap.py	2019-02-05 12:19:08 +0000
@@ -0,0 +1,274 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test base snaps."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from testtools.matchers import (
+    ContainsDict,
+    Equals,
+    Is,
+    )
+from zope.component import (
+    getAdapter,
+    getUtility,
+    )
+
+from lp.app.interfaces.security import IAuthorization
+from lp.services.webapp.interfaces import OAuthPermission
+from lp.snappy.interfaces.basesnap import (
+    CannotDeleteBaseSnap,
+    IBaseSnap,
+    IBaseSnapSet,
+    NoSuchBaseSnap,
+    )
+from lp.testing import (
+    api_url,
+    celebrity_logged_in,
+    logout,
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    ZopelessDatabaseLayer,
+    )
+from lp.testing.pages import webservice_for_person
+
+
+class TestBaseSnap(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def test_implements_interface(self):
+        # BaseSnap implements IBaseSnap.
+        base_snap = self.factory.makeBaseSnap()
+        self.assertProvides(base_snap, IBaseSnap)
+
+    def test_new_not_default(self):
+        base_snap = self.factory.makeBaseSnap()
+        self.assertFalse(base_snap.is_default)
+
+    def test_anonymous(self):
+        # Anyone can view an `IBaseSnap`.
+        base_snap = self.factory.makeBaseSnap()
+        authz = getAdapter(base_snap, IAuthorization, name="launchpad.View")
+        self.assertTrue(authz.checkUnauthenticated())
+
+    def test_destroySelf(self):
+        base_snap = self.factory.makeBaseSnap()
+        base_snap_name = base_snap.name
+        base_snap_set = getUtility(IBaseSnapSet)
+        self.assertEqual(base_snap, base_snap_set.getByName(base_snap_name))
+        base_snap.destroySelf()
+        self.assertRaises(
+            NoSuchBaseSnap, base_snap_set.getByName, base_snap_name)
+
+    def test_destroySelf_refuses_default(self):
+        base_snap = self.factory.makeBaseSnap()
+        getUtility(IBaseSnapSet).setDefault(base_snap)
+        self.assertRaises(CannotDeleteBaseSnap, base_snap.destroySelf)
+
+
+class TestBaseSnapSet(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def test_getByName(self):
+        base_snap_set = getUtility(IBaseSnapSet)
+        base_snap = self.factory.makeBaseSnap(name="foo")
+        self.factory.makeBaseSnap()
+        self.assertEqual(base_snap, base_snap_set.getByName("foo"))
+        self.assertRaises(NoSuchBaseSnap, base_snap_set.getByName, "bar")
+
+    def test_getDefault(self):
+        base_snap_set = getUtility(IBaseSnapSet)
+        base_snap = self.factory.makeBaseSnap()
+        self.factory.makeBaseSnap()
+        self.assertIsNone(base_snap_set.getDefault())
+        base_snap_set.setDefault(base_snap)
+        self.assertEqual(base_snap, base_snap_set.getDefault())
+
+    def test_setDefault(self):
+        base_snap_set = getUtility(IBaseSnapSet)
+        base_snaps = [self.factory.makeBaseSnap() for _ in range(3)]
+        base_snap_set.setDefault(base_snaps[0])
+        self.assertEqual(
+            [True, False, False],
+            [base_snap.is_default for base_snap in base_snaps])
+        base_snap_set.setDefault(base_snaps[1])
+        self.assertEqual(
+            [False, True, False],
+            [base_snap.is_default for base_snap in base_snaps])
+        base_snap_set.setDefault(None)
+        self.assertEqual(
+            [False, False, False],
+            [base_snap.is_default for base_snap in base_snaps])
+
+    def test_getAll(self):
+        base_snaps = [self.factory.makeBaseSnap() for _ in range(3)]
+        self.assertContentEqual(base_snaps, getUtility(IBaseSnapSet).getAll())
+
+
+class TestBaseSnapWebservice(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_new_unpriv(self):
+        # An unprivileged user cannot create a BaseSnap.
+        person = self.factory.makePerson()
+        distroseries = self.factory.makeDistroSeries()
+        distroseries_url = api_url(distroseries)
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        response = webservice.named_post(
+            "/+base-snaps", "new",
+            name="dummy", display_name="Dummy",
+            distro_series=distroseries_url, channels={"snapcraft": "stable"})
+        self.assertEqual(401, response.status)
+
+    def test_new(self):
+        # A registry expert can create a BaseSnap.
+        person = self.factory.makeRegistryExpert()
+        distroseries = self.factory.makeDistroSeries()
+        distroseries_url = api_url(distroseries)
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        logout()
+        response = webservice.named_post(
+            "/+base-snaps", "new",
+            name="dummy", display_name="Dummy",
+            distro_series=distroseries_url, channels={"snapcraft": "stable"})
+        self.assertEqual(201, response.status)
+        base_snap = webservice.get(response.getHeader("Location")).jsonBody()
+        with person_logged_in(person):
+            self.assertThat(base_snap, ContainsDict({
+                "registrant_link": Equals(
+                    webservice.getAbsoluteUrl(api_url(person))),
+                "name": Equals("dummy"),
+                "display_name": Equals("Dummy"),
+                "distro_series_link": Equals(
+                    webservice.getAbsoluteUrl(distroseries_url)),
+                "channels": Equals({"snapcraft": "stable"}),
+                "is_default": Is(False),
+                }))
+
+    def test_new_duplicate_name(self):
+        # An attempt to create a BaseSnap with a duplicate name is rejected.
+        person = self.factory.makeRegistryExpert()
+        distroseries = self.factory.makeDistroSeries()
+        distroseries_url = api_url(distroseries)
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        logout()
+        response = webservice.named_post(
+            "/+base-snaps", "new",
+            name="dummy", display_name="Dummy",
+            distro_series=distroseries_url, channels={"snapcraft": "stable"})
+        self.assertEqual(201, response.status)
+        response = webservice.named_post(
+            "/+base-snaps", "new",
+            name="dummy", display_name="Dummy",
+            distro_series=distroseries_url, channels={"snapcraft": "stable"})
+        self.assertEqual(400, response.status)
+        self.assertEqual(
+            "name: dummy is already in use by another base snap.",
+            response.body)
+
+    def test_getByName(self):
+        # lp.base_snaps.getByName returns a matching BaseSnap.
+        person = self.factory.makePerson()
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.READ_PUBLIC)
+        webservice.default_api_version = "devel"
+        with celebrity_logged_in("registry_experts"):
+            self.factory.makeBaseSnap(name="dummy")
+        response = webservice.named_get(
+            "/+base-snaps", "getByName", name="dummy")
+        self.assertEqual(200, response.status)
+        self.assertEqual("dummy", response.jsonBody()["name"])
+
+    def test_getByName_missing(self):
+        # lp.base_snaps.getByName returns 404 for a non-existent BaseSnap.
+        person = self.factory.makePerson()
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.READ_PUBLIC)
+        webservice.default_api_version = "devel"
+        logout()
+        response = webservice.named_get(
+            "/+base-snaps", "getByName", name="nonexistent")
+        self.assertEqual(404, response.status)
+        self.assertEqual("No such base snap: 'nonexistent'.", response.body)
+
+    def test_getDefault(self):
+        # lp.base_snaps.getDefault returns the default BaseSnap, if any.
+        person = self.factory.makePerson()
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.READ_PUBLIC)
+        webservice.default_api_version = "devel"
+        response = webservice.named_get("/+base-snaps", "getDefault")
+        self.assertEqual(200, response.status)
+        self.assertIsNone(response.jsonBody())
+        with celebrity_logged_in("registry_experts"):
+            getUtility(IBaseSnapSet).setDefault(
+                self.factory.makeBaseSnap(name="default-base"))
+            self.factory.makeBaseSnap()
+        response = webservice.named_get("/+base-snaps", "getDefault")
+        self.assertEqual(200, response.status)
+        self.assertEqual("default-base", response.jsonBody()["name"])
+
+    def test_setDefault_unpriv(self):
+        # An unprivileged user cannot set the default BaseSnap.
+        person = self.factory.makePerson()
+        with celebrity_logged_in("registry_experts"):
+            base_snap = self.factory.makeBaseSnap()
+            base_snap_url = api_url(base_snap)
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        response = webservice.named_post(
+            "/+base-snaps", "setDefault", base_snap=base_snap_url)
+        self.assertEqual(401, response.status)
+
+    def test_setDefault(self):
+        # A registry expert can set the default BaseSnap.
+        person = self.factory.makeRegistryExpert()
+        with person_logged_in(person):
+            base_snaps = [self.factory.makeBaseSnap() for _ in range(3)]
+            base_snap_urls = [api_url(base_snap) for base_snap in base_snaps]
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        response = webservice.named_post(
+            "/+base-snaps", "setDefault", base_snap=base_snap_urls[0])
+        self.assertEqual(200, response.status)
+        with person_logged_in(person):
+            self.assertEqual(
+                base_snaps[0], getUtility(IBaseSnapSet).getDefault())
+        response = webservice.named_post(
+            "/+base-snaps", "setDefault", base_snap=base_snap_urls[1])
+        self.assertEqual(200, response.status)
+        with person_logged_in(person):
+            self.assertEqual(
+                base_snaps[1], getUtility(IBaseSnapSet).getDefault())
+
+    def test_collection(self):
+        # lp.base_snaps is a collection of all BaseSnaps.
+        person = self.factory.makePerson()
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.READ_PUBLIC)
+        webservice.default_api_version = "devel"
+        with celebrity_logged_in("registry_experts"):
+            for i in range(3):
+                self.factory.makeBaseSnap(name="base-%d" % i)
+        response = webservice.get("/+base-snaps")
+        self.assertEqual(200, response.status)
+        self.assertContentEqual(
+            ["base-0", "base-1", "base-2"],
+            [entry["name"] for entry in response.jsonBody()["entries"]])

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2018-12-12 10:37:06 +0000
+++ lib/lp/testing/factory.py	2019-02-05 12:19:08 +0000
@@ -2,7 +2,7 @@
 # NOTE: The first line above must stay first; do not move the copyright
 # notice to the top.  See http://www.python.org/dev/peps/pep-0263/.
 #
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Testing infrastructure for the Launchpad application.
@@ -286,6 +286,7 @@
 from lp.services.webhooks.interfaces import IWebhookSet
 from lp.services.worlddata.interfaces.country import ICountrySet
 from lp.services.worlddata.interfaces.language import ILanguageSet
+from lp.snappy.interfaces.basesnap import IBaseSnapSet
 from lp.snappy.interfaces.snap import ISnapSet
 from lp.snappy.interfaces.snapbuild import ISnapBuildSet
 from lp.snappy.interfaces.snappyseries import ISnappySeriesSet
@@ -4843,6 +4844,24 @@
         IStore(snappy_series).flush()
         return snappy_series
 
+    def makeBaseSnap(self, registrant=None, name=None, display_name=None,
+                     distro_series=None, channels=None, date_created=DEFAULT):
+        """Make a new BaseSnap."""
+        if registrant is None:
+            registrant = self.makePerson()
+        if name is None:
+            name = self.getUniqueString(u"base-snap-name")
+        if display_name is None:
+            display_name = SPACE.join(
+                word.capitalize() for word in name.split('-'))
+        if distro_series is None:
+            distro_series = self.makeDistroSeries()
+        if channels is None:
+            channels = {u"snapcraft": u"stable"}
+        return getUtility(IBaseSnapSet).new(
+            registrant, name, display_name, distro_series, channels,
+            date_created=date_created)
+
 
 # Some factory methods return simple Python types. We don't add
 # security wrappers for them, as well as for objects created by


Follow ups