Thiago F. Pappacena has proposed merging ~pappacena/launchpad:oci-api-create-project into launchpad:master.
Commit message: API operation to create a new OCIProject for a Distribution. The feature is only enabled if we turn on the 'oci.project.create.enabled' feature flag. Requested reviews: Launchpad code reviewers (launchpad-reviewers) For more details, see: https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/381189 -- Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:oci-api-create-project into launchpad:master.
diff --git a/lib/lp/_schema_circular_imports.py b/lib/lp/_schema_circular_imports.py index 70c5b72..a8d119e 100644 --- a/lib/lp/_schema_circular_imports.py +++ b/lib/lp/_schema_circular_imports.py @@ -120,6 +120,8 @@ from lp.registry.interfaces.milestone import ( IHasMilestones, IMilestone, ) +from lp.registry.interfaces.ociproject import IOCIProject +from lp.registry.interfaces.ociprojectseries import IOCIProjectSeries from lp.registry.interfaces.person import ( IPerson, IPersonEditRestricted, @@ -327,6 +329,8 @@ patch_entry_return_type(ISourcePackagePublic, 'getBranch', IBranch) patch_plain_parameter_type(ISourcePackageEdit, 'setBranch', 'branch', IBranch) patch_reference_property(ISourcePackage, 'distribution', IDistribution) +patch_entry_return_type(IDistribution, 'newOCIProject', IOCIProject) + # IPerson patch_entry_return_type(IPerson, 'createRecipe', ISourcePackageRecipe) patch_list_parameter_type(IPerson, 'createRecipe', 'distroseries', @@ -1093,6 +1097,9 @@ patch_operations_explicit_version( # IWikiName patch_entry_explicit_version(IWikiName, 'beta') +# IOCIProject +patch_collection_property(IOCIProject, 'series', IOCIProjectSeries) + # IOCIRecipe patch_collection_property(IOCIRecipe, 'builds', IOCIRecipeBuild) patch_collection_property(IOCIRecipe, 'completed_builds', IOCIRecipeBuild) diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml index da8d695..3382341 100644 --- a/lib/lp/oci/configure.zcml +++ b/lib/lp/oci/configure.zcml @@ -5,10 +5,13 @@ xmlns="http://namespaces.zope.org/zope" xmlns:i18n="http://namespaces.zope.org/i18n" xmlns:lp="http://namespaces.canonical.com/lp" + xmlns:webservice="http://namespaces.canonical.com/webservice" i18n_domain="launchpad"> <include package=".browser" /> + <webservice:register module="lp.oci.interfaces.webservice" /> + <!-- OCIRecipe --> <class class="lp.oci.model.ocirecipe.OCIRecipe"> diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py index 16926c7..b80c25e 100644 --- a/lib/lp/oci/interfaces/ocirecipe.py +++ b/lib/lp/oci/interfaces/ocirecipe.py @@ -20,7 +20,12 @@ __all__ = [ 'OCIRecipeNotOwner', ] -from lazr.restful.declarations import error_status +from lazr.lifecycle.snapshot import doNotSnapshot +from lazr.restful.declarations import ( + error_status, + export_as_webservice_entry, + exported, + ) from lazr.restful.fields import ( CollectionField, Reference, @@ -93,40 +98,40 @@ class IOCIRecipeView(Interface): """`IOCIRecipe` attributes that require launchpad.View permission.""" id = Int(title=_("ID"), required=True, readonly=True) - date_created = Datetime( - title=_("Date created"), required=True, readonly=True) - date_last_modified = Datetime( - title=_("Date last modified"), required=True, readonly=True) + date_created = exported(Datetime( + title=_("Date created"), required=True, readonly=True)) + date_last_modified = exported(Datetime( + title=_("Date last modified"), required=True, readonly=True)) - registrant = PublicPersonChoice( + registrant = exported(PublicPersonChoice( title=_("Registrant"), description=_("The user who registered this recipe."), - vocabulary='ValidPersonOrTeam', required=True, readonly=True) + vocabulary='ValidPersonOrTeam', required=True, readonly=True)) - builds = CollectionField( + builds = exported(doNotSnapshot(CollectionField( title=_("Completed builds of this OCI recipe."), description=_( "Completed builds of this OCI recipe, sorted in descending " "order of finishing."), # Really IOCIRecipeBuild, patched in _schema_circular_imports. value_type=Reference(schema=Interface), - required=True, readonly=True) + required=True, readonly=True))) - completed_builds = CollectionField( + completed_builds = exported(doNotSnapshot(CollectionField( title=_("Completed builds of this OCI recipe."), description=_( "Completed builds of this OCI recipe, sorted in descending " "order of finishing."), # Really IOCIRecipeBuild, patched in _schema_circular_imports. - value_type=Reference(schema=Interface), readonly=True) + value_type=Reference(schema=Interface), readonly=True))) - pending_builds = CollectionField( + pending_builds = exported(doNotSnapshot(CollectionField( title=_("Pending builds of this OCI recipe."), description=_( "Pending builds of this OCI recipe, sorted in descending " "order of creation."), # Really IOCIRecipeBuild, patched in _schema_circular_imports. - value_type=Reference(schema=Interface), readonly=True) + value_type=Reference(schema=Interface), readonly=True))) def requestBuild(requester, architecture): """Request that the OCI recipe is built. @@ -150,26 +155,26 @@ class IOCIRecipeEditableAttributes(IHasOwner): These attributes need launchpad.View to see, and launchpad.Edit to change. """ - name = TextLine( + name = exported(TextLine( title=_("Name"), description=_("The name of this recipe."), constraint=name_validator, required=True, - readonly=False) + readonly=False)) - owner = PersonChoice( + owner = exported(PersonChoice( title=_("Owner"), required=True, vocabulary="AllUserTeamsParticipationPlusSelf", description=_("The owner of this OCI recipe."), - readonly=False) + readonly=False)) - oci_project = Reference( + oci_project = exported(Reference( IOCIProject, title=_("OCI project"), description=_("The OCI project that this recipe is for."), required=True, - readonly=True) + readonly=True)) official = Bool( title=_("OCI project official"), @@ -178,11 +183,11 @@ class IOCIRecipeEditableAttributes(IHasOwner): description=_("True if this recipe is official for its OCI project."), readonly=False) - git_ref = Reference( + git_ref = exported(Reference( IGitRef, title=_("Git branch"), required=True, readonly=False, description=_( "The Git branch containing a Dockerfile at the location " - "defined by the build_file attribute.")) + "defined by the build_file attribute."))) git_repository = ReferenceChoice( title=_("Git repository"), @@ -198,26 +203,26 @@ class IOCIRecipeEditableAttributes(IHasOwner): "The path of the Git branch containing a Dockerfile " "at the location defined by the build_file attribute.")) - description = Text( + description = exported(Text( title=_("Description"), description=_("A short description of this recipe."), required=False, - readonly=False) + readonly=False)) - build_file = TextLine( + build_file = exported(TextLine( title=_("Build file path"), description=_("The relative path to the file within this recipe's " "branch that defines how to build the recipe."), constraint=path_does_not_escape, required=True, - readonly=False) + readonly=False)) - build_daily = Bool( + build_daily = exported(Bool( title=_("Build daily"), required=True, default=False, description=_("If True, this recipe should be built daily."), - readonly=False) + readonly=False)) class IOCIRecipeAdminAttributes(Interface): @@ -235,6 +240,9 @@ class IOCIRecipe(IOCIRecipeView, IOCIRecipeEdit, IOCIRecipeEditableAttributes, IOCIRecipeAdminAttributes): """A recipe for building Open Container Initiative images.""" + export_as_webservice_entry( + publish_web_link=True, as_of="devel", singular_name="oci_recipe") + class IOCIRecipeSet(Interface): """A utility to create and access OCI Recipes.""" diff --git a/lib/lp/oci/interfaces/webservice.py b/lib/lp/oci/interfaces/webservice.py new file mode 100644 index 0000000..0a6933b --- /dev/null +++ b/lib/lp/oci/interfaces/webservice.py @@ -0,0 +1,14 @@ +# Copyright 2020 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""All the interfaces that are exposed through the webservice.""" + +__all__ = [ + 'IOCIProject', + 'IOCIProjectSeries', + 'IOCIRecipe', + ] + +from lp.oci.interfaces.ocirecipe import IOCIRecipe +from lp.registry.interfaces.ociproject import IOCIProject +from lp.registry.interfaces.ociprojectseries import IOCIProjectSeries diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py index 7c07615..aa8f686 100644 --- a/lib/lp/oci/tests/test_ocirecipe.py +++ b/lib/lp/oci/tests/test_ocirecipe.py @@ -5,9 +5,13 @@ from __future__ import absolute_import, print_function, unicode_literals +import json + from fixtures import FakeLogger +from six import string_types from storm.exceptions import LostObjectError from testtools.matchers import ( + ContainsDict, Equals, MatchesDict, MatchesStructure, @@ -35,16 +39,19 @@ from lp.services.database.constants import ( ) from lp.services.database.sqlbase import flush_database_caches from lp.services.features.testing import FeatureFixture +from lp.services.webapp.interfaces import OAuthPermission from lp.services.webapp.publisher import canonical_url from lp.services.webapp.snapshot import notify_modified from lp.services.webhooks.testing import LogsScheduledWebhooks from lp.testing import ( admin_logged_in, + api_url, person_logged_in, TestCaseWithFactory, ) from lp.testing.dbuser import dbuser from lp.testing.layers import DatabaseFunctionalLayer +from lp.testing.pages import webservice_for_person class TestOCIRecipe(TestCaseWithFactory): @@ -351,3 +358,97 @@ class TestOCIRecipeSet(TestCaseWithFactory): for oci_recipe in oci_recipes[:2]: self.assertSqlAttributeEqualsDate( oci_recipe, "date_last_modified", UTC_NOW) + + +class TestOCIRecipeWebservice(TestCaseWithFactory): + layer = DatabaseFunctionalLayer + + def setUp(self): + super(TestOCIRecipeWebservice, self).setUp() + self.person = self.factory.makePerson(displayname="Test Person") + self.webservice = webservice_for_person( + self.person, permission=OAuthPermission.WRITE_PUBLIC, + default_api_version="devel") + + def getAbsoluteURL(self, target): + """Get the webservice absolute URL of the given object or relative + path.""" + if not isinstance(target, string_types): + target = api_url(target) + return self.webservice.getAbsoluteUrl(target) + + def load_from_api(self, url): + response = self.webservice.get(url) + self.assertEqual(200, response.status, response.body) + return response.jsonBody() + + def test_api_get_oci_recipe(self): + with person_logged_in(self.person): + project = removeSecurityProxy(self.factory.makeOCIProject( + registrant=self.person)) + recipe = removeSecurityProxy(self.factory.makeOCIRecipe( + oci_project=project)) + url = api_url(recipe) + + ws_recipe = self.load_from_api(url) + + recipe_abs_url = self.getAbsoluteURL(recipe) + self.assertThat(ws_recipe, ContainsDict(dict( + date_created=Equals(recipe.date_created.isoformat()), + date_last_modified=Equals(recipe.date_last_modified.isoformat()), + registrant_link=Equals(self.getAbsoluteURL(recipe.registrant)), + pending_builds_collection_link=Equals( + recipe_abs_url + "/pending_builds"), + webhooks_collection_link=Equals(recipe_abs_url + "/webhooks"), + name=Equals(recipe.name), + owner_link=Equals(self.getAbsoluteURL(recipe.owner)), + oci_project_link=Equals(self.getAbsoluteURL(project)), + git_ref_link=Equals(self.getAbsoluteURL(recipe.git_ref)), + description=Equals(recipe.description), + build_file=Equals(recipe.build_file), + build_daily=Equals(recipe.build_daily) + ))) + + def test_api_patch_oci_recipe(self): + with person_logged_in(self.person): + distro = self.factory.makeDistribution(owner=self.person) + project = removeSecurityProxy(self.factory.makeOCIProject( + pillar=distro, registrant=self.person)) + # Only the owner should be able to edit. + recipe = removeSecurityProxy(self.factory.makeOCIRecipe( + oci_project=project, owner=self.person, + registrant=self.person)) + url = api_url(recipe) + + new_description = 'Some other description' + resp = self.webservice.patch( + url, 'application/json', + json.dumps({'description': new_description})) + + self.assertEqual(209, resp.status, resp.body) + + ws_project = self.load_from_api(url) + self.assertEqual(new_description, ws_project['description']) + + def test_api_patch_fails_with_different_user(self): + with admin_logged_in(): + other_person = self.factory.makePerson() + with person_logged_in(other_person): + distro = self.factory.makeDistribution(owner=other_person) + project = removeSecurityProxy(self.factory.makeOCIProject( + pillar=distro, registrant=other_person)) + # Only the owner should be able to edit. + recipe = removeSecurityProxy(self.factory.makeOCIRecipe( + oci_project=project, owner=other_person, + registrant=other_person, + description="old description")) + url = api_url(recipe) + + new_description = 'Some other description' + resp = self.webservice.patch( + url, 'application/json', + json.dumps({'description': new_description})) + self.assertEqual(401, resp.status, resp.body) + + ws_project = self.load_from_api(url) + self.assertEqual("old description", ws_project['description']) diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml index 72864b9..5605a9f 100644 --- a/lib/lp/registry/browser/configure.zcml +++ b/lib/lp/registry/browser/configure.zcml @@ -1,4 +1,4 @@ -<!-- Copyright 2009-2019 Canonical Ltd. This software is licensed under the +<!-- Copyright 2009-2020 Canonical Ltd. This software is licensed under the GNU Affero General Public License version 3 (see the file LICENSE). --> @@ -611,6 +611,11 @@ path_expression="string:+oci/${name}" attribute_to_parent="pillar" /> + <browser:url + for="lp.registry.interfaces.ociprojectseries.IOCIProjectSeries" + path_expression="string:+series/${name}" + attribute_to_parent="oci_project" + /> <browser:navigation module="lp.registry.browser.ociproject" classes="OCIProjectNavigation" diff --git a/lib/lp/registry/browser/ociproject.py b/lib/lp/registry/browser/ociproject.py index 3562a03..5ee4205 100644 --- a/lib/lp/registry/browser/ociproject.py +++ b/lib/lp/registry/browser/ociproject.py @@ -1,4 +1,4 @@ -# Copyright 2019 Canonical Ltd. This software is licensed under the +# Copyright 2019-2020 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Views, menus, and traversal related to `OCIProject`s.""" @@ -22,6 +22,7 @@ from lp.app.browser.launchpadform import ( LaunchpadEditFormView, ) from lp.app.browser.tales import CustomizableFormatter +from lp.app.errors import NotFoundError from lp.code.browser.vcslisting import TargetDefaultVCSNavigationMixin from lp.oci.interfaces.ocirecipe import IOCIRecipeSet from lp.registry.interfaces.ociproject import ( @@ -36,6 +37,7 @@ from lp.services.webapp import ( Navigation, NavigationMenu, StandardLaunchpadFacets, + stepthrough, ) from lp.services.webapp.breadcrumb import Breadcrumb from lp.services.webapp.interfaces import IMultiFacetedBreadcrumb @@ -55,6 +57,13 @@ class OCIProjectNavigation(TargetDefaultVCSNavigationMixin, Navigation): usedfor = IOCIProject + @stepthrough('+series') + def traverse_series(self, name): + series = self.context.getSeriesByName(name) + if series is None: + raise NotFoundError('%s is not a valid series name' % name) + return series + @implementer(IMultiFacetedBreadcrumb) class OCIProjectBreadcrumb(Breadcrumb): diff --git a/lib/lp/registry/interfaces/distribution.py b/lib/lp/registry/interfaces/distribution.py index a7106f6..886b02e 100644 --- a/lib/lp/registry/interfaces/distribution.py +++ b/lib/lp/registry/interfaces/distribution.py @@ -22,6 +22,7 @@ from lazr.restful.declarations import ( collection_default_content, export_as_webservice_collection, export_as_webservice_entry, + export_factory_operation, export_operation_as, export_read_operation, exported, @@ -655,6 +656,40 @@ class IDistributionPublic( def userCanEdit(user): """Can the user edit this distribution?""" + # XXX: pappacena 2020-04-25: This method is sit on IDistributionPublic + # for now, until we workout the specific permission for creating OCI + # Projects. + @call_with(registrant=REQUEST_USER) + @operation_parameters( + ociprojectname=Text( + title=_("The OCI project name."), + description=_("The name that groups a set of OCI projects " + "together.")), + description=Text( + title=_("Description for this OCI project."), + description=_("A short description of this OCI project.")), + bug_reporting_guidelines=Text( + title=_("The guidelines to report a bug."), + description=_("What is the guideline to report a bug to this " + "OCI Project?")), + bug_reported_acknowledgement=Text( + title=_("Acknowledgement text for a bug reported."), + description=_("Acknowledgement text for a bug reported in this " + "OCI Project.")), + bugfiling_duplicate_search=Bool( + title=_("Show bug search before allowing to open a bug?"), + description=_("To avoid duplicate bugs, show to the user a bug " + "search before allowing them to create new bugs?")) + ) + # Interface is actually IOCIProject. Fixed at _schema_circular_imports + @export_factory_operation(Interface, []) + @operation_for_version("devel") + def newOCIProject( + registrant, ociprojectname, description=None, + bug_reporting_guidelines=None, bug_reported_acknowledgement=None, + bugfiling_duplicate_search=False): + """Create an `IOCIProject` for this distro.""" + class IDistribution( IDistributionEditRestricted, IDistributionPublic, IHasBugSupervisor, diff --git a/lib/lp/registry/interfaces/ociproject.py b/lib/lp/registry/interfaces/ociproject.py index e0b3c00..d33e7fb 100644 --- a/lib/lp/registry/interfaces/ociproject.py +++ b/lib/lp/registry/interfaces/ociproject.py @@ -1,4 +1,4 @@ -# Copyright 2019 Canonical Ltd. This software is licensed under the +# Copyright 2019-2020 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """OCI Project interfaces.""" @@ -11,15 +11,16 @@ __all__ = [ 'IOCIProjectSet', ] +from lazr.restful.declarations import ( + export_as_webservice_entry, + exported, + ) from lazr.restful.fields import ( CollectionField, Reference, ReferenceChoice, ) -from zope.interface import ( - Attribute, - Interface, - ) +from zope.interface import Interface from zope.schema import ( Datetime, Int, @@ -42,22 +43,27 @@ class IOCIProjectView(IHasGitRepositories, Interface): """IOCIProject attributes that require launchpad.View permission.""" id = Int(title=_("ID"), required=True, readonly=True) - date_created = Datetime( - title=_("Date created"), required=True, readonly=True) - date_last_modified = Datetime( - title=_("Date last modified"), required=True, readonly=True) + date_created = exported(Datetime( + title=_("Date created"), required=True, readonly=True)) + date_last_modified = exported(Datetime( + title=_("Date last modified"), required=True, readonly=True)) - registrant = PublicPersonChoice( + registrant = exported(PublicPersonChoice( title=_("Registrant"), description=_("The person that registered this project."), - vocabulary='ValidPersonOrTeam', required=True, readonly=True) + vocabulary='ValidPersonOrTeam', required=True, readonly=True)) - series = CollectionField( + series = exported(CollectionField( title=_("Series inside this OCI project."), # Really IOCIProjectSeries - value_type=Reference(schema=Interface)) + value_type=Reference(schema=Interface))) - display_name = Attribute(_("Display name for this OCI project.")) + display_name = exported(TextLine( + title=_("Display name for this OCI project."), + required=True, readonly=True)) + + def getSeriesByName(name): + """Get an OCIProjectSeries for this OCIProject by series' name.""" class IOCIProjectEditableAttributes(IBugTarget): @@ -66,23 +72,25 @@ class IOCIProjectEditableAttributes(IBugTarget): These attributes need launchpad.View to see, and launchpad.Edit to change. """ - distribution = ReferenceChoice( + distribution = exported(ReferenceChoice( title=_("The distribution that this OCI project is associated with."), schema=IDistribution, vocabulary="Distribution", - required=True, readonly=False) - name = TextLine( + required=True, readonly=False)) + name = exported(TextLine( title=_("Name"), required=True, readonly=False, constraint=name_validator, - description=_("The name of this OCI project.")) + description=_("The name of this OCI project."))) ociprojectname = Reference( IOCIProjectName, title=_("The name of this OCI project, as an `IOCIProjectName`."), required=True, readonly=True) - description = Text(title=_("The description for this OCI project.")) - pillar = Reference( + description = exported(Text( + title=_("The description for this OCI project."), + required=True, readonly=False)) + pillar = exported(Reference( IDistribution, - title=_("The pillar containing this target."), readonly=True) + title=_("The pillar containing this target."), readonly=True)) class IOCIProjectEdit(Interface): @@ -97,6 +105,9 @@ class IOCIProject(IOCIProjectView, IOCIProjectEdit, IOCIProjectEditableAttributes): """A project containing Open Container Initiative recipes.""" + export_as_webservice_entry( + publish_web_link=True, as_of="devel", singular_name="oci_project") + class IOCIProjectSet(Interface): """A utility to create and access OCI Projects.""" diff --git a/lib/lp/registry/interfaces/ociprojectseries.py b/lib/lp/registry/interfaces/ociprojectseries.py index 3019c22..274fc4f 100644 --- a/lib/lp/registry/interfaces/ociprojectseries.py +++ b/lib/lp/registry/interfaces/ociprojectseries.py @@ -1,4 +1,4 @@ -# Copyright 2019 Canonical Ltd. This software is licensed under the +# Copyright 2019-2020 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Interfaces to allow bug filing on multiple versions of an OCI Project.""" @@ -12,6 +12,10 @@ __all__ = [ 'IOCIProjectSeriesView', ] +from lazr.restful.declarations import ( + export_as_webservice_entry, + exported, + ) from lazr.restful.fields import Reference from zope.interface import Interface from zope.schema import ( @@ -34,20 +38,20 @@ class IOCIProjectSeriesView(Interface): id = Int(title=_("ID"), required=True, readonly=True) - oci_project = Reference( + oci_project = exported(Reference( IOCIProject, title=_("The OCI project that this series belongs to."), - required=True, readonly=True) + required=True, readonly=True)) - date_created = Datetime( + date_created = exported(Datetime( title=_("Date created"), required=True, readonly=True, description=_( - "The date on which this series was created in Launchpad.")) + "The date on which this series was created in Launchpad."))) - registrant = PublicPersonChoice( + registrant = exported(PublicPersonChoice( title=_("Registrant"), description=_("The person that registered this series."), - vocabulary='ValidPersonOrTeam', required=True, readonly=True) + vocabulary='ValidPersonOrTeam', required=True, readonly=True)) class IOCIProjectSeriesEditableAttributes(Interface): @@ -56,18 +60,18 @@ class IOCIProjectSeriesEditableAttributes(Interface): These attributes need launchpad.View to see, and launchpad.Edit to change. """ - name = TextLine( + name = exported(TextLine( title=_("Name"), constraint=name_validator, required=True, readonly=False, - description=_("The name of this series.")) + description=_("The name of this series."))) - summary = Text( + summary = exported(Text( title=_("Summary"), required=True, readonly=False, - description=_("A brief summary of this series.")) + description=_("A brief summary of this series."))) - status = Choice( - title=_("Status"), required=True, - vocabulary=SeriesStatus) + status = exported(Choice( + title=_("Status"), required=True, readonly=False, + vocabulary=SeriesStatus)) class IOCIProjectSeriesEdit(Interface): @@ -80,3 +84,7 @@ class IOCIProjectSeries(IOCIProjectSeriesView, IOCIProjectSeriesEdit, This is used to allow tracking bugs against multiple versions of images. """ + + export_as_webservice_entry( + publish_web_link=True, as_of="devel", + singular_name="oci_project_series") diff --git a/lib/lp/registry/model/distribution.py b/lib/lp/registry/model/distribution.py index f78908b..d82a375 100644 --- a/lib/lp/registry/model/distribution.py +++ b/lib/lp/registry/model/distribution.py @@ -35,6 +35,7 @@ from storm.info import ClassAlias from storm.store import Store from zope.component import getUtility from zope.interface import implementer +from zope.security.interfaces import Unauthorized from lp.answers.enums import QUESTION_STATUS_DEFAULT_SEARCH from lp.answers.model.faq import ( @@ -130,6 +131,7 @@ from lp.registry.model.milestone import ( HasMilestonesMixin, Milestone, ) +from lp.registry.model.ociproject import OCI_PROJECT_ALLOW_CREATE from lp.registry.model.oopsreferences import referenced_oops from lp.registry.model.pillar import HasAliasMixin from lp.registry.model.sourcepackagename import SourcePackageName @@ -147,6 +149,7 @@ from lp.services.database.stormexpr import ( fti_search, rank_by_fti, ) +from lp.services.features import getFeatureFlag from lp.services.helpers import shortlist from lp.services.propertycache import ( cachedproperty, @@ -1450,6 +1453,20 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements, return True return False + def newOCIProject(self, registrant, ociprojectname, description=None, + bug_reporting_guidelines=None, bug_reported_acknowledgement=None, + bugfiling_duplicate_search=False): + """Create an `IOCIProject` for this distro.""" + if not getFeatureFlag(OCI_PROJECT_ALLOW_CREATE): + raise Unauthorized("Creating new OCI projects is not allowed.") + return getUtility(IOCIProjectSet).new( + pillar=self, + registrant=registrant, ociprojectname=ociprojectname, + description=description, + bug_reporting_guidelines=bug_reporting_guidelines, + bug_reported_acknowledgement=bug_reported_acknowledgement, + bugfiling_duplicate_search=bugfiling_duplicate_search) + @implementer(IDistributionSet) class DistributionSet: diff --git a/lib/lp/registry/model/ociproject.py b/lib/lp/registry/model/ociproject.py index c13ee66..55b3ce3 100644 --- a/lib/lp/registry/model/ociproject.py +++ b/lib/lp/registry/model/ociproject.py @@ -1,4 +1,4 @@ -# Copyright 2019 Canonical Ltd. This software is licensed under the +# Copyright 2019-2020 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """OCI Project implementation.""" @@ -7,11 +7,13 @@ from __future__ import absolute_import, print_function, unicode_literals __metaclass__ = type __all__ = [ + 'OCI_PROJECT_ALLOW_CREATE', 'OCIProject', 'OCIProjectSet', ] import pytz +from six import string_types from storm.locals import ( Bool, DateTime, @@ -44,6 +46,9 @@ from lp.services.database.interfaces import ( from lp.services.database.stormbase import StormBase +OCI_PROJECT_ALLOW_CREATE = 'oci.project.create.enabled' + + def oci_project_modified(oci_project, event): """Update the date_last_modified property when an OCIProject is modified. @@ -127,6 +132,9 @@ class OCIProject(BugTargetBase, StormBase): ).order_by(OCIProjectSeries.date_created) return ret + def getSeriesByName(self, name): + return self.series.find(OCIProjectSeries.name == name).one() + @implementer(IOCIProjectSet) class OCIProjectSet: @@ -137,6 +145,9 @@ class OCIProjectSet: bug_reported_acknowledgement=None, bugfiling_duplicate_search=False): """See `IOCIProjectSet`.""" + if isinstance(ociprojectname, string_types): + ociprojectname = getUtility(IOCIProjectNameSet).getOrCreateByName( + ociprojectname) store = IMasterStore(OCIProject) target = OCIProject() target.date_created = date_created @@ -155,6 +166,7 @@ class OCIProjectSet: target.ociprojectname = ociprojectname target.description = description target.bug_reporting_guidelines = bug_reporting_guidelines + target.bug_reported_acknowledgement = bug_reported_acknowledgement target.enable_bugfiling_duplicate_search = bugfiling_duplicate_search store.add(target) return target diff --git a/lib/lp/registry/tests/test_ociproject.py b/lib/lp/registry/tests/test_ociproject.py index 42c5d7d..7e291ce 100644 --- a/lib/lp/registry/tests/test_ociproject.py +++ b/lib/lp/registry/tests/test_ociproject.py @@ -1,4 +1,4 @@ -# Copyright 2019 Canonical Ltd. This software is licensed under the +# Copyright 2019-2020 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Tests for `OCIProject` and `OCIProjectSet`.""" @@ -7,21 +7,39 @@ from __future__ import absolute_import, print_function, unicode_literals __metaclass__ = type +import json + +from six import string_types +from storm.store import Store +from testtools.matchers import ( + ContainsDict, + Equals, + ) from testtools.testcase import ExpectedException from zope.component import getUtility from zope.security.interfaces import Unauthorized +from zope.security.proxy import removeSecurityProxy from lp.registry.interfaces.ociproject import ( IOCIProject, IOCIProjectSet, ) from lp.registry.interfaces.ociprojectseries import IOCIProjectSeries +from lp.registry.model.ociproject import ( + OCI_PROJECT_ALLOW_CREATE, + OCIProject, + ) +from lp.services.features.testing import FeatureFixture +from lp.services.macaroons.testing import MatchesStructure +from lp.services.webapp.interfaces import OAuthPermission from lp.testing import ( admin_logged_in, + api_url, person_logged_in, TestCaseWithFactory, ) from lp.testing.layers import DatabaseFunctionalLayer +from lp.testing.pages import webservice_for_person class TestOCIProject(TestCaseWithFactory): @@ -120,3 +138,136 @@ class TestOCIProjectSet(TestCaseWithFactory): IOCIProjectSet).getByDistributionAndName( distribution, oci_project.ociprojectname.name) self.assertEqual(oci_project, fetched_result) + + +class TestOCIProjectWebservice(TestCaseWithFactory): + layer = DatabaseFunctionalLayer + + def setUp(self): + super(TestOCIProjectWebservice, self).setUp() + self.person = self.factory.makePerson(displayname="Test Person") + self.webservice = webservice_for_person( + self.person, permission=OAuthPermission.WRITE_PUBLIC, + default_api_version="devel") + self.useFixture(FeatureFixture({OCI_PROJECT_ALLOW_CREATE: 'on'})) + + def getAbsoluteURL(self, target): + """Get the webservice absolute URL of the given object or relative + path.""" + if not isinstance(target, string_types): + target = api_url(target) + return self.webservice.getAbsoluteUrl(target) + + def load_from_api(self, url): + response = self.webservice.get(url) + self.assertEqual(200, response.status, response.body) + return response.jsonBody() + + def test_api_get_oci_project(self): + with person_logged_in(self.person): + person = removeSecurityProxy(self.person) + project = removeSecurityProxy(self.factory.makeOCIProject( + registrant=self.person)) + self.factory.makeOCIProjectSeries( + oci_project=project, registrant=self.person) + url = api_url(project) + + ws_project = self.load_from_api(url) + + series_url = "{project_path}/series".format( + project_path=self.getAbsoluteURL(project)) + + self.assertThat(ws_project, ContainsDict(dict( + date_created=Equals(project.date_created.isoformat()), + date_last_modified=Equals(project.date_last_modified.isoformat()), + display_name=Equals(project.display_name), + registrant_link=Equals(self.getAbsoluteURL(person)), + series_collection_link=Equals(series_url) + ))) + + def test_api_save_oci_project(self): + with person_logged_in(self.person): + # Only the owner of the distribution (which is the pillar of the + # OCIProject) is allowed to update its attributes. + distro = self.factory.makeDistribution(owner=self.person) + project = removeSecurityProxy(self.factory.makeOCIProject( + registrant=self.person, pillar=distro)) + url = api_url(project) + + new_description = 'Some other description' + resp = self.webservice.patch( + url, 'application/json', + json.dumps({'description': new_description})) + self.assertEqual(209, resp.status, resp.body) + + ws_project = self.load_from_api(url) + self.assertEqual(new_description, ws_project['description']) + + def test_api_save_oci_project_prevents_updates_from_others(self): + with admin_logged_in(): + other_person = self.factory.makePerson() + with person_logged_in(other_person): + # Only the owner of the distribution (which is the pillar of the + # OCIProject) is allowed to update its attributes. + distro = self.factory.makeDistribution(owner=other_person) + project = removeSecurityProxy(self.factory.makeOCIProject( + registrant=other_person, pillar=distro, + description="old description")) + url = api_url(project) + + new_description = 'Some other description' + resp = self.webservice.patch( + url, 'application/json', + json.dumps({'description': new_description})) + self.assertEqual(401, resp.status, resp.body) + + ws_project = self.load_from_api(url) + self.assertEqual("old description", ws_project['description']) + + def test_create_oci_project(self): + with person_logged_in(self.person): + distro = removeSecurityProxy(self.factory.makeDistribution( + owner=self.person)) + url = api_url(distro) + + obj = { + "ociprojectname": "someprojectname", + "description": "My OCI project", + "bug_reporting_guidelines": "Bug reporting guide", + "bug_reported_acknowledgement": "Bug reporting ack", + "bugfiling_duplicate_search": True, + } + resp = self.webservice.named_post(url, "newOCIProject", **obj) + self.assertEqual(201, resp.status, resp.body) + + store = Store.of(distro) + result_set = [i for i in store.find(OCIProject)] + + self.assertEqual(1, len(result_set)) + self.assertThat(result_set[0], MatchesStructure( + ociprojectname=MatchesStructure( + name=Equals(obj["ociprojectname"])), + description=Equals(obj["description"]), + bug_reporting_guidelines=Equals(obj["bug_reporting_guidelines"]), + bug_reported_acknowledgement=Equals( + obj["bug_reported_acknowledgement"]), + enable_bugfiling_duplicate_search=Equals( + obj["bugfiling_duplicate_search"]) + )) + + def test_api_create_oci_project_is_disabled_by_feature_flag(self): + self.useFixture(FeatureFixture({OCI_PROJECT_ALLOW_CREATE: ''})) + with person_logged_in(self.person): + distro = removeSecurityProxy(self.factory.makeDistribution( + owner=self.person)) + url = api_url(distro) + + obj = { + "ociprojectname": "someprojectname", + "description": "My OCI project", + "bug_reporting_guidelines": "Bug reporting guide", + "bug_reported_acknowledgement": "Bug reporting ack", + "bugfiling_duplicate_search": True, + } + resp = self.webservice.named_post(url, "newOCIProject", **obj) + self.assertEqual(401, resp.status, resp.body) diff --git a/lib/lp/registry/tests/test_ociprojectseries.py b/lib/lp/registry/tests/test_ociprojectseries.py index f3da75c..a8f7ad7 100644 --- a/lib/lp/registry/tests/test_ociprojectseries.py +++ b/lib/lp/registry/tests/test_ociprojectseries.py @@ -1,4 +1,4 @@ -# Copyright 2019 Canonical Ltd. This software is licensed under the +# Copyright 2019-2020 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Test OCIProjectSeries.""" @@ -7,20 +7,29 @@ from __future__ import absolute_import, print_function, unicode_literals __metaclass__ = type -from testtools.matchers import MatchesStructure +from six import string_types +from testtools.matchers import ( + ContainsDict, + Equals, + MatchesStructure, + ) from testtools.testcase import ExpectedException from zope.security.interfaces import Unauthorized +from zope.security.proxy import removeSecurityProxy from lp.registry.errors import InvalidName from lp.registry.interfaces.ociprojectseries import IOCIProjectSeries from lp.registry.interfaces.series import SeriesStatus from lp.registry.model.ociprojectseries import OCIProjectSeries from lp.services.database.constants import UTC_NOW +from lp.services.webapp.interfaces import OAuthPermission from lp.testing import ( + api_url, person_logged_in, TestCaseWithFactory, ) from lp.testing.layers import DatabaseFunctionalLayer +from lp.testing.pages import webservice_for_person class TestOCIProjectSeries(TestCaseWithFactory): @@ -97,3 +106,62 @@ class TestOCIProjectSeries(TestCaseWithFactory): project_series.name = 'allowed' self.assertEqual(project_series.name, 'allowed') + + +class TestOCIProjectSeriesWebservice(TestCaseWithFactory): + layer = DatabaseFunctionalLayer + + def setUp(self): + super(TestOCIProjectSeriesWebservice, self).setUp() + self.person = self.factory.makePerson(displayname="Test Person") + self.webservice = webservice_for_person( + self.person, permission=OAuthPermission.WRITE_PUBLIC, + default_api_version="devel") + + def getAbsoluteURL(self, target): + """Get the webservice absolute URL of the given object or relative + path.""" + if not isinstance(target, string_types): + target = api_url(target) + return self.webservice.getAbsoluteUrl(target) + + def load_from_api(self, url): + response = self.webservice.get(url) + self.assertEqual(200, response.status, response.body) + return response.jsonBody() + + def test_get_oci_project_series(self): + with person_logged_in(self.person): + person = removeSecurityProxy(self.person) + project = removeSecurityProxy(self.factory.makeOCIProject( + registrant=self.person)) + series = self.factory.makeOCIProjectSeries( + oci_project=project, registrant=self.person) + url = api_url(series) + + expected_url = "{project}/+series/{name}".format( + project=api_url(project), name=series.name) + self.assertEqual(expected_url, url) + + ws_series = self.load_from_api(url) + + self.assertThat(ws_series, ContainsDict({ + 'date_created': Equals(series.date_created.isoformat()), + 'name': Equals(series.name), + 'oci_project_link': Equals(self.getAbsoluteURL(project)), + 'registrant_link': Equals(self.getAbsoluteURL(series.registrant)), + 'status': Equals(series.status.title), + 'summary': Equals(series.summary), + })) + + def test_get_non_existent_series(self): + with person_logged_in(self.person): + project = removeSecurityProxy(self.factory.makeOCIProject( + registrant=self.person)) + series = self.factory.makeOCIProjectSeries( + oci_project=project, registrant=self.person) + + url = "{project}/+series/{name}trash".format( + project=api_url(project), name=series.name) + resp = self.webservice.get(url + 'trash') + self.assertEqual(404, resp.status, resp.body) diff --git a/lib/lp/services/webservice/wadl-to-refhtml.xsl b/lib/lp/services/webservice/wadl-to-refhtml.xsl index 19aa544..5664cab 100644 --- a/lib/lp/services/webservice/wadl-to-refhtml.xsl +++ b/lib/lp/services/webservice/wadl-to-refhtml.xsl @@ -455,6 +455,30 @@ <xsl:text>/</xsl:text> <var><name></var> </xsl:when> + <xsl:when test="@id = 'oci_project'"> + <xsl:text>/</xsl:text> + <var><distribution.name></var> + <xsl:text>/+oci/</xsl:text> + <var><oci_project.name></var> + </xsl:when> + <xsl:when test="@id = 'oci_project_series'"> + <xsl:text>/</xsl:text> + <var><distribution.name></var> + <xsl:text>/+oci/</xsl:text> + <var><oci_project.name></var> + <xsl:text>/+series/</xsl:text> + <var><oci_project_series.name></var> + </xsl:when> + <xsl:when test="@id = 'oci_recipe'"> + <xsl:text>/~</xsl:text> + <var><person.name></var> + <xsl:text>/</xsl:text> + <var><distribution.name></var> + <xsl:text>/+oci/</xsl:text> + <var><oci_project.name></var> + <xsl:text>/+recipe/</xsl:text> + <var><oci_recipe.name></var> + </xsl:when> <xsl:when test="@id = 'team' or @id = 'person'"> <xsl:text>/~</xsl:text> <var><name></var>
_______________________________________________ Mailing list: https://launchpad.net/~launchpad-reviewers Post to : launchpad-reviewers@lists.launchpad.net Unsubscribe : https://launchpad.net/~launchpad-reviewers More help : https://help.launchpad.net/ListHelp