Merge ~pappacena/launchpad:oci-project-api into launchpad:master
- Git
- lp:~pappacena/launchpad
- oci-project-api
- Merge into master
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) |
Related bugs: |
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?
Colin Watson (cjwatson) wrote : | # |
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".
Colin Watson (cjwatson) wrote : | # |
For the new entry types, you should also edit the notably vile find-entry-uri template in lib/lp/
- 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
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.
Colin Watson (cjwatson) : | # |
- 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
Thiago F. Pappacena (pappacena) wrote : | # |
cjwatson, pushed the work to have OCIProjectSeries under <oci-project-
It should be good for a another round of review now.
Colin Watson (cjwatson) wrote : | # |
Mostly LGTM now, thanks! You have a merge conflict to fix though.
- 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
Thiago F. Pappacena (pappacena) wrote : | # |
I'll fix the conflict and top-approve it. Thanks!
- 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
1 | diff --git a/lib/lp/_schema_circular_imports.py b/lib/lp/_schema_circular_imports.py |
2 | index 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) |
24 | diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml |
25 | index 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"> |
42 | diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py |
43 | index 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.""" |
203 | diff --git a/lib/lp/oci/interfaces/webservice.py b/lib/lp/oci/interfaces/webservice.py |
204 | new file mode 100644 |
205 | index 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 |
223 | diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py |
224 | index 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']) |
359 | diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml |
360 | index 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" |
381 | diff --git a/lib/lp/registry/browser/ociproject.py b/lib/lp/registry/browser/ociproject.py |
382 | index 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): |
421 | diff --git a/lib/lp/registry/interfaces/ociproject.py b/lib/lp/registry/interfaces/ociproject.py |
422 | index 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.""" |
532 | diff --git a/lib/lp/registry/interfaces/ociprojectseries.py b/lib/lp/registry/interfaces/ociprojectseries.py |
533 | index 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") |
614 | diff --git a/lib/lp/registry/model/ociproject.py b/lib/lp/registry/model/ociproject.py |
615 | index 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: |
634 | diff --git a/lib/lp/registry/tests/test_ociproject.py b/lib/lp/registry/tests/test_ociproject.py |
635 | index 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']) |
765 | diff --git a/lib/lp/registry/tests/test_ociprojectseries.py b/lib/lp/registry/tests/test_ociprojectseries.py |
766 | index 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) |
869 | diff --git a/lib/lp/services/webservice/wadl-to-refhtml.xsl b/lib/lp/services/webservice/wadl-to-refhtml.xsl |
870 | index 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><name></var> |
876 | </xsl:when> |
877 | + <xsl:when test="@id = 'oci_project'"> |
878 | + <xsl:text>/</xsl:text> |
879 | + <var><distribution.name></var> |
880 | + <xsl:text>/+oci/</xsl:text> |
881 | + <var><oci_project.name></var> |
882 | + </xsl:when> |
883 | + <xsl:when test="@id = 'oci_project_series'"> |
884 | + <xsl:text>/</xsl:text> |
885 | + <var><distribution.name></var> |
886 | + <xsl:text>/+oci/</xsl:text> |
887 | + <var><oci_project.name></var> |
888 | + <xsl:text>/+series/</xsl:text> |
889 | + <var><oci_project_series.name></var> |
890 | + </xsl:when> |
891 | + <xsl:when test="@id = 'oci_recipe'"> |
892 | + <xsl:text>/~</xsl:text> |
893 | + <var><person.name></var> |
894 | + <xsl:text>/</xsl:text> |
895 | + <var><distribution.name></var> |
896 | + <xsl:text>/+oci/</xsl:text> |
897 | + <var><oci_project.name></var> |
898 | + <xsl:text>/+recipe/</xsl:text> |
899 | + <var><oci_recipe.name></var> |
900 | + </xsl:when> |
901 | <xsl:when test="@id = 'team' or @id = 'person'"> |
902 | <xsl:text>/~</xsl:text> |
903 | <var><name></var> |
904 | diff --git a/lib/lp/soyuz/stories/ppa/xx-delete-packages.txt b/lib/lp/soyuz/stories/ppa/xx-delete-packages.txt |
905 | index 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 |
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.