← Back to team overview

launchpad-reviewers team mailing list archive

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

 

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

Commit message:
Add SnapSeries and SnapDistroSeries models.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1572605 in Launchpad itself: "Automatically upload snap builds to store"
  https://bugs.launchpad.net/launchpad/+bug/1572605

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

Add SnapSeries and SnapDistroSeries models.  This is preparation for being able to upload snap builds to the store, for which Launchpad needs to have a basic idea of the store's view of series.  SnapDistroSeries exists so that for a snap based on a given DistroSeries we can have some idea of which SnapSeries are legitimate targets.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-series into lp:launchpad.
=== modified file 'lib/lp/app/browser/launchpad.py'
--- lib/lp/app/browser/launchpad.py	2015-08-06 00:46:39 +0000
+++ lib/lp/app/browser/launchpad.py	2016-04-21 13:21:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2014 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2016 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."""
@@ -158,6 +158,7 @@
 from lp.services.worlddata.interfaces.country import ICountrySet
 from lp.services.worlddata.interfaces.language import ILanguageSet
 from lp.snappy.interfaces.snap import ISnapSet
+from lp.snappy.interfaces.snapseries import ISnapSeriesSet
 from lp.soyuz.interfaces.archive import IArchiveSet
 from lp.soyuz.interfaces.binarypackagename import IBinaryPackageNameSet
 from lp.soyuz.interfaces.livefs import ILiveFSSet
@@ -803,6 +804,7 @@
         'projects': IProductSet,
         'projectgroups': IProjectGroupSet,
         '+snaps': ISnapSet,
+        '+snap-series': ISnapSeriesSet,
         'sourcepackagenames': ISourcePackageNameSet,
         'specs': ISpecificationSet,
         'sprints': ISprintSet,

=== modified file 'lib/lp/security.py'
--- lib/lp/security.py	2016-04-12 10:50:30 +0000
+++ lib/lp/security.py	2016-04-21 13:21:13 +0000
@@ -194,6 +194,10 @@
     )
 from lp.snappy.interfaces.snap import ISnap
 from lp.snappy.interfaces.snapbuild import ISnapBuild
+from lp.snappy.interfaces.snapseries import (
+    ISnapSeries,
+    ISnapSeriesSet,
+    )
 from lp.soyuz.interfaces.archive import IArchive
 from lp.soyuz.interfaces.archiveauthtoken import IArchiveAuthToken
 from lp.soyuz.interfaces.archivepermission import IArchivePermissionSet
@@ -3191,3 +3195,11 @@
 
 class AdminSnapBuild(AdminByBuilddAdmin):
     usedfor = ISnapBuild
+
+
+class EditSnapSeries(EditByRegistryExpertsOrAdmins):
+    usedfor = ISnapSeries
+
+
+class EditSnapSeriesSet(EditByRegistryExpertsOrAdmins):
+    usedfor = ISnapSeriesSet

=== modified file 'lib/lp/snappy/browser/configure.zcml'
--- lib/lp/snappy/browser/configure.zcml	2016-02-28 17:12:41 +0000
+++ lib/lp/snappy/browser/configure.zcml	2016-04-21 13:21:13 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2015 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -117,6 +117,19 @@
             for="lp.snappy.interfaces.snapbuild.ISnapBuild"
             factory="lp.services.webapp.breadcrumb.TitleBreadcrumb"
             permission="zope.Public" />
+        <browser:url
+            for="lp.snappy.interfaces.snapseries.ISnapSeries"
+            path_expression="name"
+            parent_utility="lp.snappy.interfaces.snapseries.ISnapSeriesSet" />
+        <browser:url
+            for="lp.snappy.interfaces.snapseries.ISnapSeriesSet"
+            path_expression="string:+snap-series"
+            parent_utility="lp.services.webapp.interfaces.ILaunchpadRoot" />
+        <browser:navigation
+            module="lp.snappy.browser.snapseries"
+            classes="
+                SnapSeriesNavigation
+                SnapSeriesSetNavigation" />
 
         <browser:page
             for="*"

=== added file 'lib/lp/snappy/browser/snapseries.py'
--- lib/lp/snappy/browser/snapseries.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/browser/snapseries.py	2016-04-21 13:21:13 +0000
@@ -0,0 +1,31 @@
+# Copyright 2016 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""SnapSeries views."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'SnapSeriesNavigation',
+    'SnapSeriesSetNavigation',
+    ]
+
+from lp.services.webapp import (
+    GetitemNavigation,
+    Navigation,
+    )
+from lp.snappy.interfaces.snapseries import (
+    ISnapSeries,
+    ISnapSeriesSet,
+    )
+
+
+class SnapSeriesSetNavigation(GetitemNavigation):
+    """Navigation methods for `ISnapSeriesSet`."""
+    usedfor = ISnapSeriesSet
+
+
+class SnapSeriesNavigation(Navigation):
+    """Navigation methods for `ISnapSeries`."""
+    usedfor = ISnapSeries

=== modified file 'lib/lp/snappy/configure.zcml'
--- lib/lp/snappy/configure.zcml	2016-01-19 17:41:11 +0000
+++ lib/lp/snappy/configure.zcml	2016-04-21 13:21:13 +0000
@@ -82,6 +82,27 @@
         <allow interface="lp.snappy.interfaces.snapbuild.ISnapFile" />
     </class>
 
+    <!-- SnapSeries -->
+    <class class="lp.snappy.model.snapseries.SnapSeries">
+        <allow
+            interface="lp.snappy.interfaces.snapseries.ISnapSeriesView
+                       lp.snappy.interfaces.snapseries.ISnapSeriesEditableAttributes" />
+        <require
+            permission="launchpad.Edit"
+            set_schema="lp.snappy.interfaces.snapseries.ISnapSeriesEditableAttributes" />
+    </class>
+
+    <!-- SnapSeriesSet -->
+    <securedutility
+        class="lp.snappy.model.snapseries.SnapSeriesSet"
+        provides="lp.snappy.interfaces.snapseries.ISnapSeriesSet">
+        <allow
+            interface="lp.snappy.interfaces.snapseries.ISnapSeriesSet" />
+        <require
+            permission="launchpad.Edit"
+            interface="lp.snappy.interfaces.snapseries.ISnapSeriesSetEdit" />
+    </securedutility>
+
     <webservice:register module="lp.snappy.interfaces.webservice" />
 
 </configure>

=== added file 'lib/lp/snappy/interfaces/snapseries.py'
--- lib/lp/snappy/interfaces/snapseries.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/interfaces/snapseries.py	2016-04-21 13:21:13 +0000
@@ -0,0 +1,168 @@
+# Copyright 2016 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Snap series interfaces."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'ISnapSeries',
+    'ISnapSeriesSet',
+    'NoSuchSnapSeries',
+    ]
+
+from lazr.restful.declarations import (
+    call_with,
+    collection_default_content,
+    export_as_webservice_collection,
+    export_as_webservice_entry,
+    export_factory_operation,
+    export_read_operation,
+    exported,
+    operation_for_version,
+    operation_parameters,
+    operation_returns_collection_of,
+    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 (
+    Choice,
+    Datetime,
+    Int,
+    List,
+    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.registry.interfaces.series import SeriesStatus
+from lp.services.fields import (
+    ContentNameField,
+    PublicPersonChoice,
+    Title,
+    )
+
+
+class NoSuchSnapSeries(NameLookupFailed):
+    """The requested `SnapSeries` does not exist."""
+
+    _message_prefix = "No such snap series"
+
+
+class SnapSeriesNameField(ContentNameField):
+    """Ensure that `ISnapSeries` has unique names."""
+
+    errormessage = _("%s is already in use by another series.")
+
+    @property
+    def _content_iface(self):
+        """See `UniqueField`."""
+        return ISnapSeries
+
+    def _getByName(self, name):
+        """See `ContentNameField`."""
+        try:
+            return getUtility(ISnapSeriesSet).getByName(name)
+        except NoSuchSnapSeries:
+            return None
+
+
+class ISnapSeriesView(Interface):
+    """`ISnapSeries` attributes that require launchpad.View permission."""
+
+    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 snap package.")))
+
+
+class ISnapSeriesEditableAttributes(Interface):
+    """`ISnapSeries` attributes that can be edited.
+
+    These attributes need launchpad.View to see, and launchpad.Edit to change.
+    """
+
+    name = exported(SnapSeriesNameField(
+        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)
+
+    status = exported(Choice(
+        title=_("Status"), required=True, vocabulary=SeriesStatus))
+
+    usable_distro_series = exported(List(
+        title=_("Usable distro series"),
+        description=_(
+            "The distro series that can be used for this snap series."),
+        value_type=Reference(schema=IDistroSeries),
+        required=True, readonly=False))
+
+
+class ISnapSeries(ISnapSeriesView, ISnapSeriesEditableAttributes):
+    """A series for snap packages in the store."""
+
+    # XXX cjwatson 2016-04-13 bug=760849: "beta" is a lie to get WADL
+    # generation working.  Individual attributes must set their version to
+    # "devel".
+    export_as_webservice_entry(plural_name="snap_serieses", as_of="beta")
+
+
+class ISnapSeriesSetEdit(Interface):
+    """`ISnapSeriesSet` methods that require launchpad.Edit permission."""
+
+    @call_with(registrant=REQUEST_USER)
+    @export_factory_operation(ISnapSeries, ["name", "display_name", "status"])
+    @operation_for_version("devel")
+    def new(registrant, name, display_name, status, date_created=None):
+        """Create an `ISnapSeries`."""
+
+
+class ISnapSeriesSet(ISnapSeriesSetEdit):
+    """Interface representing the set of snap series."""
+
+    export_as_webservice_collection(ISnapSeries)
+
+    def __iter__():
+        """Iterate over `ISnapSeries`."""
+
+    def __getitem__(name):
+        """Return the `ISnapSeries` with this name."""
+
+    @operation_parameters(
+        name=TextLine(title=_("Snap series name"), required=True))
+    @operation_returns_entry(ISnapSeries)
+    @export_read_operation()
+    @operation_for_version("devel")
+    def getByName(name):
+        """Return the `ISnapSeries` with this name.
+
+        :raises NoSuchSnapSeries: if no snap series exists with this name.
+        """
+
+    @operation_parameters(
+        distro_series=Reference(
+            IDistroSeries, title=_("Distro series"), required=True))
+    @operation_returns_collection_of(ISnapSeries)
+    @export_read_operation()
+    @operation_for_version("devel")
+    def getByDistroSeries(distro_series):
+        """Return all `ISnapSeries` usable with this `IDistroSeries`."""
+
+    @collection_default_content()
+    def getAll():
+        """Return all `ISnapSeries`."""

=== modified file 'lib/lp/snappy/interfaces/webservice.py'
--- lib/lp/snappy/interfaces/webservice.py	2015-08-04 23:52:48 +0000
+++ lib/lp/snappy/interfaces/webservice.py	2016-04-21 13:21:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2016 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.
@@ -12,6 +12,8 @@
 __all__ = [
     'ISnap',
     'ISnapBuild',
+    'ISnapSeries',
+    'ISnapSeriesSet',
     'ISnapSet',
     ]
 
@@ -29,6 +31,10 @@
     ISnapBuild,
     ISnapFile,
     )
+from lp.snappy.interfaces.snapseries import (
+    ISnapSeries,
+    ISnapSeriesSet,
+    )
 
 
 # ISnapFile

=== added file 'lib/lp/snappy/model/snapseries.py'
--- lib/lp/snappy/model/snapseries.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/model/snapseries.py	2016-04-21 13:21:13 +0000
@@ -0,0 +1,149 @@
+# Copyright 2016 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Snap series."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'SnapSeries',
+    ]
+
+import pytz
+from storm.locals import (
+    DateTime,
+    Int,
+    Reference,
+    Store,
+    Storm,
+    Unicode,
+    )
+from zope.interface import implementer
+
+from lp.registry.interfaces.series import SeriesStatus
+from lp.registry.model.distroseries import DistroSeries
+from lp.services.database.constants import DEFAULT
+from lp.services.database.enumcol import EnumCol
+from lp.services.database.interfaces import (
+    IMasterStore,
+    IStore,
+    )
+from lp.snappy.interfaces.snapseries import (
+    ISnapSeries,
+    ISnapSeriesSet,
+    NoSuchSnapSeries,
+    )
+
+
+@implementer(ISnapSeries)
+class SnapSeries(Storm):
+    """See `ISnapSeries`."""
+
+    __storm_table__ = 'SnapSeries'
+
+    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)
+
+    status = EnumCol(enum=SeriesStatus, notNull=True)
+
+    def __init__(self, registrant, name, display_name, status,
+                 date_created=DEFAULT):
+        super(SnapSeries, self).__init__()
+        self.registrant = registrant
+        self.name = name
+        self.display_name = display_name
+        self.status = status
+        self.date_created = date_created
+
+    @property
+    def title(self):
+        return self.display_name
+
+    @property
+    def usable_distro_series(self):
+        rows = IStore(DistroSeries).find(
+            DistroSeries,
+            SnapDistroSeries.snap_series == self,
+            SnapDistroSeries.distro_series_id == DistroSeries.id)
+        return rows.order_by(DistroSeries.id)
+
+    @usable_distro_series.setter
+    def usable_distro_series(self, value):
+        enablements = dict(Store.of(self).find(
+            (DistroSeries, SnapDistroSeries),
+            SnapDistroSeries.snap_series == self,
+            SnapDistroSeries.distro_series_id == DistroSeries.id))
+        for distro_series in enablements:
+            if distro_series not in value:
+                Store.of(self).remove(enablements[distro_series])
+        for distro_series in value:
+            if distro_series not in enablements:
+                link = SnapDistroSeries()
+                link.snap_series = self
+                link.distro_series = distro_series
+                Store.of(self).add(link)
+
+
+class SnapDistroSeries(Storm):
+    """Link table between `SnapSeries` and `DistroSeries`."""
+
+    __storm_table__ = 'SnapDistroSeries'
+    __storm_primary__ = ('snap_series_id', 'distro_series_id')
+
+    snap_series_id = Int(name='snap_series', allow_none=False)
+    snap_series = Reference(snap_series_id, 'SnapSeries.id')
+
+    distro_series_id = Int(name='distro_series', allow_none=False)
+    distro_series = Reference(distro_series_id, 'DistroSeries.id')
+
+
+@implementer(ISnapSeriesSet)
+class SnapSeriesSet:
+    """See `ISnapSeriesSet`."""
+
+    def new(self, registrant, name, display_name, status,
+            date_created=DEFAULT):
+        """See `ISnapSeriesSet`."""
+        store = IMasterStore(SnapSeries)
+        snap_series = SnapSeries(
+            registrant, name, display_name, status, date_created=date_created)
+        store.add(snap_series)
+        return snap_series
+
+    def __iter__(self):
+        """See `ISnapSeriesSet`."""
+        return iter(self.getAll())
+
+    def __getitem__(self, name):
+        """See `ISnapSeriesSet`."""
+        return self.getByName(name)
+
+    def getByName(self, name):
+        """See `ISnapSeriesSet`."""
+        snap_series = IStore(SnapSeries).find(
+            SnapSeries, SnapSeries.name == name).one()
+        if snap_series is None:
+            raise NoSuchSnapSeries(name)
+        return snap_series
+
+    def getByDistroSeries(self, distro_series):
+        """See `ISnapSeriesSet`."""
+        rows = IStore(SnapSeries).find(
+            SnapSeries,
+            SnapDistroSeries.snap_series_id == SnapSeries.id,
+            SnapDistroSeries.distro_series == distro_series)
+        return rows.order_by(SnapSeries.name)
+
+    def getAll(self):
+        """See `ISnapSeriesSet`."""
+        return IStore(SnapSeries).find(SnapSeries).order_by(SnapSeries.name)

=== added file 'lib/lp/snappy/tests/test_snapseries.py'
--- lib/lp/snappy/tests/test_snapseries.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/tests/test_snapseries.py	2016-04-21 13:21:13 +0000
@@ -0,0 +1,221 @@
+# Copyright 2016 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test snap series."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from zope.component import getUtility
+
+from lp.services.features.testing import FeatureFixture
+from lp.services.webapp.interfaces import OAuthPermission
+from lp.snappy.interfaces.snap import SNAP_TESTING_FLAGS
+from lp.snappy.interfaces.snapseries import (
+    ISnapSeries,
+    ISnapSeriesSet,
+    NoSuchSnapSeries,
+    )
+from lp.testing import (
+    admin_logged_in,
+    api_url,
+    logout,
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    ZopelessDatabaseLayer,
+    )
+from lp.testing.pages import webservice_for_person
+
+
+class TestSnapSeries(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def setUp(self):
+        super(TestSnapSeries, self).setUp()
+        self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
+
+    def test_implements_interface(self):
+        # SnapSeries implements ISnapSeries.
+        snap_series = self.factory.makeSnapSeries()
+        self.assertProvides(snap_series, ISnapSeries)
+
+    def test_new_no_usable_distro_series(self):
+        snap_series = self.factory.makeSnapSeries()
+        self.assertContentEqual([], snap_series.usable_distro_series)
+
+    def test_set_usable_distro_series(self):
+        dses = [self.factory.makeDistroSeries() for _ in range(3)]
+        snap_series = self.factory.makeSnapSeries()
+        snap_series.usable_distro_series = [dses[0]]
+        self.assertContentEqual([dses[0]], snap_series.usable_distro_series)
+        snap_series.usable_distro_series = dses
+        self.assertContentEqual(dses, snap_series.usable_distro_series)
+        snap_series.usable_distro_series = []
+        self.assertContentEqual([], snap_series.usable_distro_series)
+
+
+class TestSnapSeriesSet(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def setUp(self):
+        super(TestSnapSeriesSet, self).setUp()
+        self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
+
+    def test_getByName(self):
+        snap_series = self.factory.makeSnapSeries(name="foo")
+        self.factory.makeSnapSeries()
+        snap_series_set = getUtility(ISnapSeriesSet)
+        self.assertEqual(snap_series, snap_series_set.getByName("foo"))
+        self.assertRaises(NoSuchSnapSeries, snap_series_set.getByName, "bar")
+
+    def test_getByDistroSeries(self):
+        dses = [self.factory.makeDistroSeries() for _ in range(3)]
+        snap_serieses = [self.factory.makeSnapSeries() for _ in range(3)]
+        snap_serieses[0].usable_distro_series = dses
+        snap_serieses[1].usable_distro_series = [dses[0], dses[1]]
+        snap_serieses[2].usable_distro_series = [dses[1], dses[2]]
+        snap_series_set = getUtility(ISnapSeriesSet)
+        self.assertContentEqual(
+            [snap_serieses[0], snap_serieses[1]],
+            snap_series_set.getByDistroSeries(dses[0]))
+        self.assertContentEqual(
+            snap_serieses, snap_series_set.getByDistroSeries(dses[1]))
+        self.assertContentEqual(
+            [snap_serieses[0], snap_serieses[2]],
+            snap_series_set.getByDistroSeries(dses[2]))
+
+    def test_getAll(self):
+        snap_serieses = [self.factory.makeSnapSeries() for _ in range(3)]
+        self.assertContentEqual(
+            snap_serieses, getUtility(ISnapSeriesSet).getAll())
+
+
+class TestSnapSeriesWebservice(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestSnapSeriesWebservice, self).setUp()
+        self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
+
+    def test_new_unpriv(self):
+        # An unprivileged user cannot create a SnapSeries.
+        person = self.factory.makePerson()
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        response = webservice.named_post(
+            "/+snap-series", "new",
+            name="dummy", display_name="dummy", status="Experimental")
+        self.assertEqual(401, response.status)
+
+    def test_new(self):
+        # A registry expert can create a SnapSeries.
+        person = self.factory.makeRegistryExpert()
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        logout()
+        response = webservice.named_post(
+            "/+snap-series", "new",
+            name="dummy", display_name="Dummy", status="Experimental")
+        self.assertEqual(201, response.status)
+        snap_series = webservice.get(response.getHeader("Location")).jsonBody()
+        with person_logged_in(person):
+            self.assertEqual(
+                webservice.getAbsoluteUrl(api_url(person)),
+                snap_series["registrant_link"])
+            self.assertEqual("dummy", snap_series["name"])
+            self.assertEqual("Dummy", snap_series["display_name"])
+            self.assertEqual("Experimental", snap_series["status"])
+
+    def test_new_duplicate_name(self):
+        # An attempt to create a SnapSeries with a duplicate name is rejected.
+        person = self.factory.makeRegistryExpert()
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        logout()
+        response = webservice.named_post(
+            "/+snap-series", "new",
+            name="dummy", display_name="Dummy", status="Experimental")
+        self.assertEqual(201, response.status)
+        response = webservice.named_post(
+            "/+snap-series", "new",
+            name="dummy", display_name="Dummy", status="Experimental")
+        self.assertEqual(400, response.status)
+        self.assertEqual(
+            "name: dummy is already in use by another series.", response.body)
+
+    def test_getByName(self):
+        # lp.snap_series.getByName returns a matching SnapSeries.
+        person = self.factory.makePerson()
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.READ_PUBLIC)
+        webservice.default_api_version = "devel"
+        with admin_logged_in():
+            self.factory.makeSnapSeries(name="dummy")
+        response = webservice.named_get(
+            "/+snap-series", "getByName", name="dummy")
+        self.assertEqual(200, response.status)
+        self.assertEqual("dummy", response.jsonBody()["name"])
+
+    def test_getByName_missing(self):
+        # lp.snap_series.getByName returns 404 for a non-existent SnapSeries.
+        person = self.factory.makePerson()
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.READ_PUBLIC)
+        webservice.default_api_version = "devel"
+        logout()
+        response = webservice.named_get(
+            "/+snap-series", "getByName", name="nonexistent")
+        self.assertEqual(404, response.status)
+        self.assertEqual("No such snap series: 'nonexistent'.", response.body)
+
+    def test_getByDistroSeries(self):
+        # lp.snap_series.getByDistroSeries returns a collection of matching
+        # SnapSeries.
+        person = self.factory.makePerson()
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.READ_PUBLIC)
+        webservice.default_api_version = "devel"
+        with admin_logged_in():
+            dses = [self.factory.makeDistroSeries() for _ in range(3)]
+            ds_urls = [api_url(ds) for ds in dses]
+            snap_serieses = [
+                self.factory.makeSnapSeries(name="ss-%d" % i)
+                for i in range(3)]
+            snap_serieses[0].usable_distro_series = dses
+            snap_serieses[1].usable_distro_series = [dses[0], dses[1]]
+            snap_serieses[2].usable_distro_series = [dses[1], dses[2]]
+        for ds_url, expected_snap_series_names in (
+                (ds_urls[0], ["ss-0", "ss-1"]),
+                (ds_urls[1], ["ss-0", "ss-1", "ss-2"]),
+                (ds_urls[2], ["ss-0", "ss-2"])):
+            response = webservice.named_get(
+                "/+snap-series", "getByDistroSeries", distro_series=ds_url)
+            self.assertEqual(200, response.status)
+            self.assertContentEqual(
+                expected_snap_series_names,
+                [entry["name"] for entry in response.jsonBody()["entries"]])
+
+    def test_collection(self):
+        # lp.snap_series is a collection of all SnapSeries.
+        person = self.factory.makePerson()
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.READ_PUBLIC)
+        webservice.default_api_version = "devel"
+        with admin_logged_in():
+            for i in range(3):
+                self.factory.makeSnapSeries(name="ss-%d" % i)
+        response = webservice.get("/+snap-series")
+        self.assertEqual(200, response.status)
+        self.assertContentEqual(
+            ["ss-0", "ss-1", "ss-2"],
+            [entry["name"] for entry in response.jsonBody()["entries"]])

=== modified file 'lib/lp/snappy/vocabularies.py'
--- lib/lp/snappy/vocabularies.py	2015-09-18 14:14:34 +0000
+++ lib/lp/snappy/vocabularies.py	2016-04-21 13:21:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015 Canonical Ltd.  This software is licensed under the GNU
+# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the GNU
 # Affero General Public License version 3 (see the file LICENSE).
 
 """Snappy vocabularies."""
@@ -7,11 +7,14 @@
 
 __all__ = [
     'SnapDistroArchSeriesVocabulary',
+    'SnapSeriesVocabulary',
     ]
 
 from zope.schema.vocabulary import SimpleTerm
 
+from lp.registry.model.series import ACTIVE_STATUSES
 from lp.services.webapp.vocabulary import StormVocabularyBase
+from lp.snappy.model.snapseries import SnapSeries
 from lp.soyuz.model.distroarchseries import DistroArchSeries
 
 
@@ -29,3 +32,10 @@
 
     def __len__(self):
         return len(self.context.getAllowedArchitectures())
+
+
+class SnapSeriesVocabulary(StormVocabularyBase):
+    """A vocabulary for searching snap series."""
+
+    _table = SnapSeries
+    _clauses = [SnapSeries.status.is_in(ACTIVE_STATUSES)]

=== modified file 'lib/lp/snappy/vocabularies.zcml'
--- lib/lp/snappy/vocabularies.zcml	2015-09-18 13:32:09 +0000
+++ lib/lp/snappy/vocabularies.zcml	2016-04-21 13:21:13 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2015 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -15,4 +15,15 @@
         <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
     </class>
 
+    <securedutility
+        name="SnapSeries"
+        component="lp.snappy.vocabularies.SnapSeriesVocabulary"
+        provides="zope.schema.interfaces.IVocabularyFactory">
+        <allow interface="zope.schema.interfaces.IVocabularyFactory" />
+    </securedutility>
+
+    <class class="lp.snappy.vocabularies.SnapSeriesVocabulary">
+        <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
+    </class>
+
 </configure>

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2016-04-04 10:06:33 +0000
+++ lib/lp/testing/factory.py	2016-04-21 13:21:13 +0000
@@ -275,6 +275,7 @@
 from lp.services.worlddata.interfaces.language import ILanguageSet
 from lp.snappy.interfaces.snap import ISnapSet
 from lp.snappy.interfaces.snapbuild import ISnapBuildSet
+from lp.snappy.interfaces.snapseries import ISnapSeriesSet
 from lp.snappy.model.snapbuild import SnapFile
 from lp.soyuz.adapters.overrides import SourceOverride
 from lp.soyuz.adapters.packagelocation import PackageLocation
@@ -4672,6 +4673,21 @@
         return ProxyFactory(
             SnapFile(snapbuild=snapbuild, libraryfile=libraryfile))
 
+    def makeSnapSeries(self, registrant=None, name=None, display_name=None,
+                       status=SeriesStatus.DEVELOPMENT, date_created=DEFAULT):
+        """Make a new SnapSeries."""
+        if registrant is None:
+            registrant = self.makePerson()
+        if name is None:
+            name = self.getUniqueString(u"snap-series-name")
+        if display_name is None:
+            display_name = SPACE.join(
+                word.capitalize() for word in name.split('-'))
+        snap_series = getUtility(ISnapSeriesSet).new(
+            registrant, name, display_name, status, date_created=date_created)
+        IStore(snap_series).flush()
+        return snap_series
+
 
 # Some factory methods return simple Python types. We don't add
 # security wrappers for them, as well as for objects created by


Follow ups