launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #24687
[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