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

Proposed by Thiago F. Pappacena
Status: Merged
Approved by: Thiago F. Pappacena
Approved revision: 2e682218c21de8331ecfde6b8ffc3805d05eff5d
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pappacena/launchpad:official-oci-recipe
Merge into: launchpad:master
Diff against target: 603 lines (+368/-18)
9 files modified
lib/lp/oci/interfaces/ocirecipe.py (+7/-7)
lib/lp/oci/model/ocirecipe.py (+9/-2)
lib/lp/oci/tests/test_ocirecipe.py (+153/-1)
lib/lp/oci/vocabularies.py (+33/-1)
lib/lp/oci/vocabularies.zcml (+11/-0)
lib/lp/registry/browser/ociproject.py (+13/-0)
lib/lp/registry/browser/tests/test_ociproject.py (+78/-7)
lib/lp/registry/interfaces/ociproject.py (+16/-0)
lib/lp/registry/model/ociproject.py (+48/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+383542@code.launchpad.net

Commit message

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

To post a comment you must log in.
Revision history for this message
Ioana Lasc (ilasc) wrote :

This looks to me like it has all the right elements but I would wait for a second review.

Revision history for this message
Colin Watson (cjwatson) :
review: Needs Fixing
Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

It should be good now for another round of review, cjwatson.

Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

I've made the requested changes, but we cannot remove IOCIRecipe._official attribute (it allows OCIProject.setOfficialRecipe to change the recipe's value).

Revision history for this message
Colin Watson (cjwatson) :
Revision history for this message
Thiago F. Pappacena (pappacena) :
Revision history for this message
Otto Co-Pilot (otto-copilot) wrote :
2e68221... by Thiago F. Pappacena

