← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/launchpad:official-oci-recipe into launchpad:master

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:official-oci-recipe into launchpad:master.

Commit message:
Adding picker to select, on OCI project edit page, which recipe is the official one.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/383542
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:official-oci-recipe into launchpad:master.
diff --git a/lib/lp/app/widgets/popup.py b/lib/lp/app/widgets/popup.py
index 9e8fb73..29315c1 100644
--- a/lib/lp/app/widgets/popup.py
+++ b/lib/lp/app/widgets/popup.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2016 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).
 
 """Single selection widget using a popup to select one item from many."""
@@ -261,6 +261,11 @@ class PersonPickerWidget(VocabularyPickerWidget):
         return '/people/'
 
 
+class OCIRecipePickerWidget(VocabularyPickerWidget):
+    header = 'Select a Recipe'
+    step_title = 'Search for the recipe.'
+
+
 class BugTrackerPickerWidget(VocabularyPickerWidget):
 
     __call__ = ViewPageTemplateFile('templates/bugtracker-picker.pt')
diff --git a/lib/lp/oci/browser/fields/__init__.py b/lib/lp/oci/browser/fields/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/oci/browser/fields/__init__.py
diff --git a/lib/lp/oci/browser/fields/ocirecipe.py b/lib/lp/oci/browser/fields/ocirecipe.py
new file mode 100644
index 0000000..f53731a
--- /dev/null
+++ b/lib/lp/oci/browser/fields/ocirecipe.py
@@ -0,0 +1,21 @@
+# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+__all__ = [
+    'OCIRecipeField',
+    ]
+
+from zope.interface import implementer
+from zope.schema import Choice
+from zope.schema.interfaces import IChoice
+
+
+class IOCIRecipeField(IChoice):
+    pass
+
+
+@implementer(IOCIRecipeField)
+class OCIRecipeField(Choice):
+    pass
diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py
index 3388a4a..0d72e63 100644
--- a/lib/lp/oci/tests/test_ocirecipe.py
+++ b/lib/lp/oci/tests/test_ocirecipe.py
@@ -5,11 +5,9 @@
 
 from __future__ import absolute_import, print_function, unicode_literals
 
-from datetime import datetime
 import json
 
 from fixtures import FakeLogger
-from pytz import utc
 from six import (
     ensure_text,
     string_types,
@@ -62,7 +60,9 @@ from lp.services.webhooks.testing import LogsScheduledWebhooks
 from lp.testing import (
     admin_logged_in,
     api_url,
+    login_person,
     person_logged_in,
+    StormStatementRecorder,
     TestCaseWithFactory,
     )
 from lp.testing.dbuser import dbuser
@@ -387,6 +387,84 @@ class TestOCIRecipe(OCIConfigHelperMixin, TestCaseWithFactory):
                 recipe.newPushRule,
                 recipe.owner, url, image_name, credentials)
 
+    def test_set_recipe_as_official_for_oci_project(self):
+        owner = self.factory.makePerson()
+        login_person(owner)
+        oci_project1 = self.factory.makeOCIProject(registrant=owner)
+        oci_project2 = self.factory.makeOCIProject(registrant=owner)
+
+        oci_proj1_recipes = [
+            self.factory.makeOCIRecipe(oci_project=oci_project1,
+                                       registrant=owner),
+            self.factory.makeOCIRecipe(oci_project=oci_project1,
+                                       registrant=owner),
+            self.factory.makeOCIRecipe(oci_project=oci_project1,
+                                       registrant=owner)
+        ]
+
+        # Recipes for project 2
+        oci_proj2_recipes = [
+            self.factory.makeOCIRecipe(oci_project=oci_project2,
+                                       registrant=owner),
+            self.factory.makeOCIRecipe(oci_project=oci_project2,
+                                       registrant=owner)
+        ]
+
+        self.assertIsNone(oci_project1.getOfficialRecipe())
+        self.assertIsNone(oci_project2.getOfficialRecipe())
+        for recipe in oci_proj1_recipes + oci_proj2_recipes:
+            self.assertFalse(recipe.official)
+
+        # Set official for project1 and make sure nothing else got changed.
+        with StormStatementRecorder() as recorder:
+            oci_project1.setOfficialRecipe(oci_proj1_recipes[0])
+            self.assertEqual(1, recorder.count)
+
+        self.assertIsNone(oci_project2.getOfficialRecipe())
+        self.assertEqual(
+            oci_proj1_recipes[0], oci_project1.getOfficialRecipe())
+        self.assertTrue(oci_proj1_recipes[0].official)
+        for recipe in oci_proj1_recipes[1:] + oci_proj2_recipes:
+            self.assertFalse(recipe.official)
+
+        # Set back no recipe as official.
+        with StormStatementRecorder() as recorder:
+            oci_project1.setOfficialRecipe(None)
+            self.assertEqual(1, recorder.count)
+
+        for recipe in oci_proj1_recipes + oci_proj2_recipes:
+            self.assertFalse(recipe.official)
+
+    def test_set_recipe_as_official_for_wrong_oci_project(self):
+        owner = self.factory.makePerson()
+        login_person(owner)
+        oci_project = self.factory.makeOCIProject(registrant=owner)
+        another_oci_project = self.factory.makeOCIProject(registrant=owner)
+
+        recipe = self.factory.makeOCIRecipe(
+            oci_project=oci_project, registrant=owner)
+
+        self.assertRaises(
+            ValueError, another_oci_project.setOfficialRecipe, recipe)
+
+    def test_search_recipe_from_oci_project(self):
+        owner = self.factory.makePerson()
+        login_person(owner)
+        oci_project = self.factory.makeOCIProject(registrant=owner)
+        another_oci_project = self.factory.makeOCIProject(registrant=owner)
+
+        recipe1 = self.factory.makeOCIRecipe(
+            name="something", oci_project=oci_project, registrant=owner)
+        recipe2 = self.factory.makeOCIRecipe(
+            name="banana", oci_project=oci_project, registrant=owner)
+        recipe_from_another_project = self.factory.makeOCIRecipe(
+            name="something too", oci_project=another_oci_project,
+            registrant=owner)
+
+        self.assertEqual([recipe1], list(oci_project.searchRecipes("somet")))
+        self.assertEqual([recipe2], list(oci_project.searchRecipes("bana")))
+        self.assertEqual([], list(oci_project.searchRecipes("foo")))
+
 
 class TestOCIRecipeProcessors(TestCaseWithFactory):
 
