Merge ~cjwatson/launchpad:charm-recipe-listing-views into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: ba72adee1c377f3a32555163d1546975a5be2027
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:charm-recipe-listing-views
Merge into: launchpad:master
Prerequisite: ~cjwatson/launchpad:charm-recipe-build-basic-browser
Diff against target: 1341 lines (+902/-34)
21 files modified
lib/lp/charms/browser/charmrecipelisting.py (+73/-0)
lib/lp/charms/browser/configure.zcml (+31/-0)
lib/lp/charms/browser/hascharmrecipes.py (+64/-0)
lib/lp/charms/browser/tests/test_charmrecipelisting.py (+276/-0)
lib/lp/charms/browser/tests/test_hascharmrecipes.py (+86/-0)
lib/lp/charms/interfaces/charmrecipe.py (+44/-9)
lib/lp/charms/model/charmrecipe.py (+125/-16)
lib/lp/charms/model/charmrecipebuild.py (+8/-0)
lib/lp/charms/templates/charmrecipe-listing.pt (+46/-0)
lib/lp/charms/templates/charmrecipe-macros.pt (+22/-0)
lib/lp/charms/tests/test_charmrecipe.py (+84/-0)
lib/lp/code/browser/gitref.py (+9/-2)
lib/lp/code/browser/gitrepository.py (+3/-1)
lib/lp/code/model/gitrepository.py (+6/-0)
lib/lp/code/templates/gitref-index.pt (+1/-0)
lib/lp/code/templates/gitrepository-index.pt (+1/-0)
lib/lp/registry/browser/person.py (+4/-1)
lib/lp/registry/browser/product.py (+4/-2)
lib/lp/registry/browser/team.py (+4/-1)
lib/lp/registry/templates/product-index.pt (+4/-0)
lib/lp/soyuz/templates/person-portlet-ppas.pt (+7/-2)
Reviewer Review Type Date Requested Status
Ioana Lasc (community) Approve
Review via email: mp+403878@code.launchpad.net

Commit message

