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

Proposed by Colin Watson on 2016-04-21
Status: Merged
Merged at revision: 18027
Proposed branch: lp:~cjwatson/launchpad/snap-series
Merge into: lp:launchpad
Diff against target: 1041 lines (+877/-5)
12 files modified
lib/lp/app/browser/launchpad.py (+3/-1)
lib/lp/security.py (+12/-0)
lib/lp/snappy/browser/configure.zcml (+12/-1)
lib/lp/snappy/browser/snappyseries.py (+19/-0)
lib/lp/snappy/configure.zcml (+35/-0)
lib/lp/snappy/interfaces/snappyseries.py (+192/-0)
lib/lp/snappy/interfaces/webservice.py (+7/-1)
lib/lp/snappy/model/snappyseries.py (+184/-0)
lib/lp/snappy/tests/test_snappyseries.py (+279/-0)
lib/lp/snappy/vocabularies.py (+81/-1)
lib/lp/snappy/vocabularies.zcml (+34/-1)
lib/lp/testing/factory.py (+19/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/snap-series
Reviewer Review Type Date Requested Status
William Grant code 2016-04-21 Approve on 2016-05-05
Review via email: mp+292516@code.launchpad.net

Commit message

Add SnapSeries and SnapDistroSeries models.

Description of the change

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.

To post a comment you must log in.
William Grant (wgrant) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/app/browser/launchpad.py'
2--- lib/lp/app/browser/launchpad.py 2016-04-11 06:38:48 +0000
3+++ lib/lp/app/browser/launchpad.py 2016-05-09 13:24:49 +0000
4@@ -1,4 +1,4 @@
5-# Copyright 2009-2014 Canonical Ltd. This software is licensed under the
6+# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
7 # GNU Affero General Public License version 3 (see the file LICENSE).
8
9 """Browser code for the launchpad application."""
10@@ -158,6 +158,7 @@
11 from lp.services.worlddata.interfaces.country import ICountrySet
12 from lp.services.worlddata.interfaces.language import ILanguageSet
13 from lp.snappy.interfaces.snap import ISnapSet
14+from lp.snappy.interfaces.snappyseries import ISnappySeriesSet
15 from lp.soyuz.interfaces.archive import IArchiveSet
16 from lp.soyuz.interfaces.binarypackagename import IBinaryPackageNameSet
17 from lp.soyuz.interfaces.livefs import ILiveFSSet
18@@ -803,6 +804,7 @@
19 'projects': IProductSet,
20 'projectgroups': IProjectGroupSet,
21 '+snaps': ISnapSet,
22+ '+snappy-series': ISnappySeriesSet,
23 'sourcepackagenames': ISourcePackageNameSet,
24 'specs': ISpecificationSet,
25 'sprints': ISprintSet,
26
27=== modified file 'lib/lp/security.py'
28--- lib/lp/security.py 2016-04-12 10:50:30 +0000
29+++ lib/lp/security.py 2016-05-09 13:24:49 +0000
30@@ -194,6 +194,10 @@
31 )
32 from lp.snappy.interfaces.snap import ISnap
33 from lp.snappy.interfaces.snapbuild import ISnapBuild
34+from lp.snappy.interfaces.snappyseries import (
35+ ISnappySeries,
36+ ISnappySeriesSet,
37+ )
38 from lp.soyuz.interfaces.archive import IArchive
39 from lp.soyuz.interfaces.archiveauthtoken import IArchiveAuthToken
40 from lp.soyuz.interfaces.archivepermission import IArchivePermissionSet
41@@ -3191,3 +3195,11 @@
42
43 class AdminSnapBuild(AdminByBuilddAdmin):
44 usedfor = ISnapBuild
45+
46+
47+class EditSnappySeries(EditByRegistryExpertsOrAdmins):
48+ usedfor = ISnappySeries
49+
50+
51+class EditSnappySeriesSet(EditByRegistryExpertsOrAdmins):
52+ usedfor = ISnappySeriesSet
53
54=== modified file 'lib/lp/snappy/browser/configure.zcml'
55--- lib/lp/snappy/browser/configure.zcml 2016-02-28 17:12:41 +0000
56+++ lib/lp/snappy/browser/configure.zcml 2016-05-09 13:24:49 +0000
57@@ -1,4 +1,4 @@
58-<!-- Copyright 2015 Canonical Ltd. This software is licensed under the
59+<!-- Copyright 2015-2016 Canonical Ltd. This software is licensed under the
60 GNU Affero General Public License version 3 (see the file LICENSE).
61 -->
62
63@@ -117,6 +117,17 @@
64 for="lp.snappy.interfaces.snapbuild.ISnapBuild"
65 factory="lp.services.webapp.breadcrumb.TitleBreadcrumb"
66 permission="zope.Public" />
67+ <browser:url
68+ for="lp.snappy.interfaces.snappyseries.ISnappySeries"
69+ path_expression="name"
70+ parent_utility="lp.snappy.interfaces.snappyseries.ISnappySeriesSet" />
71+ <browser:url
72+ for="lp.snappy.interfaces.snappyseries.ISnappySeriesSet"
73+ path_expression="string:+snappy-series"
74+ parent_utility="lp.services.webapp.interfaces.ILaunchpadRoot" />
75+ <browser:navigation
76+ module="lp.snappy.browser.snappyseries"
77+ classes="SnappySeriesSetNavigation" />
78
79 <browser:page
80 for="*"
81
82=== added file 'lib/lp/snappy/browser/snappyseries.py'
83--- lib/lp/snappy/browser/snappyseries.py 1970-01-01 00:00:00 +0000
84+++ lib/lp/snappy/browser/snappyseries.py 2016-05-09 13:24:49 +0000
85@@ -0,0 +1,19 @@
86+# Copyright 2016 Canonical Ltd. This software is licensed under the
87+# GNU Affero General Public License version 3 (see the file LICENSE).
88+
89+"""SnappySeries views."""
90+
91+from __future__ import absolute_import, print_function, unicode_literals
92+
93+__metaclass__ = type
94+__all__ = [
95+ 'SnappySeriesSetNavigation',
96+ ]
97+
98+from lp.services.webapp import GetitemNavigation
99+from lp.snappy.interfaces.snappyseries import ISnappySeriesSet
100+
101+
102+class SnappySeriesSetNavigation(GetitemNavigation):
103+ """Navigation methods for `ISnappySeriesSet`."""
104+ usedfor = ISnappySeriesSet
105
106=== modified file 'lib/lp/snappy/configure.zcml'
107--- lib/lp/snappy/configure.zcml 2016-01-19 17:41:11 +0000
108+++ lib/lp/snappy/configure.zcml 2016-05-09 13:24:49 +0000
109@@ -82,6 +82,41 @@
110 <allow interface="lp.snappy.interfaces.snapbuild.ISnapFile" />
111 </class>
112
113+ <!-- SnappySeries -->
114+ <class class="lp.snappy.model.snappyseries.SnappySeries">
115+ <allow
116+ interface="lp.snappy.interfaces.snappyseries.ISnappySeriesView
117+ lp.snappy.interfaces.snappyseries.ISnappySeriesEditableAttributes" />
118+ <require
119+ permission="launchpad.Edit"
120+ set_schema="lp.snappy.interfaces.snappyseries.ISnappySeriesEditableAttributes" />
121+ </class>
122+
123+ <!-- SnappyDistroSeries -->
124+ <class class="lp.snappy.model.snappyseries.SnappyDistroSeries">
125+ <allow
126+ interface="lp.snappy.interfaces.snappyseries.ISnappyDistroSeries" />
127+ </class>
128+
129+ <!-- SnappySeriesSet -->
130+ <securedutility
131+ class="lp.snappy.model.snappyseries.SnappySeriesSet"
132+ provides="lp.snappy.interfaces.snappyseries.ISnappySeriesSet">
133+ <allow
134+ interface="lp.snappy.interfaces.snappyseries.ISnappySeriesSet" />
135+ <require
136+ permission="launchpad.Edit"
137+ interface="lp.snappy.interfaces.snappyseries.ISnappySeriesSetEdit" />
138+ </securedutility>
139+
140+ <!-- SnappyDistroSeriesSet -->
141+ <securedutility
142+ class="lp.snappy.model.snappyseries.SnappyDistroSeriesSet"
143+ provides="lp.snappy.interfaces.snappyseries.ISnappyDistroSeriesSet">
144+ <allow
145+ interface="lp.snappy.interfaces.snappyseries.ISnappyDistroSeriesSet" />
146+ </securedutility>
147+
148 <webservice:register module="lp.snappy.interfaces.webservice" />
149
150 </configure>
151
152=== added file 'lib/lp/snappy/interfaces/snappyseries.py'
153--- lib/lp/snappy/interfaces/snappyseries.py 1970-01-01 00:00:00 +0000
154+++ lib/lp/snappy/interfaces/snappyseries.py 2016-05-09 13:24:49 +0000
155@@ -0,0 +1,192 @@
156+# Copyright 2016 Canonical Ltd. This software is licensed under the
157+# GNU Affero General Public License version 3 (see the file LICENSE).
158+
159+"""Snappy series interfaces."""
160+
161+from __future__ import absolute_import, print_function, unicode_literals
162+
163+__metaclass__ = type
164+__all__ = [
165+ 'ISnappyDistroSeries',
166+ 'ISnappyDistroSeriesSet',
167+ 'ISnappySeries',
168+ 'ISnappySeriesSet',
169+ 'NoSuchSnappySeries',
170+ ]
171+
172+from lazr.restful.declarations import (
173+ call_with,
174+ collection_default_content,
175+ export_as_webservice_collection,
176+ export_as_webservice_entry,
177+ export_factory_operation,
178+ export_read_operation,
179+ exported,
180+ operation_for_version,
181+ operation_parameters,
182+ operation_returns_collection_of,
183+ operation_returns_entry,
184+ REQUEST_USER,
185+ )
186+from lazr.restful.fields import Reference
187+from zope.component import getUtility
188+from zope.interface import Interface
189+from zope.schema import (
190+ Choice,
191+ Datetime,
192+ Int,
193+ List,
194+ TextLine,
195+ )
196+
197+from lp import _
198+from lp.app.errors import NameLookupFailed
199+from lp.app.validators.name import name_validator
200+from lp.registry.interfaces.distroseries import IDistroSeries
201+from lp.registry.interfaces.series import SeriesStatus
202+from lp.services.fields import (
203+ ContentNameField,
204+ PublicPersonChoice,
205+ Title,
206+ )
207+
208+
209+class NoSuchSnappySeries(NameLookupFailed):
210+ """The requested `SnappySeries` does not exist."""
211+
212+ _message_prefix = "No such snappy series"
213+
214+
215+class SnappySeriesNameField(ContentNameField):
216+ """Ensure that `ISnappySeries` has unique names."""
217+
218+ errormessage = _("%s is already in use by another series.")
219+
220+ @property
221+ def _content_iface(self):
222+ """See `UniqueField`."""
223+ return ISnappySeries
224+
225+ def _getByName(self, name):
226+ """See `ContentNameField`."""
227+ try:
228+ return getUtility(ISnappySeriesSet).getByName(name)
229+ except NoSuchSnappySeries:
230+ return None
231+
232+
233+class ISnappySeriesView(Interface):
234+ """`ISnappySeries` attributes that anyone can view."""
235+
236+ id = Int(title=_("ID"), required=True, readonly=True)
237+
238+ date_created = exported(Datetime(
239+ title=_("Date created"), required=True, readonly=True))
240+
241+ registrant = exported(PublicPersonChoice(
242+ title=_("Registrant"), required=True, readonly=True,
243+ vocabulary="ValidPersonOrTeam",
244+ description=_("The person who registered this snap package.")))
245+
246+
247+class ISnappySeriesEditableAttributes(Interface):
248+ """`ISnappySeries` attributes that can be edited.
249+
250+ Anyone can view these attributes, but they need launchpad.Edit to change.
251+ """
252+
253+ name = exported(SnappySeriesNameField(
254+ title=_("Name"), required=True, readonly=False,
255+ constraint=name_validator))
256+
257+ display_name = exported(TextLine(
258+ title=_("Display name"), required=True, readonly=False))
259+
260+ title = Title(title=_("Title"), required=True, readonly=True)
261+
262+ status = exported(Choice(
263+ title=_("Status"), required=True, vocabulary=SeriesStatus))
264+
265+ usable_distro_series = exported(List(
266+ title=_("Usable distro series"),
267+ description=_(
268+ "The distro series that can be used for this snappy series."),
269+ value_type=Reference(schema=IDistroSeries),
270+ required=True, readonly=False))
271+
272+
273+class ISnappySeries(ISnappySeriesView, ISnappySeriesEditableAttributes):
274+ """A series for snap packages in the store."""
275+
276+ # XXX cjwatson 2016-04-13 bug=760849: "beta" is a lie to get WADL
277+ # generation working. Individual attributes must set their version to
278+ # "devel".
279+ export_as_webservice_entry(plural_name="snappy_serieses", as_of="beta")
280+
281+
282+class ISnappyDistroSeries(Interface):
283+ """A snappy/distro series link."""
284+
285+ snappy_series = Reference(
286+ ISnappySeries, title=_("Snappy series"), readonly=True)
287+ distro_series = Reference(
288+ IDistroSeries, title=_("Distro series"), readonly=True)
289+
290+ title = Title(title=_("Title"), required=True, readonly=True)
291+
292+
293+class ISnappySeriesSetEdit(Interface):
294+ """`ISnappySeriesSet` methods that require launchpad.Edit permission."""
295+
296+ @call_with(registrant=REQUEST_USER)
297+ @export_factory_operation(
298+ ISnappySeries, ["name", "display_name", "status"])
299+ @operation_for_version("devel")
300+ def new(registrant, name, display_name, status, date_created=None):
301+ """Create an `ISnappySeries`."""
302+
303+
304+class ISnappySeriesSet(ISnappySeriesSetEdit):
305+ """Interface representing the set of snappy series."""
306+
307+ export_as_webservice_collection(ISnappySeries)
308+
309+ def __iter__():
310+ """Iterate over `ISnappySeries`."""
311+
312+ def __getitem__(name):
313+ """Return the `ISnappySeries` with this name."""
314+
315+ @operation_parameters(
316+ name=TextLine(title=_("Snappy series name"), required=True))
317+ @operation_returns_entry(ISnappySeries)
318+ @export_read_operation()
319+ @operation_for_version("devel")
320+ def getByName(name):
321+ """Return the `ISnappySeries` with this name.
322+
323+ :raises NoSuchSnappySeries: if no snappy series exists with this name.
324+ """
325+
326+ @operation_parameters(
327+ distro_series=Reference(
328+ IDistroSeries, title=_("Distro series"), required=True))
329+ @operation_returns_collection_of(ISnappySeries)
330+ @export_read_operation()
331+ @operation_for_version("devel")
332+ def getByDistroSeries(distro_series):
333+ """Return all `ISnappySeries` usable with this `IDistroSeries`."""
334+
335+ @collection_default_content()
336+ def getAll():
337+ """Return all `ISnappySeries`."""
338+
339+
340+class ISnappyDistroSeriesSet(Interface):
341+ """Interface representing the set of snappy/distro series links."""
342+
343+ def getByDistroSeries(distro_series):
344+ """Return all `SnappyDistroSeries` for this `IDistroSeries`."""
345+
346+ def getByBothSeries(snappy_series, distro_series):
347+ """Return a `SnappyDistroSeries` for this pair of series, or None."""
348
349=== modified file 'lib/lp/snappy/interfaces/webservice.py'
350--- lib/lp/snappy/interfaces/webservice.py 2015-08-04 23:52:48 +0000
351+++ lib/lp/snappy/interfaces/webservice.py 2016-05-09 13:24:49 +0000
352@@ -1,4 +1,4 @@
353-# Copyright 2015 Canonical Ltd. This software is licensed under the
354+# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
355 # GNU Affero General Public License version 3 (see the file LICENSE).
356
357 """All the interfaces that are exposed through the webservice.
358@@ -12,6 +12,8 @@
359 __all__ = [
360 'ISnap',
361 'ISnapBuild',
362+ 'ISnappySeries',
363+ 'ISnappySeriesSet',
364 'ISnapSet',
365 ]
366
367@@ -29,6 +31,10 @@
368 ISnapBuild,
369 ISnapFile,
370 )
371+from lp.snappy.interfaces.snappyseries import (
372+ ISnappySeries,
373+ ISnappySeriesSet,
374+ )
375
376
377 # ISnapFile
378
379=== added file 'lib/lp/snappy/model/snappyseries.py'
380--- lib/lp/snappy/model/snappyseries.py 1970-01-01 00:00:00 +0000
381+++ lib/lp/snappy/model/snappyseries.py 2016-05-09 13:24:49 +0000
382@@ -0,0 +1,184 @@
383+# Copyright 2016 Canonical Ltd. This software is licensed under the
384+# GNU Affero General Public License version 3 (see the file LICENSE).
385+
386+"""Snappy series."""
387+
388+from __future__ import absolute_import, print_function, unicode_literals
389+
390+__metaclass__ = type
391+__all__ = [
392+ 'SnappyDistroSeries',
393+ 'SnappySeries',
394+ ]
395+
396+import pytz
397+from storm.locals import (
398+ DateTime,
399+ Desc,
400+ Int,
401+ Reference,
402+ Store,
403+ Storm,
404+ Unicode,
405+ )
406+from zope.interface import implementer
407+
408+from lp.registry.interfaces.series import SeriesStatus
409+from lp.registry.model.distroseries import DistroSeries
410+from lp.services.database.constants import DEFAULT
411+from lp.services.database.enumcol import EnumCol
412+from lp.services.database.interfaces import (
413+ IMasterStore,
414+ IStore,
415+ )
416+from lp.snappy.interfaces.snappyseries import (
417+ ISnappyDistroSeries,
418+ ISnappyDistroSeriesSet,
419+ ISnappySeries,
420+ ISnappySeriesSet,
421+ NoSuchSnappySeries,
422+ )
423+
424+
425+@implementer(ISnappySeries)
426+class SnappySeries(Storm):
427+ """See `ISnappySeries`."""
428+
429+ __storm_table__ = 'SnappySeries'
430+
431+ id = Int(primary=True)
432+
433+ date_created = DateTime(
434+ name='date_created', tzinfo=pytz.UTC, allow_none=False)
435+
436+ registrant_id = Int(name='registrant', allow_none=False)
437+ registrant = Reference(registrant_id, 'Person.id')
438+
439+ name = Unicode(name='name', allow_none=False)
440+
441+ display_name = Unicode(name='display_name', allow_none=False)
442+
443+ status = EnumCol(enum=SeriesStatus, notNull=True)
444+
445+ def __init__(self, registrant, name, display_name, status,
446+ date_created=DEFAULT):
447+ super(SnappySeries, self).__init__()
448+ self.registrant = registrant
449+ self.name = name
450+ self.display_name = display_name
451+ self.status = status
452+ self.date_created = date_created
453+
454+ @property
455+ def title(self):
456+ return self.display_name
457+
458+ @property
459+ def usable_distro_series(self):
460+ rows = IStore(DistroSeries).find(
461+ DistroSeries,
462+ SnappyDistroSeries.snappy_series == self,
463+ SnappyDistroSeries.distro_series_id == DistroSeries.id)
464+ return rows.order_by(DistroSeries.id)
465+
466+ @usable_distro_series.setter
467+ def usable_distro_series(self, value):
468+ enablements = dict(Store.of(self).find(
469+ (DistroSeries, SnappyDistroSeries),
470+ SnappyDistroSeries.snappy_series == self,
471+ SnappyDistroSeries.distro_series_id == DistroSeries.id))
472+ for distro_series in enablements:
473+ if distro_series not in value:
474+ Store.of(self).remove(enablements[distro_series])
475+ for distro_series in value:
476+ if distro_series not in enablements:
477+ link = SnappyDistroSeries(self, distro_series)
478+ Store.of(self).add(link)
479+
480+
481+@implementer(ISnappyDistroSeries)
482+class SnappyDistroSeries(Storm):
483+ """Link table between `SnappySeries` and `DistroSeries`."""
484+
485+ __storm_table__ = 'SnappyDistroSeries'
486+ __storm_primary__ = ('snappy_series_id', 'distro_series_id')
487+
488+ snappy_series_id = Int(name='snappy_series', allow_none=False)
489+ snappy_series = Reference(snappy_series_id, 'SnappySeries.id')
490+
491+ distro_series_id = Int(name='distro_series', allow_none=False)
492+ distro_series = Reference(distro_series_id, 'DistroSeries.id')
493+
494+ def __init__(self, snappy_series, distro_series):
495+ super(SnappyDistroSeries, self).__init__()
496+ self.snappy_series = snappy_series
497+ self.distro_series = distro_series
498+
499+ @property
500+ def title(self):
501+ return "%s, for %s" % (
502+ self.distro_series.fullseriesname, self.snappy_series.title)
503+
504+
505+@implementer(ISnappySeriesSet)
506+class SnappySeriesSet:
507+ """See `ISnappySeriesSet`."""
508+
509+ def new(self, registrant, name, display_name, status,
510+ date_created=DEFAULT):
511+ """See `ISnappySeriesSet`."""
512+ store = IMasterStore(SnappySeries)
513+ snappy_series = SnappySeries(
514+ registrant, name, display_name, status, date_created=date_created)
515+ store.add(snappy_series)
516+ return snappy_series
517+
518+ def __iter__(self):
519+ """See `ISnappySeriesSet`."""
520+ return iter(self.getAll())
521+
522+ def __getitem__(self, name):
523+ """See `ISnappySeriesSet`."""
524+ return self.getByName(name)
525+
526+ def getByName(self, name):
527+ """See `ISnappySeriesSet`."""
528+ snappy_series = IStore(SnappySeries).find(
529+ SnappySeries, SnappySeries.name == name).one()
530+ if snappy_series is None:
531+ raise NoSuchSnappySeries(name)
532+ return snappy_series
533+
534+ def getByDistroSeries(self, distro_series):
535+ """See `ISnappySeriesSet`."""
536+ rows = IStore(SnappySeries).find(
537+ SnappySeries,
538+ SnappyDistroSeries.snappy_series_id == SnappySeries.id,
539+ SnappyDistroSeries.distro_series == distro_series)
540+ return rows.order_by(Desc(SnappySeries.name))
541+
542+ def getAll(self):
543+ """See `ISnappySeriesSet`."""
544+ return IStore(SnappySeries).find(SnappySeries).order_by(
545+ SnappySeries.name)
546+
547+
548+@implementer(ISnappyDistroSeriesSet)
549+class SnappyDistroSeriesSet:
550+ """See `ISnappyDistroSeriesSet`."""
551+
552+ def getByDistroSeries(self, distro_series):
553+ """See `ISnappyDistroSeriesSet`."""
554+ store = IStore(SnappyDistroSeries)
555+ rows = store.using(SnappyDistroSeries, SnappySeries).find(
556+ SnappyDistroSeries,
557+ SnappyDistroSeries.snappy_series_id == SnappySeries.id,
558+ SnappyDistroSeries.distro_series == distro_series)
559+ return rows.order_by(Desc(SnappySeries.name))
560+
561+ def getByBothSeries(self, snappy_series, distro_series):
562+ """See `ISnappyDistroSeriesSet`."""
563+ return IStore(SnappyDistroSeries).find(
564+ SnappyDistroSeries,
565+ SnappyDistroSeries.snappy_series == snappy_series,
566+ SnappyDistroSeries.distro_series == distro_series).one()
567
568=== added file 'lib/lp/snappy/tests/test_snappyseries.py'
569--- lib/lp/snappy/tests/test_snappyseries.py 1970-01-01 00:00:00 +0000
570+++ lib/lp/snappy/tests/test_snappyseries.py 2016-05-09 13:24:49 +0000
571@@ -0,0 +1,279 @@
572+# Copyright 2016 Canonical Ltd. This software is licensed under the
573+# GNU Affero General Public License version 3 (see the file LICENSE).
574+
575+"""Test snappy series."""
576+
577+from __future__ import absolute_import, print_function, unicode_literals
578+
579+__metaclass__ = type
580+
581+from testtools.matchers import (
582+ MatchesSetwise,
583+ MatchesStructure,
584+ )
585+from zope.component import getUtility
586+
587+from lp.services.features.testing import FeatureFixture
588+from lp.services.webapp.interfaces import OAuthPermission
589+from lp.snappy.interfaces.snap import SNAP_TESTING_FLAGS
590+from lp.snappy.interfaces.snappyseries import (
591+ ISnappyDistroSeriesSet,
592+ ISnappySeries,
593+ ISnappySeriesSet,
594+ NoSuchSnappySeries,
595+ )
596+from lp.testing import (
597+ admin_logged_in,
598+ api_url,
599+ logout,
600+ person_logged_in,
601+ TestCaseWithFactory,
602+ )
603+from lp.testing.layers import (
604+ DatabaseFunctionalLayer,
605+ ZopelessDatabaseLayer,
606+ )
607+from lp.testing.pages import webservice_for_person
608+
609+
610+class TestSnappySeries(TestCaseWithFactory):
611+
612+ layer = ZopelessDatabaseLayer
613+
614+ def setUp(self):
615+ super(TestSnappySeries, self).setUp()
616+ self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
617+
618+ def test_implements_interface(self):
619+ # SnappySeries implements ISnappySeries.
620+ snappy_series = self.factory.makeSnappySeries()
621+ self.assertProvides(snappy_series, ISnappySeries)
622+
623+ def test_new_no_usable_distro_series(self):
624+ snappy_series = self.factory.makeSnappySeries()
625+ self.assertContentEqual([], snappy_series.usable_distro_series)
626+
627+ def test_set_usable_distro_series(self):
628+ dses = [self.factory.makeDistroSeries() for _ in range(3)]
629+ snappy_series = self.factory.makeSnappySeries(
630+ usable_distro_series=[dses[0]])
631+ self.assertContentEqual([dses[0]], snappy_series.usable_distro_series)
632+ snappy_series.usable_distro_series = dses
633+ self.assertContentEqual(dses, snappy_series.usable_distro_series)
634+ snappy_series.usable_distro_series = []
635+ self.assertContentEqual([], snappy_series.usable_distro_series)
636+
637+
638+class TestSnappySeriesSet(TestCaseWithFactory):
639+
640+ layer = ZopelessDatabaseLayer
641+
642+ def setUp(self):
643+ super(TestSnappySeriesSet, self).setUp()
644+ self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
645+
646+ def test_getByName(self):
647+ snappy_series = self.factory.makeSnappySeries(name="foo")
648+ self.factory.makeSnappySeries()
649+ snappy_series_set = getUtility(ISnappySeriesSet)
650+ self.assertEqual(snappy_series, snappy_series_set.getByName("foo"))
651+ self.assertRaises(
652+ NoSuchSnappySeries, snappy_series_set.getByName, "bar")
653+
654+ def test_getByDistroSeries(self):
655+ dses = [self.factory.makeDistroSeries() for _ in range(3)]
656+ snappy_serieses = [self.factory.makeSnappySeries() for _ in range(3)]
657+ snappy_serieses[0].usable_distro_series = dses
658+ snappy_serieses[1].usable_distro_series = [dses[0], dses[1]]
659+ snappy_serieses[2].usable_distro_series = [dses[1], dses[2]]
660+ snappy_series_set = getUtility(ISnappySeriesSet)
661+ self.assertContentEqual(
662+ [snappy_serieses[0], snappy_serieses[1]],
663+ snappy_series_set.getByDistroSeries(dses[0]))
664+ self.assertContentEqual(
665+ snappy_serieses, snappy_series_set.getByDistroSeries(dses[1]))
666+ self.assertContentEqual(
667+ [snappy_serieses[0], snappy_serieses[2]],
668+ snappy_series_set.getByDistroSeries(dses[2]))
669+
670+ def test_getAll(self):
671+ snappy_serieses = [self.factory.makeSnappySeries() for _ in range(3)]
672+ self.assertContentEqual(
673+ snappy_serieses, getUtility(ISnappySeriesSet).getAll())
674+
675+
676+class TestSnappySeriesWebservice(TestCaseWithFactory):
677+
678+ layer = DatabaseFunctionalLayer
679+
680+ def setUp(self):
681+ super(TestSnappySeriesWebservice, self).setUp()
682+ self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
683+
684+ def test_new_unpriv(self):
685+ # An unprivileged user cannot create a SnappySeries.
686+ person = self.factory.makePerson()
687+ webservice = webservice_for_person(
688+ person, permission=OAuthPermission.WRITE_PUBLIC)
689+ webservice.default_api_version = "devel"
690+ response = webservice.named_post(
691+ "/+snappy-series", "new",
692+ name="dummy", display_name="dummy", status="Experimental")
693+ self.assertEqual(401, response.status)
694+
695+ def test_new(self):
696+ # A registry expert can create a SnappySeries.
697+ person = self.factory.makeRegistryExpert()
698+ webservice = webservice_for_person(
699+ person, permission=OAuthPermission.WRITE_PUBLIC)
700+ webservice.default_api_version = "devel"
701+ logout()
702+ response = webservice.named_post(
703+ "/+snappy-series", "new",
704+ name="dummy", display_name="Dummy", status="Experimental")
705+ self.assertEqual(201, response.status)
706+ snappy_series = webservice.get(
707+ response.getHeader("Location")).jsonBody()
708+ with person_logged_in(person):
709+ self.assertEqual(
710+ webservice.getAbsoluteUrl(api_url(person)),
711+ snappy_series["registrant_link"])
712+ self.assertEqual("dummy", snappy_series["name"])
713+ self.assertEqual("Dummy", snappy_series["display_name"])
714+ self.assertEqual("Experimental", snappy_series["status"])
715+
716+ def test_new_duplicate_name(self):
717+ # An attempt to create a SnappySeries with a duplicate name is
718+ # rejected.
719+ person = self.factory.makeRegistryExpert()
720+ webservice = webservice_for_person(
721+ person, permission=OAuthPermission.WRITE_PUBLIC)
722+ webservice.default_api_version = "devel"
723+ logout()
724+ response = webservice.named_post(
725+ "/+snappy-series", "new",
726+ name="dummy", display_name="Dummy", status="Experimental")
727+ self.assertEqual(201, response.status)
728+ response = webservice.named_post(
729+ "/+snappy-series", "new",
730+ name="dummy", display_name="Dummy", status="Experimental")
731+ self.assertEqual(400, response.status)
732+ self.assertEqual(
733+ "name: dummy is already in use by another series.", response.body)
734+
735+ def test_getByName(self):
736+ # lp.snappy_serieses.getByName returns a matching SnappySeries.
737+ person = self.factory.makePerson()
738+ webservice = webservice_for_person(
739+ person, permission=OAuthPermission.READ_PUBLIC)
740+ webservice.default_api_version = "devel"
741+ with admin_logged_in():
742+ self.factory.makeSnappySeries(name="dummy")
743+ response = webservice.named_get(
744+ "/+snappy-series", "getByName", name="dummy")
745+ self.assertEqual(200, response.status)
746+ self.assertEqual("dummy", response.jsonBody()["name"])
747+
748+ def test_getByName_missing(self):
749+ # lp.snappy_serieses.getByName returns 404 for a non-existent
750+ # SnappySeries.
751+ person = self.factory.makePerson()
752+ webservice = webservice_for_person(
753+ person, permission=OAuthPermission.READ_PUBLIC)
754+ webservice.default_api_version = "devel"
755+ logout()
756+ response = webservice.named_get(
757+ "/+snappy-series", "getByName", name="nonexistent")
758+ self.assertEqual(404, response.status)
759+ self.assertEqual(
760+ "No such snappy series: 'nonexistent'.", response.body)
761+
762+ def test_getByDistroSeries(self):
763+ # lp.snappy_serieses.getByDistroSeries returns a collection of
764+ # matching SnappySeries.
765+ person = self.factory.makePerson()
766+ webservice = webservice_for_person(
767+ person, permission=OAuthPermission.READ_PUBLIC)
768+ webservice.default_api_version = "devel"
769+ with admin_logged_in():
770+ dses = [self.factory.makeDistroSeries() for _ in range(3)]
771+ ds_urls = [api_url(ds) for ds in dses]
772+ snappy_serieses = [
773+ self.factory.makeSnappySeries(name="ss-%d" % i)
774+ for i in range(3)]
775+ snappy_serieses[0].usable_distro_series = dses
776+ snappy_serieses[1].usable_distro_series = [dses[0], dses[1]]
777+ snappy_serieses[2].usable_distro_series = [dses[1], dses[2]]
778+ for ds_url, expected_snappy_series_names in (
779+ (ds_urls[0], ["ss-0", "ss-1"]),
780+ (ds_urls[1], ["ss-0", "ss-1", "ss-2"]),
781+ (ds_urls[2], ["ss-0", "ss-2"])):
782+ response = webservice.named_get(
783+ "/+snappy-series", "getByDistroSeries", distro_series=ds_url)
784+ self.assertEqual(200, response.status)
785+ self.assertContentEqual(
786+ expected_snappy_series_names,
787+ [entry["name"] for entry in response.jsonBody()["entries"]])
788+
789+ def test_collection(self):
790+ # lp.snappy_serieses is a collection of all SnappySeries.
791+ person = self.factory.makePerson()
792+ webservice = webservice_for_person(
793+ person, permission=OAuthPermission.READ_PUBLIC)
794+ webservice.default_api_version = "devel"
795+ with admin_logged_in():
796+ for i in range(3):
797+ self.factory.makeSnappySeries(name="ss-%d" % i)
798+ response = webservice.get("/+snappy-series")
799+ self.assertEqual(200, response.status)
800+ self.assertContentEqual(
801+ ["ss-0", "ss-1", "ss-2"],
802+ [entry["name"] for entry in response.jsonBody()["entries"]])
803+
804+
805+class TestSnappyDistroSeriesSet(TestCaseWithFactory):
806+
807+ layer = ZopelessDatabaseLayer
808+
809+ def setUp(self):
810+ super(TestSnappyDistroSeriesSet, self).setUp()
811+ self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
812+
813+ def test_getByDistroSeries(self):
814+ dses = [self.factory.makeDistroSeries() for _ in range(3)]
815+ snappy_serieses = [self.factory.makeSnappySeries() for _ in range(3)]
816+ snappy_serieses[0].usable_distro_series = dses
817+ snappy_serieses[1].usable_distro_series = [dses[0], dses[1]]
818+ snappy_serieses[2].usable_distro_series = [dses[1], dses[2]]
819+ sds_set = getUtility(ISnappyDistroSeriesSet)
820+ self.assertThat(
821+ sds_set.getByDistroSeries(dses[0]),
822+ MatchesSetwise(*(
823+ MatchesStructure.byEquality(
824+ snappy_series=ss, distro_series=dses[0])
825+ for ss in (snappy_serieses[0], snappy_serieses[1]))))
826+ self.assertThat(
827+ sds_set.getByDistroSeries(dses[1]),
828+ MatchesSetwise(*(
829+ MatchesStructure.byEquality(
830+ snappy_series=ss, distro_series=dses[1])
831+ for ss in snappy_serieses)))
832+ self.assertThat(
833+ sds_set.getByDistroSeries(dses[2]),
834+ MatchesSetwise(*(
835+ MatchesStructure.byEquality(
836+ snappy_series=ss, distro_series=dses[2])
837+ for ss in (snappy_serieses[0], snappy_serieses[2]))))
838+
839+ def test_getByBothSeries(self):
840+ dses = [self.factory.makeDistroSeries() for _ in range(2)]
841+ snappy_serieses = [self.factory.makeSnappySeries() for _ in range(2)]
842+ snappy_serieses[0].usable_distro_series = [dses[0]]
843+ sds_set = getUtility(ISnappyDistroSeriesSet)
844+ self.assertThat(
845+ sds_set.getByBothSeries(snappy_serieses[0], dses[0]),
846+ MatchesStructure.byEquality(
847+ snappy_series=snappy_serieses[0], distro_series=dses[0]))
848+ self.assertIsNone(sds_set.getByBothSeries(snappy_serieses[0], dses[1]))
849+ self.assertIsNone(sds_set.getByBothSeries(snappy_serieses[1], dses[0]))
850+ self.assertIsNone(sds_set.getByBothSeries(snappy_serieses[1], dses[1]))
851
852=== modified file 'lib/lp/snappy/vocabularies.py'
853--- lib/lp/snappy/vocabularies.py 2015-09-18 14:14:34 +0000
854+++ lib/lp/snappy/vocabularies.py 2016-05-09 13:24:49 +0000
855@@ -1,4 +1,4 @@
856-# Copyright 2015 Canonical Ltd. This software is licensed under the GNU
857+# Copyright 2015-2016 Canonical Ltd. This software is licensed under the GNU
858 # Affero General Public License version 3 (see the file LICENSE).
859
860 """Snappy vocabularies."""
861@@ -7,11 +7,21 @@
862
863 __all__ = [
864 'SnapDistroArchSeriesVocabulary',
865+ 'SnappySeriesVocabulary',
866 ]
867
868+from storm.locals import Desc
869 from zope.schema.vocabulary import SimpleTerm
870
871+from lp.registry.model.distribution import Distribution
872+from lp.registry.model.distroseries import DistroSeries
873+from lp.registry.model.series import ACTIVE_STATUSES
874+from lp.services.database.interfaces import IStore
875 from lp.services.webapp.vocabulary import StormVocabularyBase
876+from lp.snappy.model.snappyseries import (
877+ SnappyDistroSeries,
878+ SnappySeries,
879+ )
880 from lp.soyuz.model.distroarchseries import DistroArchSeries
881
882
883@@ -29,3 +39,73 @@
884
885 def __len__(self):
886 return len(self.context.getAllowedArchitectures())
887+
888+
889+class SnappySeriesVocabulary(StormVocabularyBase):
890+ """A vocabulary for searching snappy series."""
891+
892+ _table = SnappySeries
893+ _clauses = [SnappySeries.status.is_in(ACTIVE_STATUSES)]
894+ _order_by = Desc(SnappySeries.date_created)
895+
896+
897+class SnappyDistroSeriesVocabulary(StormVocabularyBase):
898+ """A vocabulary for searching snappy/distro series combinations."""
899+
900+ _table = SnappyDistroSeries
901+ _clauses = [
902+ SnappyDistroSeries.snappy_series_id == SnappySeries.id,
903+ SnappyDistroSeries.distro_series_id == DistroSeries.id,
904+ DistroSeries.distributionID == Distribution.id,
905+ ]
906+
907+ @property
908+ def _entries(self):
909+ tables = [SnappyDistroSeries, SnappySeries, DistroSeries, Distribution]
910+ entries = IStore(self._table).using(*tables).find(
911+ self._table, *self._clauses)
912+ return entries.order_by(
913+ Distribution.display_name, Desc(DistroSeries.date_created),
914+ Desc(SnappySeries.date_created))
915+
916+ def toTerm(self, obj):
917+ """See `IVocabulary`."""
918+ token = "%s/%s/%s" % (
919+ obj.distro_series.distribution.name, obj.distro_series.name,
920+ obj.snappy_series.name)
921+ return SimpleTerm(obj, token, obj.title)
922+
923+ def __contains__(self, value):
924+ """See `IVocabulary`."""
925+ return value in self._entries
926+
927+ def getTerm(self, value):
928+ """See `IVocabulary`."""
929+ if value not in self:
930+ raise LookupError(value)
931+ return self.toTerm(value)
932+
933+ def getTermByToken(self, token):
934+ """See `IVocabularyTokenized`."""
935+ try:
936+ distribution_name, distro_series_name, snappy_series_name = (
937+ token.split("/", 2))
938+ except ValueError:
939+ raise LookupError(token)
940+ entry = IStore(self._table).find(
941+ self._table,
942+ Distribution.name == distribution_name,
943+ DistroSeries.name == distro_series_name,
944+ SnappySeries.name == snappy_series_name,
945+ *self._clauses).one()
946+ if entry is None:
947+ raise LookupError(token)
948+ return self.toTerm(entry)
949+
950+
951+class BuildableSnappyDistroSeriesVocabulary(SnappyDistroSeriesVocabulary):
952+ """A vocabulary for searching active snappy/distro series combinations."""
953+
954+ _clauses = SnappyDistroSeriesVocabulary._clauses + [
955+ SnappySeries.status.is_in(ACTIVE_STATUSES),
956+ ]
957
958=== modified file 'lib/lp/snappy/vocabularies.zcml'
959--- lib/lp/snappy/vocabularies.zcml 2015-09-18 13:32:09 +0000
960+++ lib/lp/snappy/vocabularies.zcml 2016-05-09 13:24:49 +0000
961@@ -1,4 +1,4 @@
962-<!-- Copyright 2015 Canonical Ltd. This software is licensed under the
963+<!-- Copyright 2015-2016 Canonical Ltd. This software is licensed under the
964 GNU Affero General Public License version 3 (see the file LICENSE).
965 -->
966
967@@ -15,4 +15,37 @@
968 <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
969 </class>
970
971+ <securedutility
972+ name="SnappySeries"
973+ component="lp.snappy.vocabularies.SnappySeriesVocabulary"
974+ provides="zope.schema.interfaces.IVocabularyFactory">
975+ <allow interface="zope.schema.interfaces.IVocabularyFactory" />
976+ </securedutility>
977+
978+ <class class="lp.snappy.vocabularies.SnappySeriesVocabulary">
979+ <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
980+ </class>
981+
982+ <securedutility
983+ name="SnappyDistroSeries"
984+ component="lp.snappy.vocabularies.SnappyDistroSeriesVocabulary"
985+ provides="zope.schema.interfaces.IVocabularyFactory">
986+ <allow interface="zope.schema.interfaces.IVocabularyFactory" />
987+ </securedutility>
988+
989+ <class class="lp.snappy.vocabularies.SnappyDistroSeriesVocabulary">
990+ <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
991+ </class>
992+
993+ <securedutility
994+ name="BuildableSnappyDistroSeries"
995+ component="lp.snappy.vocabularies.BuildableSnappyDistroSeriesVocabulary"
996+ provides="zope.schema.interfaces.IVocabularyFactory">
997+ <allow interface="zope.schema.interfaces.IVocabularyFactory" />
998+ </securedutility>
999+
1000+ <class class="lp.snappy.vocabularies.BuildableSnappyDistroSeriesVocabulary">
1001+ <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
1002+ </class>
1003+
1004 </configure>
1005
1006=== modified file 'lib/lp/testing/factory.py'
1007--- lib/lp/testing/factory.py 2016-04-28 02:25:46 +0000
1008+++ lib/lp/testing/factory.py 2016-05-09 13:24:49 +0000
1009@@ -275,6 +275,7 @@
1010 from lp.services.worlddata.interfaces.language import ILanguageSet
1011 from lp.snappy.interfaces.snap import ISnapSet
1012 from lp.snappy.interfaces.snapbuild import ISnapBuildSet
1013+from lp.snappy.interfaces.snappyseries import ISnappySeriesSet
1014 from lp.snappy.model.snapbuild import SnapFile
1015 from lp.soyuz.adapters.overrides import SourceOverride
1016 from lp.soyuz.adapters.packagelocation import PackageLocation
1017@@ -4679,6 +4680,24 @@
1018 return ProxyFactory(
1019 SnapFile(snapbuild=snapbuild, libraryfile=libraryfile))
1020
1021+ def makeSnappySeries(self, registrant=None, name=None, display_name=None,
1022+ status=SeriesStatus.DEVELOPMENT,
1023+ date_created=DEFAULT, usable_distro_series=None):
1024+ """Make a new SnappySeries."""
1025+ if registrant is None:
1026+ registrant = self.makePerson()
1027+ if name is None:
1028+ name = self.getUniqueString(u"snappy-series-name")
1029+ if display_name is None:
1030+ display_name = SPACE.join(
1031+ word.capitalize() for word in name.split('-'))
1032+ snappy_series = getUtility(ISnappySeriesSet).new(
1033+ registrant, name, display_name, status, date_created=date_created)
1034+ if usable_distro_series is not None:
1035+ snappy_series.usable_distro_series = usable_distro_series
1036+ IStore(snappy_series).flush()
1037+ return snappy_series
1038+
1039
1040 # Some factory methods return simple Python types. We don't add
1041 # security wrappers for them, as well as for objects created by