@@ -901,7 +979,7 @@ class TestOCIRecipeWebservice(OCIConfigHelperMixin, TestCaseWithFactory):
             recipe = self.factory.makeOCIRecipe(
                 oci_project=oci_project, owner=self.person,
                 registrant=self.person)
-            push_rule = self.factory.makeOCIPushRule(
+            self.factory.makeOCIPushRule(
                 recipe=recipe, image_name=image_name)
             url = api_url(recipe)
 
diff --git a/lib/lp/oci/vocabularies.py b/lib/lp/oci/vocabularies.py
index 2e44624..2026609 100644
--- a/lib/lp/oci/vocabularies.py
+++ b/lib/lp/oci/vocabularies.py
@@ -8,9 +8,14 @@ from __future__ import absolute_import, print_function, unicode_literals
 __metaclass__ = type
 __all__ = []
 
+from zope.interface import implementer
 from zope.schema.vocabulary import SimpleTerm
 
-from lp.services.webapp.vocabulary import StormVocabularyBase
+from lp.oci.model.ocirecipe import OCIRecipe
+from lp.services.webapp.vocabulary import (
+    IHugeVocabulary,
+    StormVocabularyBase,
+    )
 from lp.soyuz.model.distroarchseries import DistroArchSeries
 
 
@@ -28,3 +33,32 @@ class OCIRecipeDistroArchSeriesVocabulary(StormVocabularyBase):
 
     def __len__(self):
         return len(self.context.getAllowedArchitectures())
+
+
+@implementer(IHugeVocabulary)
+class OCIRecipeVocabulary(StormVocabularyBase):
+    """All OCI Recipes of a given OCI Project."""
+
+    _table = OCIRecipe
+
+    def toTerm(self, recipe):
+        title = "~%s/%s" % (recipe.owner.name, recipe.name)
+        return SimpleTerm(recipe, title, title)
+
+    def getTermByToken(self, token):
+        # Remove the starting tilde, and split owner and recipe name.
+        owner_name, recipe_name = token[1:].split('/')
+        recipe = self.context.searchRecipes(recipe_name, owner_name).one()
+        if recipe is None:
+            raise LookupError(token)
+        return self.toTerm(recipe)
+
+    def search(self, query, vocab_filter=None):
+        return self.context.searchRecipes(query)
+
+    def __iter__(self):
+        for obj in self.context.getRecipes():
+            yield self.toTerm(obj)
+
+    def __len__(self):
+        return self.context.getRecipes().count()
diff --git a/lib/lp/oci/vocabularies.zcml b/lib/lp/oci/vocabularies.zcml
index fae4a6d..1a6b75c 100644
--- a/lib/lp/oci/vocabularies.zcml
+++ b/lib/lp/oci/vocabularies.zcml
@@ -15,4 +15,15 @@
         <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
     </class>
 
+    <securedutility
+        name="OCIRecipe"
+        component="lp.oci.vocabularies.OCIRecipeVocabulary"
+        provides="zope.schema.interfaces.IVocabularyFactory">
+        <allow interface="zope.schema.interfaces.IVocabularyFactory" />
+    </securedutility>
+
+    <class class="lp.oci.vocabularies.OCIRecipeVocabulary">
+        <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
+    </class>
+
 </configure>
diff --git a/lib/lp/registry/browser/ociproject.py b/lib/lp/registry/browser/ociproject.py
index 700af68..42db336 100644
--- a/lib/lp/registry/browser/ociproject.py
+++ b/lib/lp/registry/browser/ociproject.py
@@ -15,6 +15,7 @@ __all__ = [
     ]
 
 from zope.component import getUtility
+from zope.formlib import form
 from zope.interface import implementer
 
 from lp.app.browser.launchpadform import (
@@ -25,6 +26,7 @@ from lp.app.browser.launchpadform import (
 from lp.app.browser.tales import CustomizableFormatter
 from lp.app.errors import NotFoundError
 from lp.code.browser.vcslisting import TargetDefaultVCSNavigationMixin
+from lp.oci.browser.fields.ocirecipe import OCIRecipeField
 from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
 from lp.registry.interfaces.ociproject import (
     IOCIProject,
@@ -170,8 +172,18 @@ class OCIProjectEditView(LaunchpadEditFormView):
     field_names = [
         'distribution',
         'name',
+        'official_recipe',
         ]
 
+    def extendFields(self):
+        official_recipe = self.context.getOfficialRecipe()
+        self.form_fields += form.Fields(
+            OCIRecipeField(
+                __name__="official_recipe", title=u"Official recipe",
+                required=False, vocabulary="OCIRecipe",
+                default=official_recipe))
+        self.widget_errors['official_recipe'] = ''
+
     @property
     def label(self):
         return 'Edit %s OCI project' % self.context.name
@@ -193,7 +205,9 @@ class OCIProjectEditView(LaunchpadEditFormView):
 
     @action('Update OCI project', name='update')
     def update_action(self, action, data):
+        official_recipe = data.pop("official_recipe")
         self.updateContextFromData(data)
+        self.context.setOfficialRecipe(official_recipe)
 
     @property
     def next_url(self):
diff --git a/lib/lp/registry/browser/tests/test_ociproject.py b/lib/lp/registry/browser/tests/test_ociproject.py
index 13adfbf..597ba77 100644
--- a/lib/lp/registry/browser/tests/test_ociproject.py
+++ b/lib/lp/registry/browser/tests/test_ociproject.py
@@ -12,6 +12,7 @@ from datetime import datetime
 
 import pytz
 
+from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE
 from lp.registry.interfaces.ociproject import (
     OCI_PROJECT_ALLOW_CREATE,
     OCIProjectCreateFeatureDisabled,
@@ -21,6 +22,7 @@ from lp.services.features.testing import FeatureFixture
 from lp.services.webapp import canonical_url
 from lp.services.webapp.escaping import structured
 from lp.testing import (
+    admin_logged_in,
     BrowserTestCase,
     person_logged_in,
     test_tales,
@@ -126,7 +128,8 @@ class TestOCIProjectEditView(BrowserTestCase):
         with person_logged_in(oci_project.pillar.owner):
             view = create_initialized_view(
                 oci_project, name="+edit", principal=oci_project.pillar.owner)
-            view.update_action.success({"name": "changed"})
+            view.update_action.success(
+                {"name": "changed", "official_recipe": None})
         self.assertSqlAttributeEqualsDate(
             oci_project, "date_last_modified", UTC_NOW)
 
@@ -156,6 +159,31 @@ class TestOCIProjectEditView(BrowserTestCase):
             extract_text(find_tags_by_class(browser.contents, "message")[1]),
             "Invalid name 'invalid name'.")
 
+    def test_edit_oci_project_setting_official_recipe(self):
+        self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
+
+        with admin_logged_in():
+            oci_project = self.factory.makeOCIProject()
+            user = oci_project.pillar.owner
+            recipe1 = self.factory.makeOCIRecipe(
+                registrant=user, owner=user, oci_project=oci_project)
+            recipe2 = self.factory.makeOCIRecipe(
+                registrant=user, owner=user, oci_project=oci_project)
+
+            name_value = oci_project.name
+            recipe_value = "~%s/%s" % (user.name, recipe1.name)
+
+        browser = self.getViewBrowser(oci_project, user=user)
+        browser.getLink("Edit OCI project").click()
+        browser.getControl(name="field.name").value = name_value
+        browser.getControl(name="field.official_recipe").value = recipe_value
+        browser.getControl("Update OCI project").click()
+
+        with admin_logged_in():
+            self.assertEqual(recipe1, oci_project.getOfficialRecipe())
+            self.assertTrue(recipe1.official)
+            self.assertFalse(recipe2.official)
+
 
 class TestOCIProjectAddView(BrowserTestCase):
 
diff --git a/lib/lp/registry/interfaces/ociproject.py b/lib/lp/registry/interfaces/ociproject.py
index 7394079..be6df3c 100644
--- a/lib/lp/registry/interfaces/ociproject.py
+++ b/lib/lp/registry/interfaces/ociproject.py
@@ -54,7 +54,6 @@ from lp.services.fields import (
     PublicPersonChoice,
     )
 
-
 # XXX: pappacena 2020-04-20: It is ok to remove the feature flag since we
 # already have in place the correct permission check for this feature.
 OCI_PROJECT_ALLOW_CREATE = 'oci.project.create.enabled'
@@ -86,6 +85,18 @@ class IOCIProjectView(IHasGitRepositories, Interface):
     def getSeriesByName(name):
         """Get an OCIProjectSeries for this OCIProject by series' name."""
 
+    def getRecipes():
+        """Returns the set of OCI Recipes for this project."""
+
+    def searchRecipes(recipe_name, owner_name):
+        """Searches for recipes in this OCI project."""
+
+    def getOfficialRecipe():
+        """Gets the official recipe for this OCI project."""
+
+    def setOfficialRecipe(recipe):
+        """Sets the given recipe as the official one."""
+
 
 class IOCIProjectEditableAttributes(IBugTarget):
     """IOCIProject attributes that can be edited.
diff --git a/lib/lp/registry/model/ociproject.py b/lib/lp/registry/model/ociproject.py
index 2967007..6e7bad0 100644
--- a/lib/lp/registry/model/ociproject.py
+++ b/lib/lp/registry/model/ociproject.py
@@ -35,6 +35,7 @@ from lp.registry.interfaces.ociprojectname import IOCIProjectNameSet
 from lp.registry.interfaces.series import SeriesStatus
 from lp.registry.model.ociprojectname import OCIProjectName
 from lp.registry.model.ociprojectseries import OCIProjectSeries
+from lp.registry.model.person import Person
 from lp.services.database.constants import (
     DEFAULT,
     UTC_NOW,
@@ -147,6 +148,37 @@ class OCIProject(BugTargetBase, StormBase):
     def getSeriesByName(self, name):
         return self.series.find(OCIProjectSeries.name == name).one()
 
+    def getRecipes(self):
+        """See `IOCIProject`."""
+        from lp.oci.model.ocirecipe import OCIRecipe
+        return IStore(OCIRecipe).find(OCIRecipe, OCIRecipe.oci_project == self)
+
+    def searchRecipes(self, recipe_name, owner_name=None):
+        """See `IOCIProject`."""
+        from lp.oci.model.ocirecipe import OCIRecipe
+        q = self.getRecipes().find(
+            OCIRecipe.name.contains_string(recipe_name))
+        if owner_name is not None:
+            q = q.find(
+                OCIRecipe.owner_id == Person.id,
+                Person.name == owner_name)
+        return q
+
+    def getOfficialRecipe(self):
+        """See `IOCIProject`."""
+        from lp.oci.model.ocirecipe import OCIRecipe
+        return self.getRecipes().find(OCIRecipe.official == True).one()
+
+    def setOfficialRecipe(self, recipe):
+        """See `IOCIProject`."""
+        if recipe is not None and recipe.oci_project != self:
+            raise ValueError(
+                "OCI recipe cannot be set as official of another OCI project.")
+        from lp.oci.model.ocirecipe import OCIRecipe
+        recipes = self.getRecipes()
+        recipe_id = recipe.id if recipe else None
+        recipes.set(official=OCIRecipe.id == recipe_id)
+
 
 @implementer(IOCIProjectSet)
 class OCIProjectSet:
diff --git a/lib/lp/services/webapp/configure.zcml b/lib/lp/services/webapp/configure.zcml
index 633210a..b2ad11c 100644
--- a/lib/lp/services/webapp/configure.zcml
+++ b/lib/lp/services/webapp/configure.zcml
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2016 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).
 -->
 
@@ -403,6 +403,16 @@
       permission="zope.Public"
       />
 
+    <!-- Define the widget used by OCIRecipeChoice fields. -->
+    <view
+      type="zope.publisher.interfaces.browser.IBrowserRequest"
+      for="lp.oci.browser.fields.ocirecipe.OCIRecipeField
+        lp.services.webapp.vocabulary.IHugeVocabulary"
+      provides="zope.formlib.interfaces.IInputWidget"
+      factory="lp.app.widgets.popup.OCIRecipePickerWidget"
+      permission="zope.Public"
+      />
+
     <!-- Define the widget used by fields that use
          DistributionSourcePackageVocabulary. -->
     <view

Follow ups