Add charm recipe listing views

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

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/charms/browser/charmrecipelisting.py b/lib/lp/charms/browser/charmrecipelisting.py
2new file mode 100644
3index 0000000..d9b3edd
4--- /dev/null
5+++ b/lib/lp/charms/browser/charmrecipelisting.py
6@@ -0,0 +1,73 @@
7+# Copyright 2021 Canonical Ltd. This software is licensed under the
8+# GNU Affero General Public License version 3 (see the file LICENSE).
9+
10+"""Base class view for charm recipe listings."""
11+
12+from __future__ import absolute_import, print_function, unicode_literals
13+
14+__metaclass__ = type
15+
16+__all__ = [
17+ "GitCharmRecipeListingView",
18+ "PersonCharmRecipeListingView",
19+ "ProjectCharmRecipeListingView",
20+ ]
21+
22+from functools import partial
23+
24+from zope.component import getUtility
25+
26+from lp.charms.interfaces.charmrecipe import ICharmRecipeSet
27+from lp.services.database.decoratedresultset import DecoratedResultSet
28+from lp.services.feeds.browser import FeedsMixin
29+from lp.services.propertycache import cachedproperty
30+from lp.services.webapp import LaunchpadView
31+from lp.services.webapp.batching import BatchNavigator
32+
33+
34+class CharmRecipeListingView(LaunchpadView, FeedsMixin):
35+
36+ feed_types = ()
37+
38+ source_enabled = True
39+ owner_enabled = True
40+
41+ @property
42+ def page_title(self):
43+ return "Charm recipes"
44+
45+ @property
46+ def label(self):
47+ return "Charm recipes for %(displayname)s" % {
48+ "displayname": self.context.displayname}
49+
50+ def initialize(self):
51+ super(CharmRecipeListingView, self).initialize()
52+ recipes = getUtility(ICharmRecipeSet).findByContext(
53+ self.context, visible_by_user=self.user)
54+ loader = partial(
55+ getUtility(ICharmRecipeSet).preloadDataForRecipes, user=self.user)
56+ self.recipes = DecoratedResultSet(recipes, pre_iter_hook=loader)
57+
58+ @cachedproperty
59+ def batchnav(self):
60+ return BatchNavigator(self.recipes, self.request)
61+
62+
63+class GitCharmRecipeListingView(CharmRecipeListingView):
64+
65+ source_enabled = False
66+
67+ @property
68+ def label(self):
69+ return "Charm recipes for %(display_name)s" % {
70+ "display_name": self.context.display_name}
71+
72+
73+class PersonCharmRecipeListingView(CharmRecipeListingView):
74+
75+ owner_enabled = False
76+
77+
78+class ProjectCharmRecipeListingView(CharmRecipeListingView):
79+ pass
80diff --git a/lib/lp/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml
81index 0475288..3c54b6e 100644
82--- a/lib/lp/charms/browser/configure.zcml
83+++ b/lib/lp/charms/browser/configure.zcml
84@@ -76,5 +76,36 @@
85 for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild"
86 factory="lp.services.webapp.breadcrumb.TitleBreadcrumb"
87 permission="zope.Public" />
88+
89+ <browser:page
90+ for="*"
91+ class="lp.app.browser.launchpad.Macro"
92+ permission="zope.Public"
93+ name="+charm-recipe-macros"
94+ template="../templates/charmrecipe-macros.pt" />
95+ <browser:page
96+ for="lp.code.interfaces.gitrepository.IGitRepository"
97+ class="lp.charms.browser.charmrecipelisting.GitCharmRecipeListingView"
98+ permission="launchpad.View"
99+ name="+charm-recipes"
100+ template="../templates/charmrecipe-listing.pt" />
101+ <browser:page
102+ for="lp.code.interfaces.gitref.IGitRef"
103+ class="lp.charms.browser.charmrecipelisting.GitCharmRecipeListingView"
104+ permission="launchpad.View"
105+ name="+charm-recipes"
106+ template="../templates/charmrecipe-listing.pt" />
107+ <browser:page
108+ for="lp.registry.interfaces.person.IPerson"
109+ class="lp.charms.browser.charmrecipelisting.PersonCharmRecipeListingView"
110+ permission="launchpad.View"
111+ name="+charm-recipes"
112+ template="../templates/charmrecipe-listing.pt" />
113+ <browser:page
114+ for="lp.registry.interfaces.product.IProduct"
115+ class="lp.charms.browser.charmrecipelisting.ProjectCharmRecipeListingView"
116+ permission="launchpad.View"
117+ name="+charm-recipes"
118+ template="../templates/charmrecipe-listing.pt" />
119 </facet>
120 </configure>
121diff --git a/lib/lp/charms/browser/hascharmrecipes.py b/lib/lp/charms/browser/hascharmrecipes.py
122new file mode 100644
123index 0000000..5c395b5
124--- /dev/null
125+++ b/lib/lp/charms/browser/hascharmrecipes.py
126@@ -0,0 +1,64 @@
127+# Copyright 2021 Canonical Ltd. This software is licensed under the
128+# GNU Affero General Public License version 3 (see the file LICENSE).
129+
130+"""Mixins for browser classes for objects that have charm recipes."""
131+
132+from __future__ import absolute_import, print_function, unicode_literals
133+
134+__metaclass__ = type
135+__all__ = [
136+ "HasCharmRecipesMenuMixin",
137+ "HasCharmRecipesViewMixin",
138+ ]
139+
140+from zope.component import getUtility
141+
142+from lp.charms.interfaces.charmrecipe import ICharmRecipeSet
143+from lp.code.interfaces.gitrepository import IGitRepository
144+from lp.services.webapp import (
145+ canonical_url,
146+ Link,
147+ )
148+from lp.services.webapp.escaping import structured
149+
150+
151+class HasCharmRecipesMenuMixin:
152+ """A mixin for context menus for objects that have charm recipes."""
153+
154+ def view_charm_recipes(self):
155+ text = "View charm recipes"
156+ enabled = not getUtility(ICharmRecipeSet).findByContext(
157+ self.context, visible_by_user=self.user).is_empty()
158+ return Link("+charm-recipes", text, icon="info", enabled=enabled)
159+
160+
161+class HasCharmRecipesViewMixin:
162+ """A view mixin for objects that have charm recipes."""
163+
164+ @property
165+ def charm_recipes(self):
166+ return getUtility(ICharmRecipeSet).findByContext(
167+ self.context, visible_by_user=self.user)
168+
169+ @property
170+ def charm_recipes_link(self):
171+ """A link to charm recipes for this object."""
172+ count = self.charm_recipes.count()
173+ if IGitRepository.providedBy(self.context):
174+ context_type = "repository"
175+ else:
176+ context_type = "branch"
177+ if count == 0:
178+ # Nothing to link to.
179+ return "No charm recipes using this %s." % context_type
180+ elif count == 1:
181+ # Link to the single charm recipe.
182+ return structured(
183+ '<a href="%s">1 charm recipe</a> using this %s.',
184+ canonical_url(self.charm_recipes.one()),
185+ context_type).escapedtext
186+ else:
187+ # Link to a charm recipe listing.
188+ return structured(
189+ '<a href="+charm-recipes">%s charm recipes</a> using this %s.',
190+ count, context_type).escapedtext
191diff --git a/lib/lp/charms/browser/tests/test_charmrecipelisting.py b/lib/lp/charms/browser/tests/test_charmrecipelisting.py
192new file mode 100644
193index 0000000..5bfd233
194--- /dev/null
195+++ b/lib/lp/charms/browser/tests/test_charmrecipelisting.py
196@@ -0,0 +1,276 @@
197+# Copyright 2021 Canonical Ltd. This software is licensed under the
198+# GNU Affero General Public License version 3 (see the file LICENSE).
199+
200+"""Test charm recipe listings."""
201+
202+from __future__ import absolute_import, print_function, unicode_literals
203+
204+__metaclass__ = type
205+
206+from datetime import (
207+ datetime,
208+ timedelta,
209+ )
210+from functools import partial
211+
212+import pytz
213+import soupmatchers
214+from testtools.matchers import (
215+ MatchesAll,
216+ Not,
217+ )
218+from zope.security.proxy import removeSecurityProxy
219+
220+from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
221+from lp.code.tests.helpers import GitHostingFixture
222+from lp.services.database.constants import (
223+ ONE_DAY_AGO,
224+ UTC_NOW,
225+ )
226+from lp.services.features.testing import MemoryFeatureFixture
227+from lp.services.webapp import canonical_url
228+from lp.testing import (
229+ ANONYMOUS,
230+ BrowserTestCase,
231+ login,
232+ person_logged_in,
233+ record_two_runs,
234+ )
235+from lp.testing.layers import LaunchpadFunctionalLayer
236+from lp.testing.matchers import HasQueryCount
237+from lp.testing.views import create_initialized_view
238+
239+
240+class TestCharmRecipeListing(BrowserTestCase):
241+
242+ layer = LaunchpadFunctionalLayer
243+
244+ def assertCharmRecipesLink(self, context, link_text,
245+ link_has_context=False, **kwargs):
246+ if link_has_context:
247+ expected_href = canonical_url(context, view_name="+charm-recipes")
248+ else:
249+ expected_href = "+charm-recipes"
250+ matcher = soupmatchers.HTMLContains(
251+ soupmatchers.Tag(
252+ "View charm recipes link", "a", text=link_text,
253+ attrs={"href": expected_href}))
254+ self.assertThat(self.getViewBrowser(context).contents, Not(matcher))
255+ login(ANONYMOUS)
256+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
257+ self.factory.makeCharmRecipe(**kwargs)
258+ self.factory.makeCharmRecipe(**kwargs)
259+ self.assertThat(self.getViewBrowser(context).contents, matcher)
260+
261+ def test_git_repository_links_to_recipes(self):
262+ repository = self.factory.makeGitRepository()
263+ [ref] = self.factory.makeGitRefs(repository=repository)
264+ self.assertCharmRecipesLink(repository, "2 charm recipes", git_ref=ref)
265+
266+ def test_git_ref_links_to_recipes(self):
267+ self.useFixture(GitHostingFixture())
268+ [ref] = self.factory.makeGitRefs()
269+ self.assertCharmRecipesLink(ref, "2 charm recipes", git_ref=ref)
270+
271+ def test_person_links_to_recipes(self):
272+ person = self.factory.makePerson()
273+ self.assertCharmRecipesLink(
274+ person, "View charm recipes", link_has_context=True,
275+ registrant=person, owner=person)
276+
277+ def test_project_links_to_recipes(self):
278+ project = self.factory.makeProduct()
279+ [ref] = self.factory.makeGitRefs(target=project)
280+ self.assertCharmRecipesLink(
281+ project, "View charm recipes", link_has_context=True, git_ref=ref)
282+
283+ def test_git_repository_recipe_listing(self):
284+ # We can see charm recipes for a Git repository.
285+ repository = self.factory.makeGitRepository()
286+ [ref] = self.factory.makeGitRefs(repository=repository)
287+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
288+ self.factory.makeCharmRecipe(git_ref=ref)
289+ text = self.getMainText(repository, "+charm-recipes")
290+ self.assertTextMatchesExpressionIgnoreWhitespace("""
291+ Charm recipes for lp:~.*
292+ Name Owner Registered
293+ charm-name.* Team Name.* .*""", text)
294+
295+ def test_git_ref_recipe_listing(self):
296+ # We can see charm recipes for a Git reference.
297+ [ref] = self.factory.makeGitRefs()
298+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
299+ self.factory.makeCharmRecipe(git_ref=ref)
300+ text = self.getMainText(ref, "+charm-recipes")
301+ self.assertTextMatchesExpressionIgnoreWhitespace("""
302+ Charm recipes for ~.*:.*
303+ Name Owner Registered
304+ charm-name.* Team Name.* .*""", text)
305+
306+ def test_person_recipe_listing(self):
307+ # We can see charm recipes for a person.
308+ owner = self.factory.makePerson(displayname="Charm Owner")
309+ [ref] = self.factory.makeGitRefs()
310+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
311+ self.factory.makeCharmRecipe(
312+ registrant=owner, owner=owner, git_ref=ref,
313+ date_created=ONE_DAY_AGO)
314+ text = self.getMainText(owner, "+charm-recipes")
315+ self.assertTextMatchesExpressionIgnoreWhitespace("""
316+ Charm recipes for Charm Owner
317+ Name Source Registered
318+ charm-name.* ~.*:.* .*""", text)
319+
320+ def test_project_recipe_listing(self):
321+ # We can see charm recipes for a project.
322+ project = self.factory.makeProduct(displayname="Charmable")
323+ [ref] = self.factory.makeGitRefs(target=project)
324+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
325+ self.factory.makeCharmRecipe(git_ref=ref, date_created=UTC_NOW)
326+ text = self.getMainText(project, "+charm-recipes")
327+ self.assertTextMatchesExpressionIgnoreWhitespace("""
328+ Charm recipes for Charmable
329+ Name Owner Source Registered
330+ charm-name.* Team Name.* ~.*:.* .*""", text)
331+
332+ def assertCharmRecipesQueryCount(self, context, item_creator):
333+ self.pushConfig("launchpad", default_batch_size=10)
334+ recorder1, recorder2 = record_two_runs(
335+ lambda: self.getMainText(context, "+charm-recipes"),
336+ item_creator, 5)
337+ self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
338+
339+ def test_git_repository_query_count(self):
340+ # The number of queries required to render the list of all charm
341+ # recipes for a Git repository is constant in the number of owners
342+ # and charm recipes.
343+ person = self.factory.makePerson()
344+ repository = self.factory.makeGitRepository(owner=person)
345+
346+ def create_recipe():
347+ with person_logged_in(person):
348+ [ref] = self.factory.makeGitRefs(repository=repository)
349+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
350+ self.factory.makeCharmRecipe(git_ref=ref)
351+
352+ self.assertCharmRecipesQueryCount(repository, create_recipe)
353+
354+ def test_git_ref_query_count(self):
355+ # The number of queries required to render the list of all charm
356+ # recipes for a Git reference is constant in the number of owners
357+ # and charm recipes.
358+ person = self.factory.makePerson()
359+ [ref] = self.factory.makeGitRefs(owner=person)
360+
361+ def create_recipe():
362+ with person_logged_in(person):
363+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
364+ self.factory.makeCharmRecipe(git_ref=ref)
365+
366+ self.assertCharmRecipesQueryCount(ref, create_recipe)
367+
368+ def test_person_query_count(self):
369+ # The number of queries required to render the list of all charm
370+ # recipes for a person is constant in the number of projects,
371+ # sources, and charm recipes.
372+ person = self.factory.makePerson()
373+
374+ def create_recipe():
375+ with person_logged_in(person):
376+ project = self.factory.makeProduct()
377+ [ref] = self.factory.makeGitRefs(owner=person, target=project)
378+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
379+ self.factory.makeCharmRecipe(git_ref=ref)
380+
381+ self.assertCharmRecipesQueryCount(person, create_recipe)
382+
383+ def test_project_query_count(self):
384+ # The number of queries required to render the list of all charm
385+ # recipes for a person is constant in the number of owners, sources,
386+ # and charm recipes.
387+ person = self.factory.makePerson()
388+ project = self.factory.makeProduct(owner=person)
389+
390+ def create_recipe():
391+ with person_logged_in(person):
392+ [ref] = self.factory.makeGitRefs(target=project)
393+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
394+ self.factory.makeCharmRecipe(git_ref=ref)
395+
396+ self.assertCharmRecipesQueryCount(project, create_recipe)
397+
398+ def makeCharmRecipesAndMatchers(self, create_recipe, count, start_time):
399+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
400+ recipes = [create_recipe() for i in range(count)]
401+ for i, recipe in enumerate(recipes):
402+ removeSecurityProxy(recipe).date_last_modified = (
403+ start_time - timedelta(seconds=i))
404+ return [
405+ soupmatchers.Tag(
406+ "charm recipe link", "a", text=recipe.name,
407+ attrs={
408+ "href": canonical_url(recipe, path_only_if_possible=True)})
409+ for recipe in recipes]
410+
411+ def assertBatches(self, context, link_matchers, batched, start, size):
412+ view = create_initialized_view(context, "+charm-recipes")
413+ listing_tag = soupmatchers.Tag(
414+ "charm recipe listing", "table",
415+ attrs={"class": "listing sortable"})
416+ batch_nav_tag = soupmatchers.Tag(
417+ "batch nav links", "td",
418+ attrs={"class": "batch-navigation-links"})
419+ present_links = ([batch_nav_tag] if batched else []) + [
420+ matcher for i, matcher in enumerate(link_matchers)
421+ if i in range(start, start + size)]
422+ absent_links = ([] if batched else [batch_nav_tag]) + [
423+ matcher for i, matcher in enumerate(link_matchers)
424+ if i not in range(start, start + size)]
425+ self.assertThat(
426+ view.render(),
427+ MatchesAll(
428+ soupmatchers.HTMLContains(listing_tag, *present_links),
429+ Not(soupmatchers.HTMLContains(*absent_links))))
430+
431+ def test_git_repository_batches_recipes(self):
432+ repository = self.factory.makeGitRepository()
433+ [ref] = self.factory.makeGitRefs(repository=repository)
434+ create_recipe = partial(self.factory.makeCharmRecipe, git_ref=ref)
435+ now = datetime.now(pytz.UTC)
436+ link_matchers = self.makeCharmRecipesAndMatchers(create_recipe, 3, now)
437+ self.assertBatches(repository, link_matchers, False, 0, 3)
438+ link_matchers.extend(self.makeCharmRecipesAndMatchers(
439+ create_recipe, 7, now - timedelta(seconds=3)))
440+ self.assertBatches(repository, link_matchers, True, 0, 5)
441+
442+ def test_git_ref_batches_recipes(self):
443+ [ref] = self.factory.makeGitRefs()
444+ create_recipe = partial(self.factory.makeCharmRecipe, git_ref=ref)
445+ now = datetime.now(pytz.UTC)
446+ link_matchers = self.makeCharmRecipesAndMatchers(create_recipe, 3, now)
447+ self.assertBatches(ref, link_matchers, False, 0, 3)
448+ link_matchers.extend(self.makeCharmRecipesAndMatchers(
449+ create_recipe, 7, now - timedelta(seconds=3)))
450+ self.assertBatches(ref, link_matchers, True, 0, 5)
451+
452+ def test_person_batches_recipes(self):
453+ owner = self.factory.makePerson()
454+ create_recipe = partial(
455+ self.factory.makeCharmRecipe, registrant=owner, owner=owner)
456+ now = datetime.now(pytz.UTC)
457+ link_matchers = self.makeCharmRecipesAndMatchers(create_recipe, 3, now)
458+ self.assertBatches(owner, link_matchers, False, 0, 3)
459+ link_matchers.extend(self.makeCharmRecipesAndMatchers(
460+ create_recipe, 7, now - timedelta(seconds=3)))
461+ self.assertBatches(owner, link_matchers, True, 0, 5)
462+
463+ def test_project_batches_recipes(self):
464+ project = self.factory.makeProduct()
465+ [ref] = self.factory.makeGitRefs(target=project)
466+ create_recipe = partial(self.factory.makeCharmRecipe, git_ref=ref)
467+ now = datetime.now(pytz.UTC)
468+ link_matchers = self.makeCharmRecipesAndMatchers(create_recipe, 3, now)
469+ self.assertBatches(project, link_matchers, False, 0, 3)
470+ link_matchers.extend(self.makeCharmRecipesAndMatchers(
471+ create_recipe, 7, now - timedelta(seconds=3)))
472+ self.assertBatches(project, link_matchers, True, 0, 5)
473diff --git a/lib/lp/charms/browser/tests/test_hascharmrecipes.py b/lib/lp/charms/browser/tests/test_hascharmrecipes.py
474new file mode 100644
475index 0000000..6374893
476--- /dev/null
477+++ b/lib/lp/charms/browser/tests/test_hascharmrecipes.py
478@@ -0,0 +1,86 @@
479+# Copyright 2021 Canonical Ltd. This software is licensed under the
480+# GNU Affero General Public License version 3 (see the file LICENSE).
481+
482+"""Test views for objects that have charm recipes."""
483+
484+from __future__ import absolute_import, print_function, unicode_literals
485+
486+__metaclass__ = type
487+
488+from testscenarios import (
489+ load_tests_apply_scenarios,
490+ WithScenarios,
491+ )
492+
493+from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
494+from lp.code.interfaces.gitrepository import IGitRepository
495+from lp.services.features.testing import FeatureFixture
496+from lp.services.webapp import canonical_url
497+from lp.testing import TestCaseWithFactory
498+from lp.testing.layers import DatabaseFunctionalLayer
499+from lp.testing.views import create_initialized_view
500+
501+
502+def make_git_repository(test_case):
503+ return test_case.factory.makeGitRepository()
504+
505+
506+def make_git_ref(test_case):
507+ return test_case.factory.makeGitRefs()[0]
508+
509+
510+class TestHasCharmRecipesView(WithScenarios, TestCaseWithFactory):
511+
512+ layer = DatabaseFunctionalLayer
513+
514+ scenarios = [
515+ ("GitRepository", {
516+ "context_type": "repository",
517+ "context_factory": make_git_repository,
518+ }),
519+ ("GitRef", {
520+ "context_type": "branch",
521+ "context_factory": make_git_ref,
522+ }),
523+ ]
524+
525+ def setUp(self):
526+ super(TestHasCharmRecipesView, self).setUp()
527+ self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
528+
529+ def makeCharmRecipe(self, context):
530+ if IGitRepository.providedBy(context):
531+ [context] = self.factory.makeGitRefs(repository=context)
532+ return self.factory.makeCharmRecipe(git_ref=context)
533+
534+ def test_charm_recipes_link_no_recipes(self):
535+ # An object with no charm recipes does not show a charm recipes link.
536+ context = self.context_factory(self)
537+ view = create_initialized_view(context, "+index")
538+ self.assertEqual(
539+ "No charm recipes using this %s." % self.context_type,
540+ view.charm_recipes_link)
541+
542+ def test_charm_recipes_link_one_recipe(self):
543+ # An object with one charm recipe shows a link to that recipe.
544+ context = self.context_factory(self)
545+ recipe = self.makeCharmRecipe(context)
546+ view = create_initialized_view(context, "+index")
547+ expected_link = (
548+ '<a href="%s">1 charm recipe</a> using this %s.' %
549+ (canonical_url(recipe), self.context_type))
550+ self.assertEqual(expected_link, view.charm_recipes_link)
551+
552+ def test_charm_recipes_link_more_recipes(self):
553+ # An object with more than one charm recipe shows a link to a listing.
554+ context = self.context_factory(self)
555+ self.makeCharmRecipe(context)
556+ self.makeCharmRecipe(context)
557+ view = create_initialized_view(context, "+index")
558+ expected_link = (
559+ '<a href="+charm-recipes">2 charm recipes</a> using this %s.' %
560+ self.context_type)
561+ self.assertEqual(expected_link, view.charm_recipes_link)
562+
563+
564+load_tests = load_tests_apply_scenarios
565diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
566index 6b70d1b..c972097 100644
567--- a/lib/lp/charms/interfaces/charmrecipe.py
568+++ b/lib/lp/charms/interfaces/charmrecipe.py
569@@ -412,7 +412,7 @@ class ICharmRecipeEditableAttributes(Interface):
570 "recipe."))
571
572 git_path = TextLine(
573- title=_("Git branch path"), required=False, readonly=False,
574+ title=_("Git branch path"), required=False, readonly=True,
575 description=_(
576 "The path of the Git branch containing a charmcraft.yaml "
577 "recipe."))
578@@ -513,6 +513,49 @@ class ICharmRecipeSet(Interface):
579 def getByName(owner, project, name):
580 """Returns the appropriate `ICharmRecipe` for the given objects."""
581
582+ def findByPerson(person, visible_by_user=None):
583+ """Return all charm recipes relevant to `person`.
584+
585+ This returns charm recipes for Git branches owned by `person`, or
586+ where `person` is the owner of the charm recipe.
587+
588+ :param person: An `IPerson`.
589+ :param visible_by_user: If not None, only return recipes visible by
590+ this user; otherwise, only return publicly-visible recipes.
591+ """
592+
593+ def findByProject(project, visible_by_user=None):
594+ """Return all charm recipes for the given project.
595+
596+ :param project: An `IProduct`.
597+ :param visible_by_user: If not None, only return recipes visible by
598+ this user; otherwise, only return publicly-visible recipes.
599+ """
600+
601+ def findByGitRepository(repository, paths=None, check_permissions=True):
602+ """Return all charm recipes for the given Git repository.
603+
604+ :param repository: An `IGitRepository`.
605+ :param paths: If not None, only return charm recipes for one of
606+ these Git reference paths.
607+ """
608+
609+ def findByGitRef(ref):
610+ """Return all charm recipes for the given Git reference."""
611+
612+ def findByContext(context, visible_by_user=None, order_by_date=True):
613+ """Return all charm recipes for the given context.
614+
615+ :param context: An `IPerson`, `IProduct`, `IGitRepository`, or
616+ `IGitRef`.
617+ :param visible_by_user: If not None, only return recipes visible by
618+ this user; otherwise, only return publicly-visible recipes.
619+ :param order_by_date: If True, order recipes by descending
620+ modification date.
621+ :raises BadCharmRecipeSearchContext: if the context is not
622+ understood.
623+ """
624+
625 def isValidInformationType(information_type, owner, git_ref=None):
626 """Whether the information type context is valid."""
627
628@@ -536,14 +579,6 @@ class ICharmRecipeSet(Interface):
629 cannot be parsed.
630 """
631
632- def findByGitRepository(repository, paths=None):
633- """Return all charm recipes for the given Git repository.
634-
635- :param repository: An `IGitRepository`.
636- :param paths: If not None, only return charm recipes for one of
637- these Git reference paths.
638- """
639-
640 def detachFromGitRepository(repository):
641 """Detach all charm recipes from the given Git repository.
642
643diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
644index da24bf6..d9c9b5c 100644
645--- a/lib/lp/charms/model/charmrecipe.py
646+++ b/lib/lp/charms/model/charmrecipe.py
647@@ -8,6 +8,7 @@ from __future__ import absolute_import, print_function, unicode_literals
648 __metaclass__ = type
649 __all__ = [
650 "CharmRecipe",
651+ "get_charm_recipe_privacy_filter",
652 ]
653
654 from operator import (
655@@ -47,6 +48,7 @@ from lp.buildmaster.interfaces.buildqueue import IBuildQueueSet
656 from lp.buildmaster.model.builder import Builder
657 from lp.charms.adapters.buildarch import determine_instances_to_build
658 from lp.charms.interfaces.charmrecipe import (
659+ BadCharmRecipeSearchContext,
660 CannotFetchCharmcraftYaml,
661 CannotParseCharmcraftYaml,
662 CHARM_RECIPE_ALLOW_CREATE,
663@@ -75,16 +77,26 @@ from lp.code.errors import (
664 GitRepositoryBlobNotFound,
665 GitRepositoryScanFault,
666 )
667+from lp.code.interfaces.gitcollection import (
668+ IAllGitRepositories,
669+ IGitCollection,
670+ )
671+from lp.code.interfaces.gitref import IGitRef
672+from lp.code.interfaces.gitrepository import IGitRepository
673 from lp.code.model.gitcollection import GenericGitCollection
674+from lp.code.model.gitref import GitRef
675 from lp.code.model.gitrepository import GitRepository
676 from lp.registry.errors import PrivatePersonLinkageError
677 from lp.registry.interfaces.distribution import IDistributionSet
678 from lp.registry.interfaces.person import (
679+ IPerson,
680 IPersonSet,
681 validate_public_person,
682 )
683+from lp.registry.interfaces.product import IProduct
684 from lp.registry.model.distribution import Distribution
685 from lp.registry.model.distroseries import DistroSeries
686+from lp.registry.model.product import Product
687 from lp.registry.model.series import ACTIVE_STATUSES
688 from lp.services.database.bulk import load_related
689 from lp.services.database.constants import (
690@@ -314,14 +326,18 @@ class CharmRecipe(StormBase):
691 """See `ICharmRecipe`."""
692 return self.information_type not in PUBLIC_INFORMATION_TYPES
693
694- @property
695- def git_ref(self):
696- """See `ICharmRecipe`."""
697+ @cachedproperty
698+ def _git_ref(self):
699 if self.git_repository is not None:
700 return self.git_repository.getRefByPath(self.git_path)
701 else:
702 return None
703
704+ @property
705+ def git_ref(self):
706+ """See `ICharmRecipe`."""
707+ return self._git_ref
708+
709 @git_ref.setter
710 def git_ref(self, value):
711 """See `ICharmRecipe`."""
712@@ -331,6 +347,7 @@ class CharmRecipe(StormBase):
713 else:
714 self.git_repository = None
715 self.git_path = None
716+ get_property_cache(self)._git_ref = value
717
718 @property
719 def source(self):
720@@ -384,9 +401,12 @@ class CharmRecipe(StormBase):
721 """See `ICharmRecipe`."""
722 if self.information_type in PUBLIC_INFORMATION_TYPES:
723 return True
724- # XXX cjwatson 2021-05-27: Finish implementing this once we have
725- # more privacy infrastructure.
726- return False
727+ if user is None:
728+ return False
729+ return not IStore(CharmRecipe).find(
730+ CharmRecipe,
731+ CharmRecipe.id == self.id,
732+ get_charm_recipe_privacy_filter(user)).is_empty()
733
734 def _isBuildableArchitectureAllowed(self, das):
735 """Check whether we may build for a buildable `DistroArchSeries`.
736@@ -675,6 +695,82 @@ class CharmRecipeSet:
737 return IStore(CharmRecipe).find(
738 CharmRecipe, owner=owner, project=project, name=name).one()
739
740+ def _getRecipesFromCollection(self, collection, owner=None,
741+ visible_by_user=None):
742+ id_column = CharmRecipe.git_repository_id
743+ ids = collection.getRepositoryIds()
744+ expressions = [id_column.is_in(ids._get_select())]
745+ if owner is not None:
746+ expressions.append(CharmRecipe.owner == owner)
747+ expressions.append(get_charm_recipe_privacy_filter(visible_by_user))
748+ return IStore(CharmRecipe).find(CharmRecipe, *expressions)
749+
750+ def findByPerson(self, person, visible_by_user=None):
751+ """See `ICharmRecipeSet`."""
752+ def _getRecipes(collection):
753+ collection = collection.visibleByUser(visible_by_user)
754+ owned = self._getRecipesFromCollection(
755+ collection.ownedBy(person), visible_by_user=visible_by_user)
756+ packaged = self._getRecipesFromCollection(
757+ collection, owner=person, visible_by_user=visible_by_user)
758+ return owned.union(packaged)
759+
760+ git_collection = removeSecurityProxy(getUtility(IAllGitRepositories))
761+ git_recipes = _getRecipes(git_collection)
762+ return git_recipes
763+
764+ def findByProject(self, project, visible_by_user=None):
765+ """See `ICharmRecipeSet`."""
766+ def _getRecipes(collection):
767+ return self._getRecipesFromCollection(
768+ collection.visibleByUser(visible_by_user),
769+ visible_by_user=visible_by_user)
770+
771+ recipes_for_project = IStore(CharmRecipe).find(
772+ CharmRecipe,
773+ CharmRecipe.project == project,
774+ get_charm_recipe_privacy_filter(visible_by_user))
775+ git_collection = removeSecurityProxy(IGitCollection(project))
776+ return recipes_for_project.union(_getRecipes(git_collection))
777+
778+ def findByGitRepository(self, repository, paths=None,
779+ visible_by_user=None, check_permissions=True):
780+ """See `ICharmRecipeSet`."""
781+ clauses = [CharmRecipe.git_repository == repository]
782+ if paths is not None:
783+ clauses.append(CharmRecipe.git_path.is_in(paths))
784+ if check_permissions:
785+ clauses.append(get_charm_recipe_privacy_filter(visible_by_user))
786+ return IStore(CharmRecipe).find(CharmRecipe, *clauses)
787+
788+ def findByGitRef(self, ref, visible_by_user=None):
789+ """See `ICharmRecipeSet`."""
790+ return IStore(CharmRecipe).find(
791+ CharmRecipe,
792+ CharmRecipe.git_repository == ref.repository,
793+ CharmRecipe.git_path == ref.path,
794+ get_charm_recipe_privacy_filter(visible_by_user))
795+
796+ def findByContext(self, context, visible_by_user=None, order_by_date=True):
797+ """See `ICharmRecipeSet`."""
798+ if IPerson.providedBy(context):
799+ recipes = self.findByPerson(
800+ context, visible_by_user=visible_by_user)
801+ elif IProduct.providedBy(context):
802+ recipes = self.findByProject(
803+ context, visible_by_user=visible_by_user)
804+ elif IGitRepository.providedBy(context):
805+ recipes = self.findByGitRepository(
806+ context, visible_by_user=visible_by_user)
807+ elif IGitRef.providedBy(context):
808+ recipes = self.findByGitRef(
809+ context, visible_by_user=visible_by_user)
810+ else:
811+ raise BadCharmRecipeSearchContext(context)
812+ if order_by_date:
813+ recipes = recipes.order_by(Desc(CharmRecipe.date_last_modified))
814+ return recipes
815+
816 def isValidInformationType(self, information_type, owner, git_ref=None):
817 """See `ICharmRecipeSet`."""
818 private = information_type not in PUBLIC_INFORMATION_TYPES
819@@ -698,6 +794,8 @@ class CharmRecipeSet:
820 """See `ICharmRecipeSet`."""
821 recipes = [removeSecurityProxy(recipe) for recipe in recipes]
822
823+ load_related(Product, recipes, ["project_id"])
824+
825 person_ids = set()
826 for recipe in recipes:
827 person_ids.add(recipe.registrant_id)
828@@ -708,6 +806,13 @@ class CharmRecipeSet:
829 if repositories:
830 GenericGitCollection.preloadDataForRepositories(repositories)
831
832+ git_refs = GitRef.findByReposAndPaths(
833+ [(recipe.git_repository, recipe.git_path) for recipe in recipes])
834+ for recipe in recipes:
835+ git_ref = git_refs.get((recipe.git_repository, recipe.git_path))
836+ if git_ref is not None:
837+ get_property_cache(recipe)._git_ref = git_ref
838+
839 # Add repository owners to the list of pre-loaded persons. We need
840 # the target repository owner as well, since repository unique names
841 # aren't trigger-maintained.
842@@ -760,16 +865,20 @@ class CharmRecipeSet:
843
844 return charmcraft_data
845
846- def findByGitRepository(self, repository, paths=None):
847- """See `ICharmRecipeSet`."""
848- clauses = [CharmRecipe.git_repository == repository]
849- if paths is not None:
850- clauses.append(CharmRecipe.git_path.is_in(paths))
851- # XXX cjwatson 2021-05-26: Check permissions once we have some
852- # privacy infrastructure.
853- return IStore(CharmRecipe).find(CharmRecipe, *clauses)
854-
855 def detachFromGitRepository(self, repository):
856 """See `ICharmRecipeSet`."""
857- self.findByGitRepository(repository).set(
858+ recipes = self.findByGitRepository(repository)
859+ for recipe in recipes:
860+ get_property_cache(recipe)._git_ref = None
861+ recipes.set(
862 git_repository_id=None, git_path=None, date_last_modified=UTC_NOW)
863+
864+
865+def get_charm_recipe_privacy_filter(user):
866+ """Return a Storm query filter to find charm recipes visible to `user`."""
867+ public_filter = CharmRecipe.information_type.is_in(
868+ PUBLIC_INFORMATION_TYPES)
869+
870+ # XXX cjwatson 2021-06-07: Flesh this out once we have more privacy
871+ # infrastructure.
872+ return [public_filter]
873diff --git a/lib/lp/charms/model/charmrecipebuild.py b/lib/lp/charms/model/charmrecipebuild.py
874index 24d7ff7..088b7b1 100644
875--- a/lib/lp/charms/model/charmrecipebuild.py
876+++ b/lib/lp/charms/model/charmrecipebuild.py
877@@ -47,6 +47,8 @@ from lp.charms.interfaces.charmrecipebuild import (
878 from lp.charms.mail.charmrecipebuild import CharmRecipeBuildMailer
879 from lp.registry.interfaces.pocket import PackagePublishingPocket
880 from lp.registry.interfaces.series import SeriesStatus
881+from lp.registry.model.distribution import Distribution
882+from lp.registry.model.distroseries import DistroSeries
883 from lp.registry.model.person import Person
884 from lp.services.config import config
885 from lp.services.database.bulk import load_related
886@@ -67,6 +69,7 @@ from lp.services.propertycache import (
887 get_property_cache,
888 )
889 from lp.services.webapp.snapshot import notify_modified
890+from lp.soyuz.model.distroarchseries import DistroArchSeries
891
892
893 @implementer(ICharmRecipeBuild)
894@@ -431,6 +434,11 @@ class CharmRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
895 load_related(Person, builds, ["requester_id"])
896 lfas = load_related(LibraryFileAlias, builds, ["log_id"])
897 load_related(LibraryFileContent, lfas, ["contentID"])
898+ distroarchserieses = load_related(
899+ DistroArchSeries, builds, ["distro_arch_series_id"])
900+ distroserieses = load_related(
901+ DistroSeries, distroarchserieses, ["distroseriesID"])
902+ load_related(Distribution, distroserieses, ["distributionID"])
903 recipes = load_related(CharmRecipe, builds, ["recipe_id"])
904 getUtility(ICharmRecipeSet).preloadDataForRecipes(recipes)
905
906diff --git a/lib/lp/charms/templates/charmrecipe-listing.pt b/lib/lp/charms/templates/charmrecipe-listing.pt
907new file mode 100644
908index 0000000..8f163d7
909--- /dev/null
910+++ b/lib/lp/charms/templates/charmrecipe-listing.pt
911@@ -0,0 +1,46 @@
912+<html
913+ xmlns="http://www.w3.org/1999/xhtml"
914+ xmlns:tal="http://xml.zope.org/namespaces/tal"
915+ xmlns:metal="http://xml.zope.org/namespaces/metal"
916+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
917+ metal:use-macro="view/macro:page/main_only"
918+ i18n:domain="launchpad">
919+
920+<body>
921+
922+ <div metal:fill-slot="main">
923+
924+ <tal:navigation
925+ condition="view/batchnav/has_multiple_pages"
926+ replace="structure view/batchnav/@@+navigation-links-upper" />
927+ <table id="charm-recipe-table" class="listing sortable">
928+ <thead>
929+ <tr>
930+ <th colspan="2">Name</th>
931+ <th tal:condition="view/owner_enabled">Owner</th>
932+ <th tal:condition="view/source_enabled">Source</th>
933+ <th>Registered</th>
934+ </tr>
935+ </thead>
936+ <tbody>
937+ <tal:recipes repeat="recipe view/batchnav/currentBatch">
938+ <tr>
939+ <td colspan="2">
940+ <a tal:attributes="href recipe/fmt:url" tal:content="recipe/name" />
941+ </td>
942+ <td tal:condition="view/owner_enabled"
943+ tal:content="structure recipe/owner/fmt:link" />
944+ <td tal:condition="view/source_enabled"
945+ tal:content="structure recipe/source/fmt:link" />
946+ <td tal:content="recipe/date_created/fmt:datetime" />
947+ </tr>
948+ </tal:recipes>
949+ </tbody>
950+ </table>
951+ <tal:navigation
952+ condition="view/batchnav/has_multiple_pages"
953+ replace="structure view/batchnav/@@+navigation-links-lower" />
954+
955+ </div>
956+</body>
957+</html>
958diff --git a/lib/lp/charms/templates/charmrecipe-macros.pt b/lib/lp/charms/templates/charmrecipe-macros.pt
959new file mode 100644
960index 0000000..93b4d69
961--- /dev/null
962+++ b/lib/lp/charms/templates/charmrecipe-macros.pt
963@@ -0,0 +1,22 @@
964+<tal:root
965+ xmlns:tal="http://xml.zope.org/namespaces/tal"
966+ xmlns:metal="http://xml.zope.org/namespaces/metal"
967+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
968+ omit-tag="">
969+
970+<div
971+ metal:define-macro="related-charm-recipes"
972+ tal:define="context_menu context/menu:context"
973+ id="related-charm-recipes">
974+
975+ <h3>Related charm recipes</h3>
976+
977+ <div id="charm-recipe-links" class="actions">
978+ <div id="charm-recipe-summary">
979+ <tal:charm_recipes replace="structure view/charm_recipes_link" />
980+ </div>
981+ </div>
982+
983+</div>
984+
985+</tal:root>
986diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py
987index 173e024..af7f1e3 100644
988--- a/lib/lp/charms/tests/test_charmrecipe.py
989+++ b/lib/lp/charms/tests/test_charmrecipe.py
990@@ -34,6 +34,7 @@ from lp.buildmaster.interfaces.processor import (
991 )
992 from lp.buildmaster.model.buildqueue import BuildQueue
993 from lp.charms.interfaces.charmrecipe import (
994+ BadCharmRecipeSearchContext,
995 CHARM_RECIPE_ALLOW_CREATE,
996 CHARM_RECIPE_BUILD_DISTRIBUTION,
997 CharmRecipeBuildAlreadyPending,
998@@ -583,6 +584,40 @@ class TestCharmRecipeSet(TestCaseWithFactory):
999 getUtility(ICharmRecipeSet).getByName(
1000 owner, project, "proj-charm"))
1001
1002+ def test_findByPerson(self):
1003+ # ICharmRecipeSet.findByPerson returns all charm recipes with the
1004+ # given owner or based on repositories with the given owner.
1005+ owners = [self.factory.makePerson() for i in range(2)]
1006+ recipes = []
1007+ for owner in owners:
1008+ recipes.append(self.factory.makeCharmRecipe(
1009+ registrant=owner, owner=owner))
1010+ [ref] = self.factory.makeGitRefs(owner=owner)
1011+ recipes.append(self.factory.makeCharmRecipe(git_ref=ref))
1012+ recipe_set = getUtility(ICharmRecipeSet)
1013+ self.assertContentEqual(
1014+ recipes[:2], recipe_set.findByPerson(owners[0]))
1015+ self.assertContentEqual(
1016+ recipes[2:], recipe_set.findByPerson(owners[1]))
1017+
1018+ def test_findByProject(self):
1019+ # ICharmRecipeSet.findByProject returns all charm recipes based on
1020+ # repositories for the given project, and charm recipes associated
1021+ # directly with the project.
1022+ projects = [self.factory.makeProduct() for i in range(2)]
1023+ recipes = []
1024+ for project in projects:
1025+ [ref] = self.factory.makeGitRefs(target=project)
1026+ recipes.append(self.factory.makeCharmRecipe(git_ref=ref))
1027+ recipes.append(self.factory.makeCharmRecipe(project=project))
1028+ [ref] = self.factory.makeGitRefs(target=None)
1029+ recipes.append(self.factory.makeCharmRecipe(git_ref=ref))
1030+ recipe_set = getUtility(ICharmRecipeSet)
1031+ self.assertContentEqual(
1032+ recipes[:2], recipe_set.findByProject(projects[0]))
1033+ self.assertContentEqual(
1034+ recipes[2:4], recipe_set.findByProject(projects[1]))
1035+
1036 def test_findByGitRepository(self):
1037 # ICharmRecipeSet.findByGitRepository returns all charm recipes with
1038 # the given Git repository.
1039@@ -620,6 +655,55 @@ class TestCharmRecipeSet(TestCaseWithFactory):
1040 repositories[0],
1041 paths=[recipes[0].git_ref.path, recipes[1].git_ref.path]))
1042
1043+ def test_findByGitRef(self):
1044+ # ICharmRecipeSet.findByGitRef returns all charm recipes with the
1045+ # given Git reference.
1046+ repositories = [self.factory.makeGitRepository() for i in range(2)]
1047+ refs = []
1048+ recipes = []
1049+ for repository in repositories:
1050+ refs.extend(self.factory.makeGitRefs(
1051+ paths=["refs/heads/master", "refs/heads/other"]))
1052+ recipes.append(self.factory.makeCharmRecipe(git_ref=refs[-2]))
1053+ recipes.append(self.factory.makeCharmRecipe(git_ref=refs[-1]))
1054+ recipe_set = getUtility(ICharmRecipeSet)
1055+ for ref, recipe in zip(refs, recipes):
1056+ self.assertContentEqual([recipe], recipe_set.findByGitRef(ref))
1057+
1058+ def test_findByContext(self):
1059+ # ICharmRecipeSet.findByContext returns all charm recipes with the
1060+ # given context.
1061+ person = self.factory.makePerson()
1062+ project = self.factory.makeProduct()
1063+ repository = self.factory.makeGitRepository(
1064+ owner=person, target=project)
1065+ refs = self.factory.makeGitRefs(
1066+ repository=repository,
1067+ paths=["refs/heads/master", "refs/heads/other"])
1068+ other_repository = self.factory.makeGitRepository()
1069+ other_refs = self.factory.makeGitRefs(
1070+ repository=other_repository,
1071+ paths=["refs/heads/master", "refs/heads/other"])
1072+ recipes = []
1073+ recipes.append(self.factory.makeCharmRecipe(git_ref=refs[0]))
1074+ recipes.append(self.factory.makeCharmRecipe(git_ref=refs[1]))
1075+ recipes.append(self.factory.makeCharmRecipe(
1076+ registrant=person, owner=person, git_ref=other_refs[0]))
1077+ recipes.append(self.factory.makeCharmRecipe(
1078+ project=project, git_ref=other_refs[1]))
1079+ recipe_set = getUtility(ICharmRecipeSet)
1080+ self.assertContentEqual(recipes[:3], recipe_set.findByContext(person))
1081+ self.assertContentEqual(
1082+ [recipes[0], recipes[1], recipes[3]],
1083+ recipe_set.findByContext(project))
1084+ self.assertContentEqual(
1085+ recipes[:2], recipe_set.findByContext(repository))
1086+ self.assertContentEqual(
1087+ [recipes[0]], recipe_set.findByContext(refs[0]))
1088+ self.assertRaises(
1089+ BadCharmRecipeSearchContext, recipe_set.findByContext,
1090+ self.factory.makeDistribution())
1091+
1092 def test_detachFromGitRepository(self):
1093 # ICharmRecipeSet.detachFromGitRepository clears the given Git
1094 # repository from all charm recipes.
1095diff --git a/lib/lp/code/browser/gitref.py b/lib/lp/code/browser/gitref.py
1096index b33dd20..c53f8f1 100644
1097--- a/lib/lp/code/browser/gitref.py
1098+++ b/lib/lp/code/browser/gitref.py
1099@@ -35,6 +35,10 @@ from lp.app.browser.launchpadform import (
1100 action,
1101 LaunchpadFormView,
1102 )
1103+from lp.charms.browser.hascharmrecipes import (
1104+ HasCharmRecipesMenuMixin,
1105+ HasCharmRecipesViewMixin,
1106+ )
1107 from lp.code.browser.branchmergeproposal import (
1108 latest_proposals_for_each_branch,
1109 )
1110@@ -71,7 +75,9 @@ from lp.snappy.browser.hassnaps import (
1111 )
1112
1113
1114-class GitRefContextMenu(ContextMenu, HasRecipesMenuMixin, HasSnapsMenuMixin):
1115+class GitRefContextMenu(
1116+ ContextMenu, HasRecipesMenuMixin, HasSnapsMenuMixin,
1117+ HasCharmRecipesMenuMixin):
1118 """Context menu for Git references."""
1119
1120 usedfor = IGitRef
1121@@ -82,6 +88,7 @@ class GitRefContextMenu(ContextMenu, HasRecipesMenuMixin, HasSnapsMenuMixin):
1122 'create_snap',
1123 'register_merge',
1124 'source',
1125+ 'view_charm_recipes',
1126 'view_recipes',
1127 ]
1128
1129@@ -111,7 +118,7 @@ class GitRefContextMenu(ContextMenu, HasRecipesMenuMixin, HasSnapsMenuMixin):
1130 return Link("+new-recipe", text, enabled=enabled, icon="add")
1131
1132
1133-class GitRefView(LaunchpadView, HasSnapsViewMixin):
1134+class GitRefView(LaunchpadView, HasSnapsViewMixin, HasCharmRecipesViewMixin):
1135
1136 # This is set at self.commit_infos, and should be accessed by the view
1137 # as self.commit_info_message.
1138diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py
1139index c41cafe..fb51ca1 100644
1140--- a/lib/lp/code/browser/gitrepository.py
1141+++ b/lib/lp/code/browser/gitrepository.py
1142@@ -75,6 +75,7 @@ from lp.app.errors import (
1143 )
1144 from lp.app.vocabularies import InformationTypeVocabulary
1145 from lp.app.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription
1146+from lp.charms.browser.hascharmrecipes import HasCharmRecipesViewMixin
1147 from lp.code.browser.branch import CodeEditOwnerMixin
1148 from lp.code.browser.branchmergeproposal import (
1149 latest_proposals_for_each_branch,
1150@@ -368,7 +369,8 @@ class GitRefBatchNavigator(TableBatchNavigator):
1151
1152
1153 class GitRepositoryView(InformationTypePortletMixin, LaunchpadView,
1154- HasSnapsViewMixin, CodeImportTargetMixin):
1155+ HasSnapsViewMixin, HasCharmRecipesViewMixin,
1156+ CodeImportTargetMixin):
1157
1158 @property
1159 def page_title(self):
1160diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
1161index 0a98bd3..d5a5b38 100644
1162--- a/lib/lp/code/model/gitrepository.py
1163+++ b/lib/lp/code/model/gitrepository.py
1164@@ -793,6 +793,12 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
1165 Store.of(self).find(
1166 GitRef,
1167 GitRef.repository == self, GitRef.path.is_in(paths)).remove()
1168+ # Clear cached references to the removed refs.
1169+ # XXX cjwatson 2021-06-08: We should probably do something similar
1170+ # for OCIRecipe, and for Snap if we start caching git_ref there.
1171+ for recipe in getUtility(ICharmRecipeSet).findByGitRepository(
1172+ self, paths=paths):
1173+ get_property_cache(recipe)._git_ref = None
1174 self.date_last_modified = UTC_NOW
1175
1176 def planRefChanges(self, hosting_path, logger=None):
1177diff --git a/lib/lp/code/templates/gitref-index.pt b/lib/lp/code/templates/gitref-index.pt
1178index 548ef39..1916bb0 100644
1179--- a/lib/lp/code/templates/gitref-index.pt
1180+++ b/lib/lp/code/templates/gitref-index.pt
1181@@ -38,6 +38,7 @@
1182 replace="structure context/@@++ref-pending-merges" />
1183 <tal:ref-recipes replace="structure context/@@++ref-recipes" />
1184 <div metal:use-macro="context/@@+snap-macros/related-snaps" />
1185+ <div metal:use-macro="context/@@+charm-recipe-macros/related-charm-recipes" />
1186 </div>
1187 </div>
1188
1189diff --git a/lib/lp/code/templates/gitrepository-index.pt b/lib/lp/code/templates/gitrepository-index.pt
1190index 89f202f..c7da7d1 100644
1191--- a/lib/lp/code/templates/gitrepository-index.pt
1192+++ b/lib/lp/code/templates/gitrepository-index.pt
1193@@ -65,6 +65,7 @@
1194 <div metal:use-macro="context/@@+snap-macros/related-snaps">
1195 <metal:context-type fill-slot="context_type">repository</metal:context-type>
1196 </div>
1197+ <div metal:use-macro="context/@@+charm-recipe-macros/related-charm-recipes" />
1198 </div>
1199 </div>
1200
1201diff --git a/lib/lp/registry/browser/person.py b/lib/lp/registry/browser/person.py
1202index d0766c5..233113d 100644
1203--- a/lib/lp/registry/browser/person.py
1204+++ b/lib/lp/registry/browser/person.py
1205@@ -135,6 +135,7 @@ from lp.bugs.interfaces.bugsupervisor import IHasBugSupervisor
1206 from lp.bugs.interfaces.bugtask import BugTaskStatus
1207 from lp.bugs.interfaces.bugtasksearch import BugTaskSearchParams
1208 from lp.buildmaster.enums import BuildStatus
1209+from lp.charms.browser.hascharmrecipes import HasCharmRecipesMenuMixin
1210 from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
1211 from lp.code.errors import InvalidNamespace
1212 from lp.code.interfaces.branchnamespace import IBranchNamespaceSet
1213@@ -812,7 +813,8 @@ class PersonMenuMixin(CommonMenuLinks):
1214
1215
1216 class PersonOverviewMenu(ApplicationMenu, PersonMenuMixin, HasRecipesMenuMixin,
1217- HasSnapsMenuMixin, HasOCIRecipesMenuMixin):
1218+ HasSnapsMenuMixin, HasOCIRecipesMenuMixin,
1219+ HasCharmRecipesMenuMixin):
1220
1221 usedfor = IPerson
1222 facet = 'overview'
1223@@ -842,6 +844,7 @@ class PersonOverviewMenu(ApplicationMenu, PersonMenuMixin, HasRecipesMenuMixin,
1224 'oauth_tokens',
1225 'oci_registry_credentials',
1226 'related_software_summary',
1227+ 'view_charm_recipes',
1228 'view_recipes',
1229 'view_snaps',
1230 'view_oci_recipes',
1231diff --git a/lib/lp/registry/browser/product.py b/lib/lp/registry/browser/product.py
1232index df7ee7a..628fe0e 100644
1233--- a/lib/lp/registry/browser/product.py
1234+++ b/lib/lp/registry/browser/product.py
1235@@ -139,6 +139,7 @@ from lp.bugs.browser.structuralsubscription import (
1236 StructuralSubscriptionTargetTraversalMixin,
1237 )
1238 from lp.bugs.interfaces.bugtask import RESOLVED_BUGTASK_STATUSES
1239+from lp.charms.browser.hascharmrecipes import HasCharmRecipesMenuMixin
1240 from lp.code.browser.branchref import BranchRef
1241 from lp.code.browser.codeimport import (
1242 CodeImportNameValidationMixin,
1243@@ -233,7 +234,6 @@ from lp.services.webapp.vhosts import allvhosts
1244 from lp.services.worlddata.helpers import browser_languages
1245 from lp.services.worlddata.interfaces.country import ICountry
1246 from lp.snappy.browser.hassnaps import HasSnapsMenuMixin
1247-from lp.snappy.interfaces.snap import ISnapSet
1248 from lp.translations.browser.customlanguagecode import (
1249 HasCustomLanguageCodesTraversalMixin,
1250 )
1251@@ -559,7 +559,8 @@ class ProductActionNavigationMenu(NavigationMenu, ProductEditLinksMixin):
1252
1253
1254 class ProductOverviewMenu(ApplicationMenu, ProductEditLinksMixin,
1255- HasRecipesMenuMixin, HasSnapsMenuMixin):
1256+ HasRecipesMenuMixin, HasSnapsMenuMixin,
1257+ HasCharmRecipesMenuMixin):
1258
1259 usedfor = IProduct
1260 facet = 'overview'
1261@@ -584,6 +585,7 @@ class ProductOverviewMenu(ApplicationMenu, ProductEditLinksMixin,
1262 'review_license',
1263 'rdf',
1264 'branding',
1265+ 'view_charm_recipes',
1266 'view_recipes',
1267 'view_snaps',
1268 'create_snap',
1269diff --git a/lib/lp/registry/browser/team.py b/lib/lp/registry/browser/team.py
1270index d419603..980ee1e 100644
1271--- a/lib/lp/registry/browser/team.py
1272+++ b/lib/lp/registry/browser/team.py
1273@@ -93,6 +93,7 @@ from lp.app.widgets.itemswidgets import (
1274 )
1275 from lp.app.widgets.owner import HiddenUserWidget
1276 from lp.app.widgets.popup import PersonPickerWidget
1277+from lp.charms.browser.hascharmrecipes import HasCharmRecipesMenuMixin
1278 from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
1279 from lp.oci.browser.hasocirecipes import HasOCIRecipesMenuMixin
1280 from lp.registry.browser.branding import BrandingChangeView
1281@@ -1624,7 +1625,8 @@ class TeamMenuMixin(PPANavigationMenuMixIn, CommonMenuLinks):
1282
1283
1284 class TeamOverviewMenu(ApplicationMenu, TeamMenuMixin, HasRecipesMenuMixin,
1285- HasSnapsMenuMixin, HasOCIRecipesMenuMixin):
1286+ HasSnapsMenuMixin, HasOCIRecipesMenuMixin,
1287+ HasCharmRecipesMenuMixin):
1288
1289 usedfor = ITeam
1290 facet = 'overview'
1291@@ -1652,6 +1654,7 @@ class TeamOverviewMenu(ApplicationMenu, TeamMenuMixin, HasRecipesMenuMixin,
1292 'maintained',
1293 'ppa',
1294 'related_software_summary',
1295+ 'view_charm_recipes',
1296 'view_recipes',
1297 'view_snaps',
1298 'view_oci_recipes',
1299diff --git a/lib/lp/registry/templates/product-index.pt b/lib/lp/registry/templates/product-index.pt
1300index 7d60223..242483d 100644
1301--- a/lib/lp/registry/templates/product-index.pt
1302+++ b/lib/lp/registry/templates/product-index.pt
1303@@ -188,6 +188,10 @@
1304 tal:condition="link/enabled">
1305 <a tal:replace="structure link/fmt:link" />
1306 </li>
1307+ <li tal:define="link context/menu:overview/view_charm_recipes"
1308+ tal:condition="link/enabled">
1309+ <a tal:replace="structure link/fmt:link" />
1310+ </li>
1311 </ul>
1312 </div>
1313 </div>
1314diff --git a/lib/lp/soyuz/templates/person-portlet-ppas.pt b/lib/lp/soyuz/templates/person-portlet-ppas.pt
1315index 19b7e6e..a97a9d5 100644
1316--- a/lib/lp/soyuz/templates/person-portlet-ppas.pt
1317+++ b/lib/lp/soyuz/templates/person-portlet-ppas.pt
1318@@ -34,10 +34,12 @@
1319 <ul class="horizontal" style="margin-top: 0;"
1320 tal:define="recipes_link context/menu:overview/view_recipes;
1321 snaps_link context/menu:overview/view_snaps;
1322- oci_recipes_link context/menu:overview/view_oci_recipes"
1323+ oci_recipes_link context/menu:overview/view_oci_recipes;
1324+ charm_recipes_link context/menu:overview/view_charm_recipes"
1325 tal:condition="python: recipes_link.enabled
1326 or snaps_link.enabled
1327- or oci_recipes_link.enabled">
1328+ or oci_recipes_link.enabled
1329+ or charm_recipes_link.enabled">
1330 <li tal:condition="recipes_link/enabled">
1331 <a tal:replace="structure recipes_link/fmt:link" />
1332 </li>
1333@@ -47,5 +49,8 @@
1334 <li tal:condition="oci_recipes_link/enabled">
1335 <a tal:replace="structure oci_recipes_link/fmt:link" />
1336 </li>
1337+ <li tal:condition="charm_recipes_link/enabled">
1338+ <a tal:replace="structure charm_recipes_link/fmt:link" />
1339+ </li>
1340 </ul>
1341 </tal:root>

Subscribers

People subscribed via source and target branches

to status/vote changes: