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>&lt;name&gt;</var>
             </xsl:when>
+            <xsl:when test="@id = 'oci_project'">
+                <xsl:text>/</xsl:text>
+                <var>&lt;distribution.name&gt;</var>
+                <xsl:text>/+oci/</xsl:text>
+                <var>&lt;oci_project.name&gt;</var>
+            </xsl:when>
+            <xsl:when test="@id = 'oci_project_series'">
+                <xsl:text>/</xsl:text>
+                <var>&lt;distribution.name&gt;</var>
+                <xsl:text>/+oci/</xsl:text>
+                <var>&lt;oci_project.name&gt;</var>
+                <xsl:text>/+series/</xsl:text>
+                <var>&lt;oci_project_series.name&gt;</var>
+            </xsl:when>
+            <xsl:when test="@id = 'oci_recipe'">
+                <xsl:text>/~</xsl:text>
+                <var>&lt;person.name&gt;</var>
+                <xsl:text>/</xsl:text>
+                <var>&lt;distribution.name&gt;</var>
+                <xsl:text>/+oci/</xsl:text>
+                <var>&lt;oci_project.name&gt;</var>
+                <xsl:text>/+recipe/</xsl:text>
+                <var>&lt;oci_recipe.name&gt;</var>
+            </xsl:when>
             <xsl:when test="@id = 'team' or @id = 'person'">
                 <xsl:text>/~</xsl:text>
                 <var>&lt;name&gt;</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

Reply via email to