Merge branch 'master' into official-oci-recipe

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
2index 3163f0f..4b2b06d 100644
3--- a/lib/lp/oci/interfaces/ocirecipe.py
4+++ b/lib/lp/oci/interfaces/ocirecipe.py
5@@ -200,6 +200,13 @@ class IOCIRecipeView(Interface):
6 "The architectures that are available to be enabled or disabled for "
7 "this OCI recipe.")
8
9+ # This should only be set by using IOCIProject.setOfficialRecipe
10+ official = Bool(
11+ title=_("OCI project official"),
12+ required=False,
13+ description=_("True if this recipe is official for its OCI project."),
14+ readonly=True)
15+
16 @call_with(check_permissions=True, user=REQUEST_USER)
17 @operation_parameters(
18 processors=List(
19@@ -340,13 +347,6 @@ class IOCIRecipeEditableAttributes(IHasOwner):
20 required=True,
21 readonly=True))
22
23- official = Bool(
24- title=_("OCI project official"),
25- required=True,
26- default=False,
27- description=_("True if this recipe is official for its OCI project."),
28- readonly=False)
29-
30 git_ref = exported(Reference(
31 IGitRef, title=_("Git branch"), required=True, readonly=False,
32 description=_(
33diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
34index f894e7a..8562778 100644
35--- a/lib/lp/oci/model/ocirecipe.py
36+++ b/lib/lp/oci/model/ocirecipe.py
37@@ -130,7 +130,9 @@ class OCIRecipe(Storm, WebhookTargetMixin):
38 name = Unicode(name="name", allow_none=False)
39 description = Unicode(name="description", allow_none=True)
40
41- official = Bool(name="official", default=False)
42+ # OCIRecipe.official shouldn't be set directly. Instead, call
43+ # oci_project.setOfficialRecipe method.
44+ _official = Bool(name="official", default=False)
45
46 git_repository_id = Int(name="git_repository", allow_none=True)
47 git_repository = Reference(git_repository_id, "GitRepository.id")
48@@ -154,7 +156,7 @@ class OCIRecipe(Storm, WebhookTargetMixin):
49 self.oci_project = oci_project
50 self.description = description
51 self.build_file = build_file
52- self.official = official
53+ self._official = official
54 self.require_virtualized = require_virtualized
55 self.build_daily = build_daily
56 self.date_created = date_created
57@@ -165,6 +167,11 @@ class OCIRecipe(Storm, WebhookTargetMixin):
58 def valid_webhook_event_types(self):
59 return ["oci-recipe:build:0.1"]
60
61+ @property
62+ def official(self):
63+ """See `IOCIProject.setOfficialRecipe` method."""
64+ return self._official
65+
66 def destroySelf(self):
67 """See `IOCIRecipe`."""
68 # XXX twom 2019-11-26 This needs to expand as more build artifacts
69diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py
70index fac8638..74d15e0 100644
71--- a/lib/lp/oci/tests/test_ocirecipe.py
72+++ b/lib/lp/oci/tests/test_ocirecipe.py
73@@ -24,6 +24,10 @@ from testtools.matchers import (
74 )
75 import transaction
76 from zope.component import getUtility
77+from zope.security.interfaces import (
78+ ForbiddenAttribute,
79+ Unauthorized,
80+ )
81 from zope.security.proxy import removeSecurityProxy
82
83 from lp.buildmaster.enums import BuildStatus
84@@ -61,7 +65,10 @@ from lp.services.webhooks.testing import LogsScheduledWebhooks
85 from lp.testing import (
86 admin_logged_in,
87 api_url,
88+ login_admin,
89+ login_person,
90 person_logged_in,
91+ StormStatementRecorder,
92 TestCaseWithFactory,
93 )
94 from lp.testing.dbuser import dbuser
95@@ -441,6 +448,151 @@ class TestOCIRecipe(OCIConfigHelperMixin, TestCaseWithFactory):
96 recipe.newPushRule,
97 recipe.owner, url, image_name, credentials)
98
99+ def test_set_official_directly_is_forbidden(self):
100+ recipe = self.factory.makeOCIRecipe()
101+ self.assertRaises(
102+ ForbiddenAttribute, setattr, recipe, 'official', True)
103+
104+ def test_set_recipe_as_official_for_oci_project(self):
105+ distro = self.factory.makeDistribution()
106+ owner = distro.owner
107+ login_person(owner)
108+ oci_project1 = self.factory.makeOCIProject(
109+ registrant=owner, pillar=distro)
110+ oci_project2 = self.factory.makeOCIProject(
111+ registrant=owner, pillar=distro)
112+
113+ oci_proj1_recipes = [
114+ self.factory.makeOCIRecipe(
115+ oci_project=oci_project1, registrant=owner, owner=owner)
116+ for _ in range(3)]
117+
118+ # Recipes for project 2
119+ oci_proj2_recipes = [
120+ self.factory.makeOCIRecipe(
121+ oci_project=oci_project2, registrant=owner, owner=owner)
122+ for _ in range(2)]
123+
124+ self.assertIsNone(oci_project1.getOfficialRecipe())
125+ self.assertIsNone(oci_project2.getOfficialRecipe())
126+ for recipe in oci_proj1_recipes + oci_proj2_recipes:
127+ self.assertFalse(recipe.official)
128+
129+ # Set official for project1 and make sure nothing else got changed.
130+ with StormStatementRecorder() as recorder:
131+ oci_project1.setOfficialRecipe(oci_proj1_recipes[0])
132+ self.assertEqual(2, recorder.count)
133+
134+ self.assertIsNone(oci_project2.getOfficialRecipe())
135+ self.assertEqual(
136+ oci_proj1_recipes[0], oci_project1.getOfficialRecipe())
137+ self.assertTrue(oci_proj1_recipes[0].official)
138+ for recipe in oci_proj1_recipes[1:] + oci_proj2_recipes:
139+ self.assertFalse(recipe.official)
140+
141+ # Set back no recipe as official.
142+ with StormStatementRecorder() as recorder:
143+ oci_project1.setOfficialRecipe(None)
144+ self.assertEqual(1, recorder.count)
145+
146+ for recipe in oci_proj1_recipes + oci_proj2_recipes:
147+ self.assertFalse(recipe.official)
148+
149+ def test_set_recipe_as_official_for_wrong_oci_project(self):
150+ distro = self.factory.makeDistribution()
151+ owner = distro.owner
152+ login_person(owner)
153+ oci_project = self.factory.makeOCIProject(
154+ registrant=owner, pillar=distro)
155+ another_oci_project = self.factory.makeOCIProject(
156+ registrant=owner, pillar=distro)
157+
158+ recipe = self.factory.makeOCIRecipe(
159+ oci_project=oci_project, registrant=owner)
160+
161+ self.assertRaises(
162+ ValueError, another_oci_project.setOfficialRecipe, recipe)
163+
164+ def test_permission_check_on_setOfficialRecipe(self):
165+ distro = self.factory.makeDistribution()
166+ owner = distro.owner
167+ login_person(owner)
168+ oci_project = self.factory.makeOCIProject(
169+ registrant=owner, pillar=distro)
170+
171+ another_user = self.factory.makePerson()
172+ with person_logged_in(another_user):
173+ self.assertRaises(
174+ Unauthorized, getattr, oci_project, 'setOfficialRecipe')
175+
176+ def test_oci_project_get_recipe_by_name_and_owner(self):
177+ owner = self.factory.makePerson()
178+ login_person(owner)
179+ oci_project = self.factory.makeOCIProject(registrant=owner)
180+
181+ recipe = self.factory.makeOCIRecipe(
182+ oci_project=oci_project, registrant=owner, owner=owner,
183+ name="foo-recipe")
184+
185+ self.assertEqual(
186+ recipe,
187+ oci_project.getRecipeByNameAndOwner(recipe.name, owner.name))
188+ self.assertIsNone(
189+ oci_project.getRecipeByNameAndOwner(recipe.name, "someone"))
190+ self.assertIsNone(
191+ oci_project.getRecipeByNameAndOwner("some-recipe", owner.name))
192+
193+ def test_search_recipe_from_oci_project(self):
194+ owner = self.factory.makePerson()
195+ login_person(owner)
196+ oci_project = self.factory.makeOCIProject(registrant=owner)
197+ another_oci_project = self.factory.makeOCIProject(registrant=owner)
198+
199+ recipe1 = self.factory.makeOCIRecipe(
200+ name="a something", oci_project=oci_project, registrant=owner)
201+ recipe2 = self.factory.makeOCIRecipe(
202+ name="banana", oci_project=oci_project, registrant=owner)
203+ # Recipe from another project.
204+ self.factory.makeOCIRecipe(
205+ name="something too", oci_project=another_oci_project,
206+ registrant=owner)
207+
208+ self.assertEqual([recipe1], list(oci_project.searchRecipes("somet")))
209+ self.assertEqual([recipe2], list(oci_project.searchRecipes("bana")))
210+ self.assertEqual([], list(oci_project.searchRecipes("foo")))
211+
212+ def test_search_recipe_from_oci_project_is_ordered(self):
213+ login_admin()
214+ team = self.factory.makeTeam()
215+ owner1 = self.factory.makePerson(name="a-user")
216+ owner2 = self.factory.makePerson(name="b-user")
217+ owner3 = self.factory.makePerson(name="foo-person")
218+ team.addMember(owner1, team)
219+ team.addMember(owner2, team)
220+ team.addMember(owner3, team)
221+
222+ distro = self.factory.makeDistribution(oci_project_admin=team)
223+ oci_project = self.factory.makeOCIProject(
224+ registrant=team, pillar=distro)
225+ recipe1 = self.factory.makeOCIRecipe(
226+ name="same-name", oci_project=oci_project,
227+ registrant=owner1, owner=owner1)
228+ recipe2 = self.factory.makeOCIRecipe(
229+ name="same-name", oci_project=oci_project,
230+ registrant=owner2, owner=owner2)
231+ recipe3 = self.factory.makeOCIRecipe(
232+ name="a-first", oci_project=oci_project,
233+ registrant=owner1, owner=owner1)
234+ # This one should be filtered out.
235+ self.factory.makeOCIRecipe(
236+ name="xxx", oci_project=oci_project,
237+ registrant=owner3, owner=owner3)
238+
239+ # It should be sorted by owner's name first, then recipe name.
240+ self.assertEqual(
241+ [recipe3, recipe1, recipe2],
242+ list(oci_project.searchRecipes(u"a")))
243+
244
245 class TestOCIRecipeProcessors(TestCaseWithFactory):
246
247@@ -955,7 +1107,7 @@ class TestOCIRecipeWebservice(OCIConfigHelperMixin, TestCaseWithFactory):
248 recipe = self.factory.makeOCIRecipe(
249 oci_project=oci_project, owner=self.person,
250 registrant=self.person)
251- push_rule = self.factory.makeOCIPushRule(
252+ self.factory.makeOCIPushRule(
253 recipe=recipe, image_name=image_name)
254 url = api_url(recipe)
255
256diff --git a/lib/lp/oci/vocabularies.py b/lib/lp/oci/vocabularies.py
257index 2e44624..aa1fae3 100644
258--- a/lib/lp/oci/vocabularies.py
259+++ b/lib/lp/oci/vocabularies.py
260@@ -8,9 +8,14 @@ from __future__ import absolute_import, print_function, unicode_literals
261 __metaclass__ = type
262 __all__ = []
263
264+from zope.interface import implementer
265 from zope.schema.vocabulary import SimpleTerm
266
267-from lp.services.webapp.vocabulary import StormVocabularyBase
268+from lp.oci.model.ocirecipe import OCIRecipe
269+from lp.services.webapp.vocabulary import (
270+ IHugeVocabulary,
271+ StormVocabularyBase,
272+ )
273 from lp.soyuz.model.distroarchseries import DistroArchSeries
274
275
276@@ -28,3 +33,30 @@ class OCIRecipeDistroArchSeriesVocabulary(StormVocabularyBase):
277
278 def __len__(self):
279 return len(self.context.getAllowedArchitectures())
280+
281+
282+@implementer(IHugeVocabulary)
283+class OCIRecipeVocabulary(StormVocabularyBase):
284+ """All OCI Recipes of a given OCI project."""
285+
286+ _table = OCIRecipe
287+ displayname = 'Select a recipe'
288+ step_title = 'Search'
289+
290+ def toTerm(self, recipe):
291+ token = "%s/%s" % (recipe.owner.name, recipe.name)
292+ title = "~%s" % token
293+ return SimpleTerm(recipe, token, title)
294+
295+ def getTermByToken(self, token):
296+ owner_name, recipe_name = token.split('/')
297+ recipe = self.context.getRecipeByNameAndOwner(recipe_name, owner_name)
298+ if recipe is None:
299+ raise LookupError(token)
300+ return self.toTerm(recipe)
301+
302+ def search(self, query, vocab_filter=None):
303+ return self.context.searchRecipes(query)
304+
305+ def _entries(self):
306+ return self.context.getRecipes()
307diff --git a/lib/lp/oci/vocabularies.zcml b/lib/lp/oci/vocabularies.zcml
308index fae4a6d..1a6b75c 100644
309--- a/lib/lp/oci/vocabularies.zcml
310+++ b/lib/lp/oci/vocabularies.zcml
311@@ -15,4 +15,15 @@
312 <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
313 </class>
314
315+ <securedutility
316+ name="OCIRecipe"
317+ component="lp.oci.vocabularies.OCIRecipeVocabulary"
318+ provides="zope.schema.interfaces.IVocabularyFactory">
319+ <allow interface="zope.schema.interfaces.IVocabularyFactory" />
320+ </securedutility>
321+
322+ <class class="lp.oci.vocabularies.OCIRecipeVocabulary">
323+ <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
324+ </class>
325+
326 </configure>
327diff --git a/lib/lp/registry/browser/ociproject.py b/lib/lp/registry/browser/ociproject.py
328index 700af68..eedec0c 100644
329--- a/lib/lp/registry/browser/ociproject.py
330+++ b/lib/lp/registry/browser/ociproject.py
331@@ -15,7 +15,9 @@ __all__ = [
332 ]
333
334 from zope.component import getUtility
335+from zope.formlib import form
336 from zope.interface import implementer
337+from zope.schema import Choice
338
339 from lp.app.browser.launchpadform import (
340 action,
341@@ -170,8 +172,17 @@ class OCIProjectEditView(LaunchpadEditFormView):
342 field_names = [
343 'distribution',
344 'name',
345+ 'official_recipe',
346 ]
347
348+ def extendFields(self):
349+ official_recipe = self.context.getOfficialRecipe()
350+ self.form_fields += form.Fields(
351+ Choice(
352+ __name__="official_recipe", title=u"Official recipe",
353+ required=False, vocabulary="OCIRecipe",
354+ default=official_recipe))
355+
356 @property
357 def label(self):
358 return 'Edit %s OCI project' % self.context.name
359@@ -193,7 +204,9 @@ class OCIProjectEditView(LaunchpadEditFormView):
360
361 @action('Update OCI project', name='update')
362 def update_action(self, action, data):
363+ official_recipe = data.pop("official_recipe")
364 self.updateContextFromData(data)
365+ self.context.setOfficialRecipe(official_recipe)
366
367 @property
368 def next_url(self):
369diff --git a/lib/lp/registry/browser/tests/test_ociproject.py b/lib/lp/registry/browser/tests/test_ociproject.py
370index 5d08e1c..5a22137 100644
371--- a/lib/lp/registry/browser/tests/test_ociproject.py
372+++ b/lib/lp/registry/browser/tests/test_ociproject.py
373@@ -13,6 +13,7 @@ from datetime import datetime
374
375 import pytz
376
377+from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE
378 from lp.registry.interfaces.ociproject import (
379 OCI_PROJECT_ALLOW_CREATE,
380 OCIProjectCreateFeatureDisabled,
381@@ -22,6 +23,7 @@ from lp.services.features.testing import FeatureFixture
382 from lp.services.webapp import canonical_url
383 from lp.services.webapp.escaping import structured
384 from lp.testing import (
385+ admin_logged_in,
386 BrowserTestCase,
387 person_logged_in,
388 test_tales,
389@@ -94,6 +96,13 @@ class TestOCIProjectEditView(BrowserTestCase):
390
391 layer = DatabaseFunctionalLayer
392
393+ def submitEditForm(self, browser, name, official_recipe=''):
394+ browser.getLink("Edit OCI project").click()
395+ browser.getControl(name="field.name").value = name
396+ browser.getControl(name="field.official_recipe").value = (
397+ official_recipe)
398+ browser.getControl("Update OCI project").click()
399+
400 def test_edit_oci_project(self):
401 oci_project = self.factory.makeOCIProject()
402 new_distribution = self.factory.makeDistribution(
403@@ -127,7 +136,8 @@ class TestOCIProjectEditView(BrowserTestCase):
404 with person_logged_in(oci_project.pillar.owner):
405 view = create_initialized_view(
406 oci_project, name="+edit", principal=oci_project.pillar.owner)
407- view.update_action.success({"name": "changed"})
408+ view.update_action.success(
409+ {"name": "changed", "official_recipe": None})
410 self.assertSqlAttributeEqualsDate(
411 oci_project, "date_last_modified", UTC_NOW)
412
413@@ -138,9 +148,7 @@ class TestOCIProjectEditView(BrowserTestCase):
414 pillar_display_name = oci_project.pillar.display_name
415 browser = self.getViewBrowser(
416 oci_project, user=oci_project.pillar.owner)
417- browser.getLink("Edit OCI project").click()
418- browser.getControl(name="field.name").value = "two"
419- browser.getControl("Update OCI project").click()
420+ self.submitEditForm(browser, "two")
421 self.assertEqual(
422 "There is already an OCI project in %s with this name." % (
423 pillar_display_name),
424@@ -150,13 +158,76 @@ class TestOCIProjectEditView(BrowserTestCase):
425 oci_project = self.factory.makeOCIProject()
426 browser = self.getViewBrowser(
427 oci_project, user=oci_project.pillar.owner)
428- browser.getLink("Edit OCI project").click()
429- browser.getControl(name="field.name").value = "invalid name"
430- browser.getControl("Update OCI project").click()
431+ self.submitEditForm(browser, "invalid name")
432+
433 self.assertStartsWith(
434 extract_text(find_tags_by_class(browser.contents, "message")[1]),
435 "Invalid name 'invalid name'.")
436
437+ def test_edit_oci_project_setting_official_recipe(self):
438+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
439+
440+ with admin_logged_in():
441+ oci_project = self.factory.makeOCIProject()
442+ user = oci_project.pillar.owner
443+ recipe1 = self.factory.makeOCIRecipe(
444+ registrant=user, owner=user, oci_project=oci_project)
445+ recipe2 = self.factory.makeOCIRecipe(
446+ registrant=user, owner=user, oci_project=oci_project)
447+
448+ name_value = oci_project.name
449+ recipe_value = "%s/%s" % (user.name, recipe1.name)
450+
451+ browser = self.getViewBrowser(oci_project, user=user)
452+ self.submitEditForm(browser, name_value, recipe_value)
453+
454+ with admin_logged_in():
455+ self.assertEqual(recipe1, oci_project.getOfficialRecipe())
456+ self.assertTrue(recipe1.official)
457+ self.assertFalse(recipe2.official)
458+
459+ def test_edit_oci_project_overriding_official_recipe(self):
460+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
461+ with admin_logged_in():
462+ oci_project = self.factory.makeOCIProject()
463+ user = oci_project.pillar.owner
464+ recipe1 = self.factory.makeOCIRecipe(
465+ registrant=user, owner=user, oci_project=oci_project)
466+ recipe2 = self.factory.makeOCIRecipe(
467+ registrant=user, owner=user, oci_project=oci_project)
468+
469+ # Sets recipe1 as the current official one
470+ oci_project.setOfficialRecipe(recipe1)
471+
472+ # And we will try to set recipe2 as the new official.
473+ name_value = oci_project.name
474+ recipe_value = "%s/%s" % (user.name, recipe2.name)
475+
476+ browser = self.getViewBrowser(oci_project, user=user)
477+ self.submitEditForm(browser, name_value, recipe_value)
478+
479+ with admin_logged_in():
480+ self.assertEqual(recipe2, oci_project.getOfficialRecipe())
481+ self.assertFalse(recipe1.official)
482+ self.assertTrue(recipe2.official)
483+
484+ def test_edit_oci_project_unsetting_official_recipe(self):
485+ self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
486+ with admin_logged_in():
487+ oci_project = self.factory.makeOCIProject()
488+ user = oci_project.pillar.owner
489+ recipe = self.factory.makeOCIRecipe(
490+ registrant=user, owner=user, oci_project=oci_project)
491+ oci_project.setOfficialRecipe(recipe)
492+ name_value = oci_project.name
493+
494+ browser = self.getViewBrowser(oci_project, user=user)
495+ self.submitEditForm(browser, name_value, '')
496+
497+ with admin_logged_in():
498+ self.assertEqual(None, oci_project.getOfficialRecipe())
499+ self.assertFalse(recipe.official)
500+
501
502 class TestOCIProjectAddView(BrowserTestCase):
503
504diff --git a/lib/lp/registry/interfaces/ociproject.py b/lib/lp/registry/interfaces/ociproject.py
505index 6d0b9bc..8e66f5d 100644
506--- a/lib/lp/registry/interfaces/ociproject.py
507+++ b/lib/lp/registry/interfaces/ociproject.py
508@@ -85,6 +85,18 @@ class IOCIProjectView(IHasGitRepositories, Interface):
509 def getSeriesByName(name):
510 """Get an OCIProjectSeries for this OCIProject by series' name."""
511
512+ def getRecipeByNameAndOwner(recipe_name, owner_name):
513+ """Returns the exact match search for recipe_name AND owner_name."""
514+
515+ def getRecipes():
516+ """Returns the set of OCI recipes for this project."""
517+
518+ def searchRecipes(query):
519+ """Searches for recipes in this OCI project."""
520+
521+ def getOfficialRecipe():
522+ """Gets the official recipe for this OCI project."""
523+
524
525 class IOCIProjectEditableAttributes(IBugTarget):
526 """IOCIProject attributes that can be edited.
527@@ -120,6 +132,10 @@ class IOCIProjectEdit(Interface):
528 status=SeriesStatus.DEVELOPMENT, date_created=DEFAULT):
529 """Creates a new `IOCIProjectSeries`."""
530
531+ def setOfficialRecipe(recipe):
532+ """Sets the given recipe as the official one. If recipe is None,
533+ the current official recipe will be unset."""
534+
535
536 class IOCIProjectLegitimate(Interface):
537 """IOCIProject methods that require launchpad.AnyLegitimatePerson
538diff --git a/lib/lp/registry/model/ociproject.py b/lib/lp/registry/model/ociproject.py
539index 230f32a..0d94e38 100644
540--- a/lib/lp/registry/model/ociproject.py
541+++ b/lib/lp/registry/model/ociproject.py
542@@ -36,6 +36,7 @@ from lp.registry.interfaces.person import IPersonSet
543 from lp.registry.interfaces.series import SeriesStatus
544 from lp.registry.model.ociprojectname import OCIProjectName
545 from lp.registry.model.ociprojectseries import OCIProjectSeries
546+from lp.registry.model.person import Person
547 from lp.services.database.bulk import load_related
548 from lp.services.database.constants import (
549 DEFAULT,
550@@ -149,6 +150,53 @@ class OCIProject(BugTargetBase, StormBase):
551 def getSeriesByName(self, name):
552 return self.series.find(OCIProjectSeries.name == name).one()
553
554+ def getRecipes(self):
555+ """See `IOCIProject`."""
556+ from lp.oci.model.ocirecipe import OCIRecipe
557+ rs = IStore(OCIRecipe).find(
558+ OCIRecipe,
559+ OCIRecipe.owner_id == Person.id,
560+ OCIRecipe.oci_project == self)
561+ return rs.order_by(Person.name, OCIRecipe.name)
562+
563+ def getRecipeByNameAndOwner(self, recipe_name, owner_name):
564+ """See `IOCIProject`."""
565+ from lp.oci.model.ocirecipe import OCIRecipe
566+ q = self.getRecipes().find(
567+ OCIRecipe.name == recipe_name,
568+ Person.name == owner_name)
569+ return q.one()
570+
571+ def searchRecipes(self, query):
572+ """See `IOCIProject`."""
573+ from lp.oci.model.ocirecipe import OCIRecipe
574+ q = self.getRecipes().find(
575+ OCIRecipe.name.contains_string(query) |
576+ Person.name.contains_string(query))
577+ return q.order_by(Person.name, OCIRecipe.name)
578+
579+ def getOfficialRecipe(self):
580+ """See `IOCIProject`."""
581+ from lp.oci.model.ocirecipe import OCIRecipe
582+ return self.getRecipes().find(OCIRecipe._official == True).one()
583+
584+ def setOfficialRecipe(self, recipe):
585+ """See `IOCIProject`."""
586+ if recipe is not None and recipe.oci_project != self:
587+ raise ValueError(
588+ "An OCI recipe cannot be set as the official recipe of "
589+ "another OCI project.")
590+ # Removing security proxy here because `_official` is a private
591+ # attribute not declared on the Interface, and we need to set it
592+ # regardless of security checks on OCIRecipe objects.
593+ recipe = removeSecurityProxy(recipe)
594+ previous = removeSecurityProxy(self.getOfficialRecipe())
595+ if previous != recipe:
596+ if previous is not None:
597+ previous._official = False
598+ if recipe is not None:
599+ recipe._official = True
600+
601
602 @implementer(IOCIProjectSet)
603 class OCIProjectSet: