Merge ~pappacena/launchpad:oci-project-api into launchpad:master

Proposed by Thiago F. Pappacena
Status: Merged
Approved by: Thiago F. Pappacena
Approved revision: 4f64097d7a38d4b72848f2c6b7e1afb04ab4e861
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pappacena/launchpad:oci-project-api
Merge into: launchpad:master
Diff against target: 923 lines (+424/-68)
14 files modified
lib/lp/_schema_circular_imports.py (+5/-0)
lib/lp/oci/configure.zcml (+3/-0)
lib/lp/oci/interfaces/ocirecipe.py (+35/-27)
lib/lp/oci/interfaces/webservice.py (+14/-0)
lib/lp/oci/tests/test_ocirecipe.py (+101/-0)
lib/lp/registry/browser/configure.zcml (+6/-1)
lib/lp/registry/browser/ociproject.py (+10/-1)
lib/lp/registry/interfaces/ociproject.py (+32/-21)
lib/lp/registry/interfaces/ociprojectseries.py (+22/-14)
lib/lp/registry/model/ociproject.py (+4/-1)
lib/lp/registry/tests/test_ociproject.py (+96/-1)
lib/lp/registry/tests/test_ociprojectseries.py (+70/-2)
lib/lp/services/webservice/wadl-to-refhtml.xsl (+24/-0)
lib/lp/soyuz/stories/ppa/xx-delete-packages.txt (+2/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+380909@code.launchpad.net

Commit message

API for managing OCI Project

Description of the change

This is still a work in progress, but I would like to open for discussions.

One thing that we should need to think about is: should we expose this API immediately, or shall we keep these endpoints hidden in production? Should we use a feature flag? Allow only for certain users?

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote :

I haven't looked at the details of this API work as yet, but in general I think it's fine for most of this to be exposed without a feature flag; if there's anything allowing creating OCI projects or maybe OCI recipes, that should be behind a feature flag for now.

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

cjwatson, I'm opening this MP for review, and I will create a new one for OCI Recipe creation to make the review process easier. Otherwise, the diff will be too big.

This one should be easy since most of the lines changed are marking OCIProject and OCIRecipe fields as "exported".

Revision history for this message
Colin Watson (cjwatson) wrote :

For the new entry types, you should also edit the notably vile find-entry-uri template in lib/lp/services/webservice/wadl-to-refhtml.xsl so that the template entry URLs in the API documentation are correct. You can check your work by running "make" and looking at lib/canonical/launchpad/apidoc/devel.html.

review: Needs Fixing
~pappacena/launchpad:oci-project-api updated
e11d5a1... by Thiago F. Pappacena

Moving test to proper location and removing some fields from API

aff75f1... by Thiago F. Pappacena

Improving tests checks

b16caa6... by Thiago F. Pappacena

Minor adjustments

4c66faf... by Thiago F. Pappacena

Explicit readonly attribute

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

cjwatson, I'm pushing some of the changes, and leaving some comments for discussion below.

I still need to work out the wadl-to-refhtml.xsl thing, so this shouldn't be totally fixed yet.

Revision history for this message
Colin Watson (cjwatson) :
~pappacena/launchpad:oci-project-api updated
5650d4d... by Thiago F. Pappacena

Fixing OCIRecipe and OCIProject URLs at apidocs

b9d7ccd... by Thiago F. Pappacena

Exposing OCIProjectSeries as <project_url>/+series/<series_name> on the API

3688a69... by Thiago F. Pappacena

Fixing oci-project-series url on api docs

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

cjwatson, pushed the work to have OCIProjectSeries under <oci-project-url>/+series/<series-name> URL, and added the correct path on lib/lp/services/webservice/wadl-to-refhtml.xsl.

It should be good for a another round of review now.

Revision history for this message
Colin Watson (cjwatson) wrote :

Mostly LGTM now, thanks! You have a merge conflict to fix though.

review: Approve
~pappacena/launchpad:oci-project-api updated
b42afa6... by Thiago F. Pappacena

Merge branch 'master' into oci-project-api

ccd94e6... by Thiago F. Pappacena

Code refactoring

3eaa040... by Thiago F. Pappacena

Fixing api doc formatting

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

I'll fix the conflict and top-approve it. Thanks!

~pappacena/launchpad:oci-project-api updated
f2b7aa1... by Thiago F. Pappacena

API doc formatting super small fix

4f64097... by Thiago F. Pappacena

Merge branch 'master' into oci-project-api

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/_schema_circular_imports.py b/lib/lp/_schema_circular_imports.py
2index 70c5b72..46247fc 100644
3--- a/lib/lp/_schema_circular_imports.py
4+++ b/lib/lp/_schema_circular_imports.py
5@@ -120,6 +120,8 @@ from lp.registry.interfaces.milestone import (
6 IHasMilestones,
7 IMilestone,
8 )
9+from lp.registry.interfaces.ociproject import IOCIProject
10+from lp.registry.interfaces.ociprojectseries import IOCIProjectSeries
11 from lp.registry.interfaces.person import (
12 IPerson,
13 IPersonEditRestricted,
14@@ -1093,6 +1095,9 @@ patch_operations_explicit_version(
15 # IWikiName
16 patch_entry_explicit_version(IWikiName, 'beta')
17
18+# IOCIProject
19+patch_collection_property(IOCIProject, 'series', IOCIProjectSeries)
20+
21 # IOCIRecipe
22 patch_collection_property(IOCIRecipe, 'builds', IOCIRecipeBuild)
23 patch_collection_property(IOCIRecipe, 'completed_builds', IOCIRecipeBuild)
24diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
25index da8d695..3382341 100644
26--- a/lib/lp/oci/configure.zcml
27+++ b/lib/lp/oci/configure.zcml
28@@ -5,10 +5,13 @@
29 xmlns="http://namespaces.zope.org/zope"
30 xmlns:i18n="http://namespaces.zope.org/i18n"
31 xmlns:lp="http://namespaces.canonical.com/lp"
32+ xmlns:webservice="http://namespaces.canonical.com/webservice"
33 i18n_domain="launchpad">
34
35 <include package=".browser" />
36
37+ <webservice:register module="lp.oci.interfaces.webservice" />
38+
39 <!-- OCIRecipe -->
40 <class
41 class="lp.oci.model.ocirecipe.OCIRecipe">
42diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
43index 16926c7..b80c25e 100644
44--- a/lib/lp/oci/interfaces/ocirecipe.py
45+++ b/lib/lp/oci/interfaces/ocirecipe.py
46@@ -20,7 +20,12 @@ __all__ = [
47 'OCIRecipeNotOwner',
48 ]
49
50-from lazr.restful.declarations import error_status
51+from lazr.lifecycle.snapshot import doNotSnapshot
52+from lazr.restful.declarations import (
53+ error_status,
54+ export_as_webservice_entry,
55+ exported,
56+ )
57 from lazr.restful.fields import (
58 CollectionField,
59 Reference,
60@@ -93,40 +98,40 @@ class IOCIRecipeView(Interface):
61 """`IOCIRecipe` attributes that require launchpad.View permission."""
62
63 id = Int(title=_("ID"), required=True, readonly=True)
64- date_created = Datetime(
65- title=_("Date created"), required=True, readonly=True)
66- date_last_modified = Datetime(
67- title=_("Date last modified"), required=True, readonly=True)
68+ date_created = exported(Datetime(
69+ title=_("Date created"), required=True, readonly=True))
70+ date_last_modified = exported(Datetime(
71+ title=_("Date last modified"), required=True, readonly=True))
72
73- registrant = PublicPersonChoice(
74+ registrant = exported(PublicPersonChoice(
75 title=_("Registrant"),
76 description=_("The user who registered this recipe."),
77- vocabulary='ValidPersonOrTeam', required=True, readonly=True)
78+ vocabulary='ValidPersonOrTeam', required=True, readonly=True))
79
80- builds = CollectionField(
81+ builds = exported(doNotSnapshot(CollectionField(
82 title=_("Completed builds of this OCI recipe."),
83 description=_(
84 "Completed builds of this OCI recipe, sorted in descending "
85 "order of finishing."),
86 # Really IOCIRecipeBuild, patched in _schema_circular_imports.
87 value_type=Reference(schema=Interface),
88- required=True, readonly=True)
89+ required=True, readonly=True)))
90
91- completed_builds = CollectionField(
92+ completed_builds = exported(doNotSnapshot(CollectionField(
93 title=_("Completed builds of this OCI recipe."),
94 description=_(
95 "Completed builds of this OCI recipe, sorted in descending "
96 "order of finishing."),
97 # Really IOCIRecipeBuild, patched in _schema_circular_imports.
98- value_type=Reference(schema=Interface), readonly=True)
99+ value_type=Reference(schema=Interface), readonly=True)))
100
101- pending_builds = CollectionField(
102+ pending_builds = exported(doNotSnapshot(CollectionField(
103 title=_("Pending builds of this OCI recipe."),
104 description=_(
105 "Pending builds of this OCI recipe, sorted in descending "
106 "order of creation."),
107 # Really IOCIRecipeBuild, patched in _schema_circular_imports.
108- value_type=Reference(schema=Interface), readonly=True)
109+ value_type=Reference(schema=Interface), readonly=True)))
110
111 def requestBuild(requester, architecture):
112 """Request that the OCI recipe is built.
113@@ -150,26 +155,26 @@ class IOCIRecipeEditableAttributes(IHasOwner):
114 These attributes need launchpad.View to see, and launchpad.Edit to change.
115 """
116
117- name = TextLine(
118+ name = exported(TextLine(
119 title=_("Name"),
120 description=_("The name of this recipe."),
121 constraint=name_validator,
122 required=True,
123- readonly=False)
124+ readonly=False))
125
126- owner = PersonChoice(
127+ owner = exported(PersonChoice(
128 title=_("Owner"),
129 required=True,
130 vocabulary="AllUserTeamsParticipationPlusSelf",
131 description=_("The owner of this OCI recipe."),
132- readonly=False)
133+ readonly=False))
134
135- oci_project = Reference(
136+ oci_project = exported(Reference(
137 IOCIProject,
138 title=_("OCI project"),
139 description=_("The OCI project that this recipe is for."),
140 required=True,
141- readonly=True)
142+ readonly=True))
143
144 official = Bool(
145 title=_("OCI project official"),
146@@ -178,11 +183,11 @@ class IOCIRecipeEditableAttributes(IHasOwner):
147 description=_("True if this recipe is official for its OCI project."),
148 readonly=False)
149
150- git_ref = Reference(
151+ git_ref = exported(Reference(
152 IGitRef, title=_("Git branch"), required=True, readonly=False,
153 description=_(
154 "The Git branch containing a Dockerfile at the location "
155- "defined by the build_file attribute."))
156+ "defined by the build_file attribute.")))
157
158 git_repository = ReferenceChoice(
159 title=_("Git repository"),
160@@ -198,26 +203,26 @@ class IOCIRecipeEditableAttributes(IHasOwner):
161 "The path of the Git branch containing a Dockerfile "
162 "at the location defined by the build_file attribute."))
163
164- description = Text(
165+ description = exported(Text(
166 title=_("Description"),
167 description=_("A short description of this recipe."),
168 required=False,
169- readonly=False)
170+ readonly=False))
171
172- build_file = TextLine(
173+ build_file = exported(TextLine(
174 title=_("Build file path"),
175 description=_("The relative path to the file within this recipe's "
176 "branch that defines how to build the recipe."),
177 constraint=path_does_not_escape,
178 required=True,
179- readonly=False)
180+ readonly=False))
181
182- build_daily = Bool(
183+ build_daily = exported(Bool(
184 title=_("Build daily"),
185 required=True,
186 default=False,
187 description=_("If True, this recipe should be built daily."),
188- readonly=False)
189+ readonly=False))
190
191
192 class IOCIRecipeAdminAttributes(Interface):
193@@ -235,6 +240,9 @@ class IOCIRecipe(IOCIRecipeView, IOCIRecipeEdit, IOCIRecipeEditableAttributes,
194 IOCIRecipeAdminAttributes):
195 """A recipe for building Open Container Initiative images."""
196
197+ export_as_webservice_entry(
198+ publish_web_link=True, as_of="devel", singular_name="oci_recipe")
199+
200
201 class IOCIRecipeSet(Interface):
202 """A utility to create and access OCI Recipes."""
203diff --git a/lib/lp/oci/interfaces/webservice.py b/lib/lp/oci/interfaces/webservice.py
204new file mode 100644
205index 0000000..0a6933b
206--- /dev/null
207+++ b/lib/lp/oci/interfaces/webservice.py
208@@ -0,0 +1,14 @@
209+# Copyright 2020 Canonical Ltd. This software is licensed under the
210+# GNU Affero General Public License version 3 (see the file LICENSE).
211+
212+"""All the interfaces that are exposed through the webservice."""
213+
214+__all__ = [
215+ 'IOCIProject',
216+ 'IOCIProjectSeries',
217+ 'IOCIRecipe',
218+ ]
219+
220+from lp.oci.interfaces.ocirecipe import IOCIRecipe
221+from lp.registry.interfaces.ociproject import IOCIProject
222+from lp.registry.interfaces.ociprojectseries import IOCIProjectSeries
223diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py
224index 7c07615..aa8f686 100644
225--- a/lib/lp/oci/tests/test_ocirecipe.py
226+++ b/lib/lp/oci/tests/test_ocirecipe.py
227@@ -5,9 +5,13 @@
228
229 from __future__ import absolute_import, print_function, unicode_literals
230
231+import json
232+
233 from fixtures import FakeLogger
234+from six import string_types
235 from storm.exceptions import LostObjectError
236 from testtools.matchers import (
237+ ContainsDict,
238 Equals,
239 MatchesDict,
240 MatchesStructure,
241@@ -35,16 +39,19 @@ from lp.services.database.constants import (
242 )
243 from lp.services.database.sqlbase import flush_database_caches
244 from lp.services.features.testing import FeatureFixture
245+from lp.services.webapp.interfaces import OAuthPermission
246 from lp.services.webapp.publisher import canonical_url
247 from lp.services.webapp.snapshot import notify_modified
248 from lp.services.webhooks.testing import LogsScheduledWebhooks
249 from lp.testing import (
250 admin_logged_in,
251+ api_url,
252 person_logged_in,
253 TestCaseWithFactory,
254 )
255 from lp.testing.dbuser import dbuser
256 from lp.testing.layers import DatabaseFunctionalLayer
257+from lp.testing.pages import webservice_for_person
258
259
260 class TestOCIRecipe(TestCaseWithFactory):
261@@ -351,3 +358,97 @@ class TestOCIRecipeSet(TestCaseWithFactory):
262 for oci_recipe in oci_recipes[:2]:
263 self.assertSqlAttributeEqualsDate(
264 oci_recipe, "date_last_modified", UTC_NOW)
265+
266+
267+class TestOCIRecipeWebservice(TestCaseWithFactory):
268+ layer = DatabaseFunctionalLayer
269+
270+ def setUp(self):
271+ super(TestOCIRecipeWebservice, self).setUp()
272+ self.person = self.factory.makePerson(displayname="Test Person")
273+ self.webservice = webservice_for_person(
274+ self.person, permission=OAuthPermission.WRITE_PUBLIC,
275+ default_api_version="devel")
276+
277+ def getAbsoluteURL(self, target):
278+ """Get the webservice absolute URL of the given object or relative
279+ path."""
280+ if not isinstance(target, string_types):
281+ target = api_url(target)
282+ return self.webservice.getAbsoluteUrl(target)
283+
284+ def load_from_api(self, url):
285+ response = self.webservice.get(url)
286+ self.assertEqual(200, response.status, response.body)
287+ return response.jsonBody()
288+
289+ def test_api_get_oci_recipe(self):
290+ with person_logged_in(self.person):
291+ project = removeSecurityProxy(self.factory.makeOCIProject(
292+ registrant=self.person))
293+ recipe = removeSecurityProxy(self.factory.makeOCIRecipe(
294+ oci_project=project))
295+ url = api_url(recipe)
296+
297+ ws_recipe = self.load_from_api(url)
298+
299+ recipe_abs_url = self.getAbsoluteURL(recipe)
300+ self.assertThat(ws_recipe, ContainsDict(dict(
301+ date_created=Equals(recipe.date_created.isoformat()),
302+ date_last_modified=Equals(recipe.date_last_modified.isoformat()),
303+ registrant_link=Equals(self.getAbsoluteURL(recipe.registrant)),
304+ pending_builds_collection_link=Equals(
305+ recipe_abs_url + "/pending_builds"),
306+ webhooks_collection_link=Equals(recipe_abs_url + "/webhooks"),
307+ name=Equals(recipe.name),
308+ owner_link=Equals(self.getAbsoluteURL(recipe.owner)),
309+ oci_project_link=Equals(self.getAbsoluteURL(project)),
310+ git_ref_link=Equals(self.getAbsoluteURL(recipe.git_ref)),
311+ description=Equals(recipe.description),
312+ build_file=Equals(recipe.build_file),
313+ build_daily=Equals(recipe.build_daily)
314+ )))
315+
316+ def test_api_patch_oci_recipe(self):
317+ with person_logged_in(self.person):
318+ distro = self.factory.makeDistribution(owner=self.person)
319+ project = removeSecurityProxy(self.factory.makeOCIProject(
320+ pillar=distro, registrant=self.person))
321+ # Only the owner should be able to edit.
322+ recipe = removeSecurityProxy(self.factory.makeOCIRecipe(
323+ oci_project=project, owner=self.person,
324+ registrant=self.person))
325+ url = api_url(recipe)
326+
327+ new_description = 'Some other description'
328+ resp = self.webservice.patch(
329+ url, 'application/json',
330+ json.dumps({'description': new_description}))
331+
332+ self.assertEqual(209, resp.status, resp.body)
333+
334+ ws_project = self.load_from_api(url)
335+ self.assertEqual(new_description, ws_project['description'])
336+
337+ def test_api_patch_fails_with_different_user(self):
338+ with admin_logged_in():
339+ other_person = self.factory.makePerson()
340+ with person_logged_in(other_person):
341+ distro = self.factory.makeDistribution(owner=other_person)
342+ project = removeSecurityProxy(self.factory.makeOCIProject(
343+ pillar=distro, registrant=other_person))
344+ # Only the owner should be able to edit.
345+ recipe = removeSecurityProxy(self.factory.makeOCIRecipe(
346+ oci_project=project, owner=other_person,
347+ registrant=other_person,
348+ description="old description"))
349+ url = api_url(recipe)
350+
351+ new_description = 'Some other description'
352+ resp = self.webservice.patch(
353+ url, 'application/json',
354+ json.dumps({'description': new_description}))
355+ self.assertEqual(401, resp.status, resp.body)
356+
357+ ws_project = self.load_from_api(url)
358+ self.assertEqual("old description", ws_project['description'])
359diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
360index 72864b9..5605a9f 100644
361--- a/lib/lp/registry/browser/configure.zcml
362+++ b/lib/lp/registry/browser/configure.zcml
363@@ -1,4 +1,4 @@
364-<!-- Copyright 2009-2019 Canonical Ltd. This software is licensed under the
365+<!-- Copyright 2009-2020 Canonical Ltd. This software is licensed under the
366 GNU Affero General Public License version 3 (see the file LICENSE).
367 -->
368
369@@ -611,6 +611,11 @@
370 path_expression="string:+oci/${name}"
371 attribute_to_parent="pillar"
372 />
373+ <browser:url
374+ for="lp.registry.interfaces.ociprojectseries.IOCIProjectSeries"
375+ path_expression="string:+series/${name}"
376+ attribute_to_parent="oci_project"
377+ />
378 <browser:navigation
379 module="lp.registry.browser.ociproject"
380 classes="OCIProjectNavigation"
381diff --git a/lib/lp/registry/browser/ociproject.py b/lib/lp/registry/browser/ociproject.py
382index 3562a03..5ee4205 100644
383--- a/lib/lp/registry/browser/ociproject.py
384+++ b/lib/lp/registry/browser/ociproject.py
385@@ -1,4 +1,4 @@
386-# Copyright 2019 Canonical Ltd. This software is licensed under the
387+# Copyright 2019-2020 Canonical Ltd. This software is licensed under the
388 # GNU Affero General Public License version 3 (see the file LICENSE).
389
390 """Views, menus, and traversal related to `OCIProject`s."""
391@@ -22,6 +22,7 @@ from lp.app.browser.launchpadform import (
392 LaunchpadEditFormView,
393 )
394 from lp.app.browser.tales import CustomizableFormatter
395+from lp.app.errors import NotFoundError
396 from lp.code.browser.vcslisting import TargetDefaultVCSNavigationMixin
397 from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
398 from lp.registry.interfaces.ociproject import (
399@@ -36,6 +37,7 @@ from lp.services.webapp import (
400 Navigation,
401 NavigationMenu,
402 StandardLaunchpadFacets,
403+ stepthrough,
404 )
405 from lp.services.webapp.breadcrumb import Breadcrumb
406 from lp.services.webapp.interfaces import IMultiFacetedBreadcrumb
407@@ -55,6 +57,13 @@ class OCIProjectNavigation(TargetDefaultVCSNavigationMixin, Navigation):
408
409 usedfor = IOCIProject
410
411+ @stepthrough('+series')
412+ def traverse_series(self, name):
413+ series = self.context.getSeriesByName(name)
414+ if series is None:
415+ raise NotFoundError('%s is not a valid series name' % name)
416+ return series
417+
418
419 @implementer(IMultiFacetedBreadcrumb)
420 class OCIProjectBreadcrumb(Breadcrumb):
421diff --git a/lib/lp/registry/interfaces/ociproject.py b/lib/lp/registry/interfaces/ociproject.py
422index e0b3c00..d33e7fb 100644
423--- a/lib/lp/registry/interfaces/ociproject.py
424+++ b/lib/lp/registry/interfaces/ociproject.py
425@@ -1,4 +1,4 @@
426-# Copyright 2019 Canonical Ltd. This software is licensed under the
427+# Copyright 2019-2020 Canonical Ltd. This software is licensed under the
428 # GNU Affero General Public License version 3 (see the file LICENSE).
429
430 """OCI Project interfaces."""
431@@ -11,15 +11,16 @@ __all__ = [
432 'IOCIProjectSet',
433 ]
434
435+from lazr.restful.declarations import (
436+ export_as_webservice_entry,
437+ exported,
438+ )
439 from lazr.restful.fields import (
440 CollectionField,
441 Reference,
442 ReferenceChoice,
443 )
444-from zope.interface import (
445- Attribute,
446- Interface,
447- )
448+from zope.interface import Interface
449 from zope.schema import (
450 Datetime,
451 Int,
452@@ -42,22 +43,27 @@ class IOCIProjectView(IHasGitRepositories, Interface):
453 """IOCIProject attributes that require launchpad.View permission."""
454
455 id = Int(title=_("ID"), required=True, readonly=True)
456- date_created = Datetime(
457- title=_("Date created"), required=True, readonly=True)
458- date_last_modified = Datetime(
459- title=_("Date last modified"), required=True, readonly=True)
460+ date_created = exported(Datetime(
461+ title=_("Date created"), required=True, readonly=True))
462+ date_last_modified = exported(Datetime(
463+ title=_("Date last modified"), required=True, readonly=True))
464
465- registrant = PublicPersonChoice(
466+ registrant = exported(PublicPersonChoice(
467 title=_("Registrant"),
468 description=_("The person that registered this project."),
469- vocabulary='ValidPersonOrTeam', required=True, readonly=True)
470+ vocabulary='ValidPersonOrTeam', required=True, readonly=True))
471
472- series = CollectionField(
473+ series = exported(CollectionField(
474 title=_("Series inside this OCI project."),
475 # Really IOCIProjectSeries
476- value_type=Reference(schema=Interface))
477+ value_type=Reference(schema=Interface)))
478
479- display_name = Attribute(_("Display name for this OCI project."))
480+ display_name = exported(TextLine(
481+ title=_("Display name for this OCI project."),
482+ required=True, readonly=True))
483+
484+ def getSeriesByName(name):
485+ """Get an OCIProjectSeries for this OCIProject by series' name."""
486
487
488 class IOCIProjectEditableAttributes(IBugTarget):
489@@ -66,23 +72,25 @@ class IOCIProjectEditableAttributes(IBugTarget):
490 These attributes need launchpad.View to see, and launchpad.Edit to change.
491 """
492
493- distribution = ReferenceChoice(
494+ distribution = exported(ReferenceChoice(
495 title=_("The distribution that this OCI project is associated with."),
496 schema=IDistribution, vocabulary="Distribution",
497- required=True, readonly=False)
498- name = TextLine(
499+ required=True, readonly=False))
500+ name = exported(TextLine(
501 title=_("Name"), required=True, readonly=False,
502 constraint=name_validator,
503- description=_("The name of this OCI project."))
504+ description=_("The name of this OCI project.")))
505 ociprojectname = Reference(
506 IOCIProjectName,
507 title=_("The name of this OCI project, as an `IOCIProjectName`."),
508 required=True,
509 readonly=True)
510- description = Text(title=_("The description for this OCI project."))
511- pillar = Reference(
512+ description = exported(Text(
513+ title=_("The description for this OCI project."),
514+ required=True, readonly=False))
515+ pillar = exported(Reference(
516 IDistribution,
517- title=_("The pillar containing this target."), readonly=True)
518+ title=_("The pillar containing this target."), readonly=True))
519
520
521 class IOCIProjectEdit(Interface):
522@@ -97,6 +105,9 @@ class IOCIProject(IOCIProjectView, IOCIProjectEdit,
523 IOCIProjectEditableAttributes):
524 """A project containing Open Container Initiative recipes."""
525
526+ export_as_webservice_entry(
527+ publish_web_link=True, as_of="devel", singular_name="oci_project")
528+
529
530 class IOCIProjectSet(Interface):
531 """A utility to create and access OCI Projects."""
532diff --git a/lib/lp/registry/interfaces/ociprojectseries.py b/lib/lp/registry/interfaces/ociprojectseries.py
533index 3019c22..274fc4f 100644
534--- a/lib/lp/registry/interfaces/ociprojectseries.py
535+++ b/lib/lp/registry/interfaces/ociprojectseries.py
536@@ -1,4 +1,4 @@
537-# Copyright 2019 Canonical Ltd. This software is licensed under the
538+# Copyright 2019-2020 Canonical Ltd. This software is licensed under the
539 # GNU Affero General Public License version 3 (see the file LICENSE).
540
541 """Interfaces to allow bug filing on multiple versions of an OCI Project."""
542@@ -12,6 +12,10 @@ __all__ = [
543 'IOCIProjectSeriesView',
544 ]
545
546+from lazr.restful.declarations import (
547+ export_as_webservice_entry,
548+ exported,
549+ )
550 from lazr.restful.fields import Reference
551 from zope.interface import Interface
552 from zope.schema import (
553@@ -34,20 +38,20 @@ class IOCIProjectSeriesView(Interface):
554
555 id = Int(title=_("ID"), required=True, readonly=True)
556
557- oci_project = Reference(
558+ oci_project = exported(Reference(
559 IOCIProject,
560 title=_("The OCI project that this series belongs to."),
561- required=True, readonly=True)
562+ required=True, readonly=True))
563
564- date_created = Datetime(
565+ date_created = exported(Datetime(
566 title=_("Date created"), required=True, readonly=True,
567 description=_(
568- "The date on which this series was created in Launchpad."))
569+ "The date on which this series was created in Launchpad.")))
570
571- registrant = PublicPersonChoice(
572+ registrant = exported(PublicPersonChoice(
573 title=_("Registrant"),
574 description=_("The person that registered this series."),
575- vocabulary='ValidPersonOrTeam', required=True, readonly=True)
576+ vocabulary='ValidPersonOrTeam', required=True, readonly=True))
577
578
579 class IOCIProjectSeriesEditableAttributes(Interface):
580@@ -56,18 +60,18 @@ class IOCIProjectSeriesEditableAttributes(Interface):
581 These attributes need launchpad.View to see, and launchpad.Edit to change.
582 """
583
584- name = TextLine(
585+ name = exported(TextLine(
586 title=_("Name"), constraint=name_validator,
587 required=True, readonly=False,
588- description=_("The name of this series."))
589+ description=_("The name of this series.")))
590
591- summary = Text(
592+ summary = exported(Text(
593 title=_("Summary"), required=True, readonly=False,
594- description=_("A brief summary of this series."))
595+ description=_("A brief summary of this series.")))
596
597- status = Choice(
598- title=_("Status"), required=True,
599- vocabulary=SeriesStatus)
600+ status = exported(Choice(
601+ title=_("Status"), required=True, readonly=False,
602+ vocabulary=SeriesStatus))
603
604
605 class IOCIProjectSeriesEdit(Interface):
606@@ -80,3 +84,7 @@ class IOCIProjectSeries(IOCIProjectSeriesView, IOCIProjectSeriesEdit,
607
608 This is used to allow tracking bugs against multiple versions of images.
609 """
610+
611+ export_as_webservice_entry(
612+ publish_web_link=True, as_of="devel",
613+ singular_name="oci_project_series")
614diff --git a/lib/lp/registry/model/ociproject.py b/lib/lp/registry/model/ociproject.py
615index c13ee66..0b1390a 100644
616--- a/lib/lp/registry/model/ociproject.py
617+++ b/lib/lp/registry/model/ociproject.py
618@@ -1,4 +1,4 @@
619-# Copyright 2019 Canonical Ltd. This software is licensed under the
620+# Copyright 2019-2020 Canonical Ltd. This software is licensed under the
621 # GNU Affero General Public License version 3 (see the file LICENSE).
622
623 """OCI Project implementation."""
624@@ -127,6 +127,9 @@ class OCIProject(BugTargetBase, StormBase):
625 ).order_by(OCIProjectSeries.date_created)
626 return ret
627
628+ def getSeriesByName(self, name):
629+ return self.series.find(OCIProjectSeries.name == name).one()
630+
631
632 @implementer(IOCIProjectSet)
633 class OCIProjectSet:
634diff --git a/lib/lp/registry/tests/test_ociproject.py b/lib/lp/registry/tests/test_ociproject.py
635index 42c5d7d..af1ca26 100644
636--- a/lib/lp/registry/tests/test_ociproject.py
637+++ b/lib/lp/registry/tests/test_ociproject.py
638@@ -1,4 +1,4 @@
639-# Copyright 2019 Canonical Ltd. This software is licensed under the
640+# Copyright 2019-2020 Canonical Ltd. This software is licensed under the
641 # GNU Affero General Public License version 3 (see the file LICENSE).
642
643 """Tests for `OCIProject` and `OCIProjectSet`."""
644@@ -7,21 +7,32 @@ from __future__ import absolute_import, print_function, unicode_literals
645
646 __metaclass__ = type
647
648+import json
649+
650+from six import string_types
651+from testtools.matchers import (
652+ ContainsDict,
653+ Equals,
654+ )
655 from testtools.testcase import ExpectedException
656 from zope.component import getUtility
657 from zope.security.interfaces import Unauthorized
658+from zope.security.proxy import removeSecurityProxy
659
660 from lp.registry.interfaces.ociproject import (
661 IOCIProject,
662 IOCIProjectSet,
663 )
664 from lp.registry.interfaces.ociprojectseries import IOCIProjectSeries
665+from lp.services.webapp.interfaces import OAuthPermission
666 from lp.testing import (
667 admin_logged_in,
668+ api_url,
669 person_logged_in,
670 TestCaseWithFactory,
671 )
672 from lp.testing.layers import DatabaseFunctionalLayer
673+from lp.testing.pages import webservice_for_person
674
675
676 class TestOCIProject(TestCaseWithFactory):
677@@ -120,3 +131,87 @@ class TestOCIProjectSet(TestCaseWithFactory):
678 IOCIProjectSet).getByDistributionAndName(
679 distribution, oci_project.ociprojectname.name)
680 self.assertEqual(oci_project, fetched_result)
681+
682+
683+class TestOCIProjectWebservice(TestCaseWithFactory):
684+ layer = DatabaseFunctionalLayer
685+
686+ def setUp(self):
687+ super(TestOCIProjectWebservice, self).setUp()
688+ self.person = self.factory.makePerson(displayname="Test Person")
689+ self.webservice = webservice_for_person(
690+ self.person, permission=OAuthPermission.WRITE_PUBLIC,
691+ default_api_version="devel")
692+
693+ def getAbsoluteURL(self, target):
694+ """Get the webservice absolute URL of the given object or relative
695+ path."""
696+ if not isinstance(target, string_types):
697+ target = api_url(target)
698+ return self.webservice.getAbsoluteUrl(target)
699+
700+ def load_from_api(self, url):
701+ response = self.webservice.get(url)
702+ self.assertEqual(200, response.status, response.body)
703+ return response.jsonBody()
704+
705+ def test_api_get_oci_project(self):
706+ with person_logged_in(self.person):
707+ person = removeSecurityProxy(self.person)
708+ project = removeSecurityProxy(self.factory.makeOCIProject(
709+ registrant=self.person))
710+ self.factory.makeOCIProjectSeries(
711+ oci_project=project, registrant=self.person)
712+ url = api_url(project)
713+
714+ ws_project = self.load_from_api(url)
715+
716+ series_url = "{project_path}/series".format(
717+ project_path=self.getAbsoluteURL(project))
718+
719+ self.assertThat(ws_project, ContainsDict(dict(
720+ date_created=Equals(project.date_created.isoformat()),
721+ date_last_modified=Equals(project.date_last_modified.isoformat()),
722+ display_name=Equals(project.display_name),
723+ registrant_link=Equals(self.getAbsoluteURL(person)),
724+ series_collection_link=Equals(series_url)
725+ )))
726+
727+ def test_api_save_oci_project(self):
728+ with person_logged_in(self.person):
729+ # Only the owner of the distribution (which is the pillar of the
730+ # OCIProject) is allowed to update its attributes.
731+ distro = self.factory.makeDistribution(owner=self.person)
732+ project = removeSecurityProxy(self.factory.makeOCIProject(
733+ registrant=self.person, pillar=distro))
734+ url = api_url(project)
735+
736+ new_description = 'Some other description'
737+ resp = self.webservice.patch(
738+ url, 'application/json',
739+ json.dumps({'description': new_description}))
740+ self.assertEqual(209, resp.status, resp.body)
741+
742+ ws_project = self.load_from_api(url)
743+ self.assertEqual(new_description, ws_project['description'])
744+
745+ def test_api_save_oci_project_prevents_updates_from_others(self):
746+ with admin_logged_in():
747+ other_person = self.factory.makePerson()
748+ with person_logged_in(other_person):
749+ # Only the owner of the distribution (which is the pillar of the
750+ # OCIProject) is allowed to update its attributes.
751+ distro = self.factory.makeDistribution(owner=other_person)
752+ project = removeSecurityProxy(self.factory.makeOCIProject(
753+ registrant=other_person, pillar=distro,
754+ description="old description"))
755+ url = api_url(project)
756+
757+ new_description = 'Some other description'
758+ resp = self.webservice.patch(
759+ url, 'application/json',
760+ json.dumps({'description': new_description}))
761+ self.assertEqual(401, resp.status, resp.body)
762+
763+ ws_project = self.load_from_api(url)
764+ self.assertEqual("old description", ws_project['description'])
765diff --git a/lib/lp/registry/tests/test_ociprojectseries.py b/lib/lp/registry/tests/test_ociprojectseries.py
766index f3da75c..a8f7ad7 100644
767--- a/lib/lp/registry/tests/test_ociprojectseries.py
768+++ b/lib/lp/registry/tests/test_ociprojectseries.py
769@@ -1,4 +1,4 @@
770-# Copyright 2019 Canonical Ltd. This software is licensed under the
771+# Copyright 2019-2020 Canonical Ltd. This software is licensed under the
772 # GNU Affero General Public License version 3 (see the file LICENSE).
773
774 """Test OCIProjectSeries."""
775@@ -7,20 +7,29 @@ from __future__ import absolute_import, print_function, unicode_literals
776
777 __metaclass__ = type
778
779-from testtools.matchers import MatchesStructure
780+from six import string_types
781+from testtools.matchers import (
782+ ContainsDict,
783+ Equals,
784+ MatchesStructure,
785+ )
786 from testtools.testcase import ExpectedException
787 from zope.security.interfaces import Unauthorized
788+from zope.security.proxy import removeSecurityProxy
789
790 from lp.registry.errors import InvalidName
791 from lp.registry.interfaces.ociprojectseries import IOCIProjectSeries
792 from lp.registry.interfaces.series import SeriesStatus
793 from lp.registry.model.ociprojectseries import OCIProjectSeries
794 from lp.services.database.constants import UTC_NOW
795+from lp.services.webapp.interfaces import OAuthPermission
796 from lp.testing import (
797+ api_url,
798 person_logged_in,
799 TestCaseWithFactory,
800 )
801 from lp.testing.layers import DatabaseFunctionalLayer
802+from lp.testing.pages import webservice_for_person
803
804
805 class TestOCIProjectSeries(TestCaseWithFactory):
806@@ -97,3 +106,62 @@ class TestOCIProjectSeries(TestCaseWithFactory):
807 project_series.name = 'allowed'
808
809 self.assertEqual(project_series.name, 'allowed')
810+
811+
812+class TestOCIProjectSeriesWebservice(TestCaseWithFactory):
813+ layer = DatabaseFunctionalLayer
814+
815+ def setUp(self):
816+ super(TestOCIProjectSeriesWebservice, self).setUp()
817+ self.person = self.factory.makePerson(displayname="Test Person")
818+ self.webservice = webservice_for_person(
819+ self.person, permission=OAuthPermission.WRITE_PUBLIC,
820+ default_api_version="devel")
821+
822+ def getAbsoluteURL(self, target):
823+ """Get the webservice absolute URL of the given object or relative
824+ path."""
825+ if not isinstance(target, string_types):
826+ target = api_url(target)
827+ return self.webservice.getAbsoluteUrl(target)
828+
829+ def load_from_api(self, url):
830+ response = self.webservice.get(url)
831+ self.assertEqual(200, response.status, response.body)
832+ return response.jsonBody()
833+
834+ def test_get_oci_project_series(self):
835+ with person_logged_in(self.person):
836+ person = removeSecurityProxy(self.person)
837+ project = removeSecurityProxy(self.factory.makeOCIProject(
838+ registrant=self.person))
839+ series = self.factory.makeOCIProjectSeries(
840+ oci_project=project, registrant=self.person)
841+ url = api_url(series)
842+
843+ expected_url = "{project}/+series/{name}".format(
844+ project=api_url(project), name=series.name)
845+ self.assertEqual(expected_url, url)
846+
847+ ws_series = self.load_from_api(url)
848+
849+ self.assertThat(ws_series, ContainsDict({
850+ 'date_created': Equals(series.date_created.isoformat()),
851+ 'name': Equals(series.name),
852+ 'oci_project_link': Equals(self.getAbsoluteURL(project)),
853+ 'registrant_link': Equals(self.getAbsoluteURL(series.registrant)),
854+ 'status': Equals(series.status.title),
855+ 'summary': Equals(series.summary),
856+ }))
857+
858+ def test_get_non_existent_series(self):
859+ with person_logged_in(self.person):
860+ project = removeSecurityProxy(self.factory.makeOCIProject(
861+ registrant=self.person))
862+ series = self.factory.makeOCIProjectSeries(
863+ oci_project=project, registrant=self.person)
864+
865+ url = "{project}/+series/{name}trash".format(
866+ project=api_url(project), name=series.name)
867+ resp = self.webservice.get(url + 'trash')
868+ self.assertEqual(404, resp.status, resp.body)
869diff --git a/lib/lp/services/webservice/wadl-to-refhtml.xsl b/lib/lp/services/webservice/wadl-to-refhtml.xsl
870index 19aa544..5664cab 100644
871--- a/lib/lp/services/webservice/wadl-to-refhtml.xsl
872+++ b/lib/lp/services/webservice/wadl-to-refhtml.xsl
873@@ -455,6 +455,30 @@
874 <xsl:text>/</xsl:text>
875 <var>&lt;name&gt;</var>
876 </xsl:when>
877+ <xsl:when test="@id = 'oci_project'">
878+ <xsl:text>/</xsl:text>
879+ <var>&lt;distribution.name&gt;</var>
880+ <xsl:text>/+oci/</xsl:text>
881+ <var>&lt;oci_project.name&gt;</var>
882+ </xsl:when>
883+ <xsl:when test="@id = 'oci_project_series'">
884+ <xsl:text>/</xsl:text>
885+ <var>&lt;distribution.name&gt;</var>
886+ <xsl:text>/+oci/</xsl:text>
887+ <var>&lt;oci_project.name&gt;</var>
888+ <xsl:text>/+series/</xsl:text>
889+ <var>&lt;oci_project_series.name&gt;</var>
890+ </xsl:when>
891+ <xsl:when test="@id = 'oci_recipe'">
892+ <xsl:text>/~</xsl:text>
893+ <var>&lt;person.name&gt;</var>
894+ <xsl:text>/</xsl:text>
895+ <var>&lt;distribution.name&gt;</var>
896+ <xsl:text>/+oci/</xsl:text>
897+ <var>&lt;oci_project.name&gt;</var>
898+ <xsl:text>/+recipe/</xsl:text>
899+ <var>&lt;oci_recipe.name&gt;</var>
900+ </xsl:when>
901 <xsl:when test="@id = 'team' or @id = 'person'">
902 <xsl:text>/~</xsl:text>
903 <var>&lt;name&gt;</var>
904diff --git a/lib/lp/soyuz/stories/ppa/xx-delete-packages.txt b/lib/lp/soyuz/stories/ppa/xx-delete-packages.txt
905index e16bb33..9384803 100644
906--- a/lib/lp/soyuz/stories/ppa/xx-delete-packages.txt
907+++ b/lib/lp/soyuz/stories/ppa/xx-delete-packages.txt
908@@ -378,6 +378,7 @@ section indicates to the user that it is still published.
909 >>> anon_browser.open(expander_url)
910 >>> print(extract_text(anon_browser.contents))
911 Publishing details
912+ Created ... ago by Foo Bar
913 Changelog
914 Builds
915 i386
916@@ -404,6 +405,7 @@ available for deletion because it contains a PUBLISHED binary.
917 >>> anon_browser.open(expander_url)
918 >>> print(extract_text(anon_browser.contents))
919 Publishing details
920+ Created ... ago by Foo Bar
921 Changelog
922 Builds
923 i386