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
diff --git a/lib/lp/charms/browser/charmrecipelisting.py b/lib/lp/charms/browser/charmrecipelisting.py
0new file mode 1006440new file mode 100644
index 0000000..d9b3edd
--- /dev/null
+++ b/lib/lp/charms/browser/charmrecipelisting.py
@@ -0,0 +1,73 @@
1# Copyright 2021 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Base class view for charm recipe listings."""
5
6from __future__ import absolute_import, print_function, unicode_literals
7
8__metaclass__ = type
9
10__all__ = [
11 "GitCharmRecipeListingView",
12 "PersonCharmRecipeListingView",
13 "ProjectCharmRecipeListingView",
14 ]
15
16from functools import partial
17
18from zope.component import getUtility
19
20from lp.charms.interfaces.charmrecipe import ICharmRecipeSet
21from lp.services.database.decoratedresultset import DecoratedResultSet
22from lp.services.feeds.browser import FeedsMixin
23from lp.services.propertycache import cachedproperty
24from lp.services.webapp import LaunchpadView
25from lp.services.webapp.batching import BatchNavigator
26
27
28class CharmRecipeListingView(LaunchpadView, FeedsMixin):
29
30 feed_types = ()
31
32 source_enabled = True
33 owner_enabled = True
34
35 @property
36 def page_title(self):
37 return "Charm recipes"
38
39 @property
40 def label(self):
41 return "Charm recipes for %(displayname)s" % {
42 "displayname": self.context.displayname}
43
44 def initialize(self):
45 super(CharmRecipeListingView, self).initialize()
46 recipes = getUtility(ICharmRecipeSet).findByContext(
47 self.context, visible_by_user=self.user)
48 loader = partial(
49 getUtility(ICharmRecipeSet).preloadDataForRecipes, user=self.user)
50 self.recipes = DecoratedResultSet(recipes, pre_iter_hook=loader)
51
52 @cachedproperty
53 def batchnav(self):
54 return BatchNavigator(self.recipes, self.request)
55
56
57class GitCharmRecipeListingView(CharmRecipeListingView):
58
59 source_enabled = False
60
61 @property
62 def label(self):
63 return "Charm recipes for %(display_name)s" % {
64 "display_name": self.context.display_name}
65
66
67class PersonCharmRecipeListingView(CharmRecipeListingView):
68
69 owner_enabled = False
70
71
72class ProjectCharmRecipeListingView(CharmRecipeListingView):
73 pass
diff --git a/lib/lp/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml
index 0475288..3c54b6e 100644
--- a/lib/lp/charms/browser/configure.zcml
+++ b/lib/lp/charms/browser/configure.zcml
@@ -76,5 +76,36 @@
76 for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild"76 for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild"
77 factory="lp.services.webapp.breadcrumb.TitleBreadcrumb"77 factory="lp.services.webapp.breadcrumb.TitleBreadcrumb"
78 permission="zope.Public" />78 permission="zope.Public" />
79
80 <browser:page
81 for="*"
82 class="lp.app.browser.launchpad.Macro"
83 permission="zope.Public"
84 name="+charm-recipe-macros"
85 template="../templates/charmrecipe-macros.pt" />
86 <browser:page
87 for="lp.code.interfaces.gitrepository.IGitRepository"
88 class="lp.charms.browser.charmrecipelisting.GitCharmRecipeListingView"
89 permission="launchpad.View"
90 name="+charm-recipes"
91 template="../templates/charmrecipe-listing.pt" />
92 <browser:page
93 for="lp.code.interfaces.gitref.IGitRef"
94 class="lp.charms.browser.charmrecipelisting.GitCharmRecipeListingView"
95 permission="launchpad.View"
96 name="+charm-recipes"
97 template="../templates/charmrecipe-listing.pt" />
98 <browser:page
99 for="lp.registry.interfaces.person.IPerson"
100 class="lp.charms.browser.charmrecipelisting.PersonCharmRecipeListingView"
101 permission="launchpad.View"
102 name="+charm-recipes"
103 template="../templates/charmrecipe-listing.pt" />
104 <browser:page
105 for="lp.registry.interfaces.product.IProduct"
106 class="lp.charms.browser.charmrecipelisting.ProjectCharmRecipeListingView"
107 permission="launchpad.View"
108 name="+charm-recipes"
109 template="../templates/charmrecipe-listing.pt" />
79 </facet>110 </facet>
80</configure>111</configure>
diff --git a/lib/lp/charms/browser/hascharmrecipes.py b/lib/lp/charms/browser/hascharmrecipes.py
81new file mode 100644112new file mode 100644
index 0000000..5c395b5
--- /dev/null
+++ b/lib/lp/charms/browser/hascharmrecipes.py
@@ -0,0 +1,64 @@
1# Copyright 2021 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Mixins for browser classes for objects that have charm recipes."""
5
6from __future__ import absolute_import, print_function, unicode_literals
7
8__metaclass__ = type
9__all__ = [
10 "HasCharmRecipesMenuMixin",
11 "HasCharmRecipesViewMixin",
12 ]
13
14from zope.component import getUtility
15
16from lp.charms.interfaces.charmrecipe import ICharmRecipeSet
17from lp.code.interfaces.gitrepository import IGitRepository
18from lp.services.webapp import (
19 canonical_url,
20 Link,
21 )
22from lp.services.webapp.escaping import structured
23
24
25class HasCharmRecipesMenuMixin:
26 """A mixin for context menus for objects that have charm recipes."""
27
28 def view_charm_recipes(self):
29 text = "View charm recipes"
30 enabled = not getUtility(ICharmRecipeSet).findByContext(
31 self.context, visible_by_user=self.user).is_empty()
32 return Link("+charm-recipes", text, icon="info", enabled=enabled)
33
34
35class HasCharmRecipesViewMixin:
36 """A view mixin for objects that have charm recipes."""
37
38 @property
39 def charm_recipes(self):
40 return getUtility(ICharmRecipeSet).findByContext(
41 self.context, visible_by_user=self.user)
42
43 @property
44 def charm_recipes_link(self):
45 """A link to charm recipes for this object."""
46 count = self.charm_recipes.count()
47 if IGitRepository.providedBy(self.context):
48 context_type = "repository"
49 else:
50 context_type = "branch"
51 if count == 0:
52 # Nothing to link to.
53 return "No charm recipes using this %s." % context_type
54 elif count == 1:
55 # Link to the single charm recipe.
56 return structured(
57 '<a href="%s">1 charm recipe</a> using this %s.',
58 canonical_url(self.charm_recipes.one()),
59 context_type).escapedtext
60 else:
61 # Link to a charm recipe listing.
62 return structured(
63 '<a href="+charm-recipes">%s charm recipes</a> using this %s.',
64 count, context_type).escapedtext
diff --git a/lib/lp/charms/browser/tests/test_charmrecipelisting.py b/lib/lp/charms/browser/tests/test_charmrecipelisting.py
0new file mode 10064465new file mode 100644
index 0000000..5bfd233
--- /dev/null
+++ b/lib/lp/charms/browser/tests/test_charmrecipelisting.py
@@ -0,0 +1,276 @@
1# Copyright 2021 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Test charm recipe listings."""
5
6from __future__ import absolute_import, print_function, unicode_literals
7
8__metaclass__ = type
9
10from datetime import (
11 datetime,
12 timedelta,
13 )
14from functools import partial
15
16import pytz
17import soupmatchers
18from testtools.matchers import (
19 MatchesAll,
20 Not,
21 )
22from zope.security.proxy import removeSecurityProxy
23
24from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
25from lp.code.tests.helpers import GitHostingFixture
26from lp.services.database.constants import (
27 ONE_DAY_AGO,
28 UTC_NOW,
29 )
30from lp.services.features.testing import MemoryFeatureFixture
31from lp.services.webapp import canonical_url
32from lp.testing import (
33 ANONYMOUS,
34 BrowserTestCase,
35 login,
36 person_logged_in,
37 record_two_runs,
38 )
39from lp.testing.layers import LaunchpadFunctionalLayer
40from lp.testing.matchers import HasQueryCount
41from lp.testing.views import create_initialized_view
42
43
44class TestCharmRecipeListing(BrowserTestCase):
45
46 layer = LaunchpadFunctionalLayer
47
48 def assertCharmRecipesLink(self, context, link_text,
49 link_has_context=False, **kwargs):
50 if link_has_context:
51 expected_href = canonical_url(context, view_name="+charm-recipes")
52 else:
53 expected_href = "+charm-recipes"
54 matcher = soupmatchers.HTMLContains(
55 soupmatchers.Tag(
56 "View charm recipes link", "a", text=link_text,
57 attrs={"href": expected_href}))
58 self.assertThat(self.getViewBrowser(context).contents, Not(matcher))
59 login(ANONYMOUS)
60 with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
61 self.factory.makeCharmRecipe(**kwargs)
62 self.factory.makeCharmRecipe(**kwargs)
63 self.assertThat(self.getViewBrowser(context).contents, matcher)
64
65 def test_git_repository_links_to_recipes(self):
66 repository = self.factory.makeGitRepository()
67 [ref] = self.factory.makeGitRefs(repository=repository)
68 self.assertCharmRecipesLink(repository, "2 charm recipes", git_ref=ref)
69
70 def test_git_ref_links_to_recipes(self):
71 self.useFixture(GitHostingFixture())
72 [ref] = self.factory.makeGitRefs()
73 self.assertCharmRecipesLink(ref, "2 charm recipes", git_ref=ref)
74
75 def test_person_links_to_recipes(self):
76 person = self.factory.makePerson()
77 self.assertCharmRecipesLink(
78 person, "View charm recipes", link_has_context=True,
79 registrant=person, owner=person)
80
81 def test_project_links_to_recipes(self):
82 project = self.factory.makeProduct()
83 [ref] = self.factory.makeGitRefs(target=project)
84 self.assertCharmRecipesLink(
85 project, "View charm recipes", link_has_context=True, git_ref=ref)
86
87 def test_git_repository_recipe_listing(self):
88 # We can see charm recipes for a Git repository.
89 repository = self.factory.makeGitRepository()
90 [ref] = self.factory.makeGitRefs(repository=repository)
91 with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
92 self.factory.makeCharmRecipe(git_ref=ref)
93 text = self.getMainText(repository, "+charm-recipes")
94 self.assertTextMatchesExpressionIgnoreWhitespace("""
95 Charm recipes for lp:~.*
96 Name Owner Registered
97 charm-name.* Team Name.* .*""", text)
98
99 def test_git_ref_recipe_listing(self):
100 # We can see charm recipes for a Git reference.
101 [ref] = self.factory.makeGitRefs()
102 with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
103 self.factory.makeCharmRecipe(git_ref=ref)
104 text = self.getMainText(ref, "+charm-recipes")
105 self.assertTextMatchesExpressionIgnoreWhitespace("""
106 Charm recipes for ~.*:.*
107 Name Owner Registered
108 charm-name.* Team Name.* .*""", text)
109
110 def test_person_recipe_listing(self):
111 # We can see charm recipes for a person.
112 owner = self.factory.makePerson(displayname="Charm Owner")
113 [ref] = self.factory.makeGitRefs()
114 with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
115 self.factory.makeCharmRecipe(
116 registrant=owner, owner=owner, git_ref=ref,
117 date_created=ONE_DAY_AGO)
118 text = self.getMainText(owner, "+charm-recipes")
119 self.assertTextMatchesExpressionIgnoreWhitespace("""
120 Charm recipes for Charm Owner
121 Name Source Registered
122 charm-name.* ~.*:.* .*""", text)
123
124 def test_project_recipe_listing(self):
125 # We can see charm recipes for a project.
126 project = self.factory.makeProduct(displayname="Charmable")
127 [ref] = self.factory.makeGitRefs(target=project)
128 with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
129 self.factory.makeCharmRecipe(git_ref=ref, date_created=UTC_NOW)
130 text = self.getMainText(project, "+charm-recipes")
131 self.assertTextMatchesExpressionIgnoreWhitespace("""
132 Charm recipes for Charmable
133 Name Owner Source Registered
134 charm-name.* Team Name.* ~.*:.* .*""", text)
135
136 def assertCharmRecipesQueryCount(self, context, item_creator):
137 self.pushConfig("launchpad", default_batch_size=10)
138 recorder1, recorder2 = record_two_runs(
139 lambda: self.getMainText(context, "+charm-recipes"),
140 item_creator, 5)
141 self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
142
143 def test_git_repository_query_count(self):
144 # The number of queries required to render the list of all charm
145 # recipes for a Git repository is constant in the number of owners
146 # and charm recipes.
147 person = self.factory.makePerson()
148 repository = self.factory.makeGitRepository(owner=person)
149
150 def create_recipe():
151 with person_logged_in(person):
152 [ref] = self.factory.makeGitRefs(repository=repository)
153 with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
154 self.factory.makeCharmRecipe(git_ref=ref)
155
156 self.assertCharmRecipesQueryCount(repository, create_recipe)
157
158 def test_git_ref_query_count(self):
159 # The number of queries required to render the list of all charm
160 # recipes for a Git reference is constant in the number of owners
161 # and charm recipes.
162 person = self.factory.makePerson()
163 [ref] = self.factory.makeGitRefs(owner=person)
164
165 def create_recipe():
166 with person_logged_in(person):
167 with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
168 self.factory.makeCharmRecipe(git_ref=ref)
169
170 self.assertCharmRecipesQueryCount(ref, create_recipe)
171
172 def test_person_query_count(self):
173 # The number of queries required to render the list of all charm
174 # recipes for a person is constant in the number of projects,
175 # sources, and charm recipes.
176 person = self.factory.makePerson()
177
178 def create_recipe():
179 with person_logged_in(person):
180 project = self.factory.makeProduct()
181 [ref] = self.factory.makeGitRefs(owner=person, target=project)
182 with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
183 self.factory.makeCharmRecipe(git_ref=ref)
184
185 self.assertCharmRecipesQueryCount(person, create_recipe)
186
187 def test_project_query_count(self):
188 # The number of queries required to render the list of all charm
189 # recipes for a person is constant in the number of owners, sources,
190 # and charm recipes.
191 person = self.factory.makePerson()
192 project = self.factory.makeProduct(owner=person)
193
194 def create_recipe():
195 with person_logged_in(person):
196 [ref] = self.factory.makeGitRefs(target=project)
197 with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
198 self.factory.makeCharmRecipe(git_ref=ref)
199
200 self.assertCharmRecipesQueryCount(project, create_recipe)
201
202 def makeCharmRecipesAndMatchers(self, create_recipe, count, start_time):
203 with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
204 recipes = [create_recipe() for i in range(count)]
205 for i, recipe in enumerate(recipes):
206 removeSecurityProxy(recipe).date_last_modified = (
207 start_time - timedelta(seconds=i))
208 return [
209 soupmatchers.Tag(
210 "charm recipe link", "a", text=recipe.name,
211 attrs={
212 "href": canonical_url(recipe, path_only_if_possible=True)})
213 for recipe in recipes]
214
215 def assertBatches(self, context, link_matchers, batched, start, size):
216 view = create_initialized_view(context, "+charm-recipes")
217 listing_tag = soupmatchers.Tag(
218 "charm recipe listing", "table",
219 attrs={"class": "listing sortable"})
220 batch_nav_tag = soupmatchers.Tag(
221 "batch nav links", "td",
222 attrs={"class": "batch-navigation-links"})
223 present_links = ([batch_nav_tag] if batched else []) + [
224 matcher for i, matcher in enumerate(link_matchers)
225 if i in range(start, start + size)]
226 absent_links = ([] if batched else [batch_nav_tag]) + [
227 matcher for i, matcher in enumerate(link_matchers)
228 if i not in range(start, start + size)]
229 self.assertThat(
230 view.render(),
231 MatchesAll(
232 soupmatchers.HTMLContains(listing_tag, *present_links),
233 Not(soupmatchers.HTMLContains(*absent_links))))
234
235 def test_git_repository_batches_recipes(self):
236 repository = self.factory.makeGitRepository()
237 [ref] = self.factory.makeGitRefs(repository=repository)
238 create_recipe = partial(self.factory.makeCharmRecipe, git_ref=ref)
239 now = datetime.now(pytz.UTC)
240 link_matchers = self.makeCharmRecipesAndMatchers(create_recipe, 3, now)
241 self.assertBatches(repository, link_matchers, False, 0, 3)
242 link_matchers.extend(self.makeCharmRecipesAndMatchers(
243 create_recipe, 7, now - timedelta(seconds=3)))
244 self.assertBatches(repository, link_matchers, True, 0, 5)
245
246 def test_git_ref_batches_recipes(self):
247 [ref] = self.factory.makeGitRefs()
248 create_recipe = partial(self.factory.makeCharmRecipe, git_ref=ref)
249 now = datetime.now(pytz.UTC)
250 link_matchers = self.makeCharmRecipesAndMatchers(create_recipe, 3, now)
251 self.assertBatches(ref, link_matchers, False, 0, 3)
252 link_matchers.extend(self.makeCharmRecipesAndMatchers(
253 create_recipe, 7, now - timedelta(seconds=3)))
254 self.assertBatches(ref, link_matchers, True, 0, 5)
255
256 def test_person_batches_recipes(self):
257 owner = self.factory.makePerson()
258 create_recipe = partial(
259 self.factory.makeCharmRecipe, registrant=owner, owner=owner)
260 now = datetime.now(pytz.UTC)
261 link_matchers = self.makeCharmRecipesAndMatchers(create_recipe, 3, now)
262 self.assertBatches(owner, link_matchers, False, 0, 3)
263 link_matchers.extend(self.makeCharmRecipesAndMatchers(
264 create_recipe, 7, now - timedelta(seconds=3)))
265 self.assertBatches(owner, link_matchers, True, 0, 5)
266
267 def test_project_batches_recipes(self):
268 project = self.factory.makeProduct()
269 [ref] = self.factory.makeGitRefs(target=project)
270 create_recipe = partial(self.factory.makeCharmRecipe, git_ref=ref)
271 now = datetime.now(pytz.UTC)
272 link_matchers = self.makeCharmRecipesAndMatchers(create_recipe, 3, now)
273 self.assertBatches(project, link_matchers, False, 0, 3)
274 link_matchers.extend(self.makeCharmRecipesAndMatchers(
275 create_recipe, 7, now - timedelta(seconds=3)))
276 self.assertBatches(project, link_matchers, True, 0, 5)
diff --git a/lib/lp/charms/browser/tests/test_hascharmrecipes.py b/lib/lp/charms/browser/tests/test_hascharmrecipes.py
0new file mode 100644277new file mode 100644
index 0000000..6374893
--- /dev/null
+++ b/lib/lp/charms/browser/tests/test_hascharmrecipes.py
@@ -0,0 +1,86 @@
1# Copyright 2021 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Test views for objects that have charm recipes."""
5
6from __future__ import absolute_import, print_function, unicode_literals
7
8__metaclass__ = type
9
10from testscenarios import (
11 load_tests_apply_scenarios,
12 WithScenarios,
13 )
14
15from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
16from lp.code.interfaces.gitrepository import IGitRepository
17from lp.services.features.testing import FeatureFixture
18from lp.services.webapp import canonical_url
19from lp.testing import TestCaseWithFactory
20from lp.testing.layers import DatabaseFunctionalLayer
21from lp.testing.views import create_initialized_view
22
23
24def make_git_repository(test_case):
25 return test_case.factory.makeGitRepository()
26
27
28def make_git_ref(test_case):
29 return test_case.factory.makeGitRefs()[0]
30
31
32class TestHasCharmRecipesView(WithScenarios, TestCaseWithFactory):
33
34 layer = DatabaseFunctionalLayer
35
36 scenarios = [
37 ("GitRepository", {
38 "context_type": "repository",
39 "context_factory": make_git_repository,
40 }),
41 ("GitRef", {
42 "context_type": "branch",
43 "context_factory": make_git_ref,
44 }),
45 ]
46
47 def setUp(self):
48 super(TestHasCharmRecipesView, self).setUp()
49 self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
50
51 def makeCharmRecipe(self, context):
52 if IGitRepository.providedBy(context):
53 [context] = self.factory.makeGitRefs(repository=context)
54 return self.factory.makeCharmRecipe(git_ref=context)
55
56 def test_charm_recipes_link_no_recipes(self):
57 # An object with no charm recipes does not show a charm recipes link.
58 context = self.context_factory(self)
59 view = create_initialized_view(context, "+index")
60 self.assertEqual(
61 "No charm recipes using this %s." % self.context_type,
62 view.charm_recipes_link)
63
64 def test_charm_recipes_link_one_recipe(self):
65 # An object with one charm recipe shows a link to that recipe.
66 context = self.context_factory(self)
67 recipe = self.makeCharmRecipe(context)
68 view = create_initialized_view(context, "+index")
69 expected_link = (
70 '<a href="%s">1 charm recipe</a> using this %s.' %
71 (canonical_url(recipe), self.context_type))
72 self.assertEqual(expected_link, view.charm_recipes_link)
73
74 def test_charm_recipes_link_more_recipes(self):
75 # An object with more than one charm recipe shows a link to a listing.
76 context = self.context_factory(self)
77 self.makeCharmRecipe(context)
78 self.makeCharmRecipe(context)
79 view = create_initialized_view(context, "+index")
80 expected_link = (
81 '<a href="+charm-recipes">2 charm recipes</a> using this %s.' %
82 self.context_type)
83 self.assertEqual(expected_link, view.charm_recipes_link)
84
85
86load_tests = load_tests_apply_scenarios
diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
index 6b70d1b..c972097 100644
--- a/lib/lp/charms/interfaces/charmrecipe.py
+++ b/lib/lp/charms/interfaces/charmrecipe.py
@@ -412,7 +412,7 @@ class ICharmRecipeEditableAttributes(Interface):
412 "recipe."))412 "recipe."))
413413
414 git_path = TextLine(414 git_path = TextLine(
415 title=_("Git branch path"), required=False, readonly=False,415 title=_("Git branch path"), required=False, readonly=True,
416 description=_(416 description=_(
417 "The path of the Git branch containing a charmcraft.yaml "417 "The path of the Git branch containing a charmcraft.yaml "
418 "recipe."))418 "recipe."))
@@ -513,6 +513,49 @@ class ICharmRecipeSet(Interface):
513 def getByName(owner, project, name):513 def getByName(owner, project, name):
514 """Returns the appropriate `ICharmRecipe` for the given objects."""514 """Returns the appropriate `ICharmRecipe` for the given objects."""
515515
516 def findByPerson(person, visible_by_user=None):
517 """Return all charm recipes relevant to `person`.
518
519 This returns charm recipes for Git branches owned by `person`, or
520 where `person` is the owner of the charm recipe.
521
522 :param person: An `IPerson`.
523 :param visible_by_user: If not None, only return recipes visible by
524 this user; otherwise, only return publicly-visible recipes.
525 """
526
527 def findByProject(project, visible_by_user=None):
528 """Return all charm recipes for the given project.
529
530 :param project: An `IProduct`.
531 :param visible_by_user: If not None, only return recipes visible by
532 this user; otherwise, only return publicly-visible recipes.
533 """
534
535 def findByGitRepository(repository, paths=None, check_permissions=True):
536 """Return all charm recipes for the given Git repository.
537
538 :param repository: An `IGitRepository`.
539 :param paths: If not None, only return charm recipes for one of
540 these Git reference paths.
541 """
542
543 def findByGitRef(ref):
544 """Return all charm recipes for the given Git reference."""
545
546 def findByContext(context, visible_by_user=None, order_by_date=True):
547 """Return all charm recipes for the given context.
548
549 :param context: An `IPerson`, `IProduct`, `IGitRepository`, or
550 `IGitRef`.
551 :param visible_by_user: If not None, only return recipes visible by
552 this user; otherwise, only return publicly-visible recipes.
553 :param order_by_date: If True, order recipes by descending
554 modification date.
555 :raises BadCharmRecipeSearchContext: if the context is not
556 understood.
557 """
558
516 def isValidInformationType(information_type, owner, git_ref=None):559 def isValidInformationType(information_type, owner, git_ref=None):
517 """Whether the information type context is valid."""560 """Whether the information type context is valid."""
518561
@@ -536,14 +579,6 @@ class ICharmRecipeSet(Interface):
536 cannot be parsed.579 cannot be parsed.
537 """580 """
538581
539 def findByGitRepository(repository, paths=None):
540 """Return all charm recipes for the given Git repository.
541
542 :param repository: An `IGitRepository`.
543 :param paths: If not None, only return charm recipes for one of
544 these Git reference paths.
545 """
546
547 def detachFromGitRepository(repository):582 def detachFromGitRepository(repository):
548 """Detach all charm recipes from the given Git repository.583 """Detach all charm recipes from the given Git repository.
549584
diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
index da24bf6..d9c9b5c 100644
--- a/lib/lp/charms/model/charmrecipe.py
+++ b/lib/lp/charms/model/charmrecipe.py
@@ -8,6 +8,7 @@ from __future__ import absolute_import, print_function, unicode_literals
8__metaclass__ = type8__metaclass__ = type
9__all__ = [9__all__ = [
10 "CharmRecipe",10 "CharmRecipe",
11 "get_charm_recipe_privacy_filter",
11 ]12 ]
1213
13from operator import (14from operator import (
@@ -47,6 +48,7 @@ from lp.buildmaster.interfaces.buildqueue import IBuildQueueSet
47from lp.buildmaster.model.builder import Builder48from lp.buildmaster.model.builder import Builder
48from lp.charms.adapters.buildarch import determine_instances_to_build49from lp.charms.adapters.buildarch import determine_instances_to_build
49from lp.charms.interfaces.charmrecipe import (50from lp.charms.interfaces.charmrecipe import (
51 BadCharmRecipeSearchContext,
50 CannotFetchCharmcraftYaml,52 CannotFetchCharmcraftYaml,
51 CannotParseCharmcraftYaml,53 CannotParseCharmcraftYaml,
52 CHARM_RECIPE_ALLOW_CREATE,54 CHARM_RECIPE_ALLOW_CREATE,
@@ -75,16 +77,26 @@ from lp.code.errors import (
75 GitRepositoryBlobNotFound,77 GitRepositoryBlobNotFound,
76 GitRepositoryScanFault,78 GitRepositoryScanFault,
77 )79 )
80from lp.code.interfaces.gitcollection import (
81 IAllGitRepositories,
82 IGitCollection,
83 )
84from lp.code.interfaces.gitref import IGitRef
85from lp.code.interfaces.gitrepository import IGitRepository
78from lp.code.model.gitcollection import GenericGitCollection86from lp.code.model.gitcollection import GenericGitCollection
87from lp.code.model.gitref import GitRef
79from lp.code.model.gitrepository import GitRepository88from lp.code.model.gitrepository import GitRepository
80from lp.registry.errors import PrivatePersonLinkageError89from lp.registry.errors import PrivatePersonLinkageError
81from lp.registry.interfaces.distribution import IDistributionSet90from lp.registry.interfaces.distribution import IDistributionSet
82from lp.registry.interfaces.person import (91from lp.registry.interfaces.person import (
92 IPerson,
83 IPersonSet,93 IPersonSet,
84 validate_public_person,94 validate_public_person,
85 )95 )
96from lp.registry.interfaces.product import IProduct
86from lp.registry.model.distribution import Distribution97from lp.registry.model.distribution import Distribution
87from lp.registry.model.distroseries import DistroSeries98from lp.registry.model.distroseries import DistroSeries
99from lp.registry.model.product import Product
88from lp.registry.model.series import ACTIVE_STATUSES100from lp.registry.model.series import ACTIVE_STATUSES
89from lp.services.database.bulk import load_related101from lp.services.database.bulk import load_related
90from lp.services.database.constants import (102from lp.services.database.constants import (
@@ -314,14 +326,18 @@ class CharmRecipe(StormBase):
314 """See `ICharmRecipe`."""326 """See `ICharmRecipe`."""
315 return self.information_type not in PUBLIC_INFORMATION_TYPES327 return self.information_type not in PUBLIC_INFORMATION_TYPES
316328
317 @property329 @cachedproperty
318 def git_ref(self):330 def _git_ref(self):
319 """See `ICharmRecipe`."""
320 if self.git_repository is not None:331 if self.git_repository is not None:
321 return self.git_repository.getRefByPath(self.git_path)332 return self.git_repository.getRefByPath(self.git_path)
322 else:333 else:
323 return None334 return None
324335
336 @property
337 def git_ref(self):
338 """See `ICharmRecipe`."""
339 return self._git_ref
340
325 @git_ref.setter341 @git_ref.setter
326 def git_ref(self, value):342 def git_ref(self, value):
327 """See `ICharmRecipe`."""343 """See `ICharmRecipe`."""
@@ -331,6 +347,7 @@ class CharmRecipe(StormBase):
331 else:347 else:
332 self.git_repository = None348 self.git_repository = None
333 self.git_path = None349 self.git_path = None
350 get_property_cache(self)._git_ref = value
334351
335 @property352 @property
336 def source(self):353 def source(self):
@@ -384,9 +401,12 @@ class CharmRecipe(StormBase):
384 """See `ICharmRecipe`."""401 """See `ICharmRecipe`."""
385 if self.information_type in PUBLIC_INFORMATION_TYPES:402 if self.information_type in PUBLIC_INFORMATION_TYPES:
386 return True403 return True
387 # XXX cjwatson 2021-05-27: Finish implementing this once we have404 if user is None:
388 # more privacy infrastructure.405 return False
389 return False406 return not IStore(CharmRecipe).find(
407 CharmRecipe,
408 CharmRecipe.id == self.id,
409 get_charm_recipe_privacy_filter(user)).is_empty()
390410
391 def _isBuildableArchitectureAllowed(self, das):411 def _isBuildableArchitectureAllowed(self, das):
392 """Check whether we may build for a buildable `DistroArchSeries`.412 """Check whether we may build for a buildable `DistroArchSeries`.
@@ -675,6 +695,82 @@ class CharmRecipeSet:
675 return IStore(CharmRecipe).find(695 return IStore(CharmRecipe).find(
676 CharmRecipe, owner=owner, project=project, name=name).one()696 CharmRecipe, owner=owner, project=project, name=name).one()
677697
698 def _getRecipesFromCollection(self, collection, owner=None,
699 visible_by_user=None):
700 id_column = CharmRecipe.git_repository_id
701 ids = collection.getRepositoryIds()
702 expressions = [id_column.is_in(ids._get_select())]
703 if owner is not None:
704 expressions.append(CharmRecipe.owner == owner)
705 expressions.append(get_charm_recipe_privacy_filter(visible_by_user))
706 return IStore(CharmRecipe).find(CharmRecipe, *expressions)
707
708 def findByPerson(self, person, visible_by_user=None):
709 """See `ICharmRecipeSet`."""
710 def _getRecipes(collection):
711 collection = collection.visibleByUser(visible_by_user)
712 owned = self._getRecipesFromCollection(
713 collection.ownedBy(person), visible_by_user=visible_by_user)
714 packaged = self._getRecipesFromCollection(
715 collection, owner=person, visible_by_user=visible_by_user)
716 return owned.union(packaged)
717
718 git_collection = removeSecurityProxy(getUtility(IAllGitRepositories))
719 git_recipes = _getRecipes(git_collection)
720 return git_recipes
721
722 def findByProject(self, project, visible_by_user=None):
723 """See `ICharmRecipeSet`."""
724 def _getRecipes(collection):
725 return self._getRecipesFromCollection(
726 collection.visibleByUser(visible_by_user),
727 visible_by_user=visible_by_user)
728
729 recipes_for_project = IStore(CharmRecipe).find(
730 CharmRecipe,
731 CharmRecipe.project == project,
732 get_charm_recipe_privacy_filter(visible_by_user))
733 git_collection = removeSecurityProxy(IGitCollection(project))
734 return recipes_for_project.union(_getRecipes(git_collection))
735
736 def findByGitRepository(self, repository, paths=None,
737 visible_by_user=None, check_permissions=True):
738 """See `ICharmRecipeSet`."""
739 clauses = [CharmRecipe.git_repository == repository]
740 if paths is not None:
741 clauses.append(CharmRecipe.git_path.is_in(paths))
742 if check_permissions:
743 clauses.append(get_charm_recipe_privacy_filter(visible_by_user))
744 return IStore(CharmRecipe).find(CharmRecipe, *clauses)
745
746 def findByGitRef(self, ref, visible_by_user=None):
747 """See `ICharmRecipeSet`."""
748 return IStore(CharmRecipe).find(
749 CharmRecipe,
750 CharmRecipe.git_repository == ref.repository,
751 CharmRecipe.git_path == ref.path,
752 get_charm_recipe_privacy_filter(visible_by_user))
753
754 def findByContext(self, context, visible_by_user=None, order_by_date=True):
755 """See `ICharmRecipeSet`."""
756 if IPerson.providedBy(context):
757 recipes = self.findByPerson(
758 context, visible_by_user=visible_by_user)
759 elif IProduct.providedBy(context):
760 recipes = self.findByProject(
761 context, visible_by_user=visible_by_user)
762 elif IGitRepository.providedBy(context):
763 recipes = self.findByGitRepository(
764 context, visible_by_user=visible_by_user)
765 elif IGitRef.providedBy(context):
766 recipes = self.findByGitRef(
767 context, visible_by_user=visible_by_user)
768 else:
769 raise BadCharmRecipeSearchContext(context)
770 if order_by_date:
771 recipes = recipes.order_by(Desc(CharmRecipe.date_last_modified))
772 return recipes
773
678 def isValidInformationType(self, information_type, owner, git_ref=None):774 def isValidInformationType(self, information_type, owner, git_ref=None):
679 """See `ICharmRecipeSet`."""775 """See `ICharmRecipeSet`."""
680 private = information_type not in PUBLIC_INFORMATION_TYPES776 private = information_type not in PUBLIC_INFORMATION_TYPES
@@ -698,6 +794,8 @@ class CharmRecipeSet:
698 """See `ICharmRecipeSet`."""794 """See `ICharmRecipeSet`."""
699 recipes = [removeSecurityProxy(recipe) for recipe in recipes]795 recipes = [removeSecurityProxy(recipe) for recipe in recipes]
700796
797 load_related(Product, recipes, ["project_id"])
798
701 person_ids = set()799 person_ids = set()
702 for recipe in recipes:800 for recipe in recipes:
703 person_ids.add(recipe.registrant_id)801 person_ids.add(recipe.registrant_id)
@@ -708,6 +806,13 @@ class CharmRecipeSet:
708 if repositories:806 if repositories:
709 GenericGitCollection.preloadDataForRepositories(repositories)807 GenericGitCollection.preloadDataForRepositories(repositories)
710808
809 git_refs = GitRef.findByReposAndPaths(
810 [(recipe.git_repository, recipe.git_path) for recipe in recipes])
811 for recipe in recipes:
812 git_ref = git_refs.get((recipe.git_repository, recipe.git_path))
813 if git_ref is not None:
814 get_property_cache(recipe)._git_ref = git_ref
815
711 # Add repository owners to the list of pre-loaded persons. We need816 # Add repository owners to the list of pre-loaded persons. We need
712 # the target repository owner as well, since repository unique names817 # the target repository owner as well, since repository unique names
713 # aren't trigger-maintained.818 # aren't trigger-maintained.
@@ -760,16 +865,20 @@ class CharmRecipeSet:
760865
761 return charmcraft_data866 return charmcraft_data
762867
763 def findByGitRepository(self, repository, paths=None):
764 """See `ICharmRecipeSet`."""
765 clauses = [CharmRecipe.git_repository == repository]
766 if paths is not None:
767 clauses.append(CharmRecipe.git_path.is_in(paths))
768 # XXX cjwatson 2021-05-26: Check permissions once we have some
769 # privacy infrastructure.
770 return IStore(CharmRecipe).find(CharmRecipe, *clauses)
771
772 def detachFromGitRepository(self, repository):868 def detachFromGitRepository(self, repository):
773 """See `ICharmRecipeSet`."""869 """See `ICharmRecipeSet`."""
774 self.findByGitRepository(repository).set(870 recipes = self.findByGitRepository(repository)
871 for recipe in recipes:
872 get_property_cache(recipe)._git_ref = None
873 recipes.set(
775 git_repository_id=None, git_path=None, date_last_modified=UTC_NOW)874 git_repository_id=None, git_path=None, date_last_modified=UTC_NOW)
875
876
877def get_charm_recipe_privacy_filter(user):
878 """Return a Storm query filter to find charm recipes visible to `user`."""
879 public_filter = CharmRecipe.information_type.is_in(
880 PUBLIC_INFORMATION_TYPES)
881
882 # XXX cjwatson 2021-06-07: Flesh this out once we have more privacy
883 # infrastructure.
884 return [public_filter]
diff --git a/lib/lp/charms/model/charmrecipebuild.py b/lib/lp/charms/model/charmrecipebuild.py
index 24d7ff7..088b7b1 100644
--- a/lib/lp/charms/model/charmrecipebuild.py
+++ b/lib/lp/charms/model/charmrecipebuild.py
@@ -47,6 +47,8 @@ from lp.charms.interfaces.charmrecipebuild import (
47from lp.charms.mail.charmrecipebuild import CharmRecipeBuildMailer47from lp.charms.mail.charmrecipebuild import CharmRecipeBuildMailer
48from lp.registry.interfaces.pocket import PackagePublishingPocket48from lp.registry.interfaces.pocket import PackagePublishingPocket
49from lp.registry.interfaces.series import SeriesStatus49from lp.registry.interfaces.series import SeriesStatus
50from lp.registry.model.distribution import Distribution
51from lp.registry.model.distroseries import DistroSeries
50from lp.registry.model.person import Person52from lp.registry.model.person import Person
51from lp.services.config import config53from lp.services.config import config
52from lp.services.database.bulk import load_related54from lp.services.database.bulk import load_related
@@ -67,6 +69,7 @@ from lp.services.propertycache import (
67 get_property_cache,69 get_property_cache,
68 )70 )
69from lp.services.webapp.snapshot import notify_modified71from lp.services.webapp.snapshot import notify_modified
72from lp.soyuz.model.distroarchseries import DistroArchSeries
7073
7174
72@implementer(ICharmRecipeBuild)75@implementer(ICharmRecipeBuild)
@@ -431,6 +434,11 @@ class CharmRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
431 load_related(Person, builds, ["requester_id"])434 load_related(Person, builds, ["requester_id"])
432 lfas = load_related(LibraryFileAlias, builds, ["log_id"])435 lfas = load_related(LibraryFileAlias, builds, ["log_id"])
433 load_related(LibraryFileContent, lfas, ["contentID"])436 load_related(LibraryFileContent, lfas, ["contentID"])
437 distroarchserieses = load_related(
438 DistroArchSeries, builds, ["distro_arch_series_id"])
439 distroserieses = load_related(
440 DistroSeries, distroarchserieses, ["distroseriesID"])
441 load_related(Distribution, distroserieses, ["distributionID"])
434 recipes = load_related(CharmRecipe, builds, ["recipe_id"])442 recipes = load_related(CharmRecipe, builds, ["recipe_id"])
435 getUtility(ICharmRecipeSet).preloadDataForRecipes(recipes)443 getUtility(ICharmRecipeSet).preloadDataForRecipes(recipes)
436444
diff --git a/lib/lp/charms/templates/charmrecipe-listing.pt b/lib/lp/charms/templates/charmrecipe-listing.pt
437new file mode 100644445new file mode 100644
index 0000000..8f163d7
--- /dev/null
+++ b/lib/lp/charms/templates/charmrecipe-listing.pt
@@ -0,0 +1,46 @@
1<html
2 xmlns="http://www.w3.org/1999/xhtml"
3 xmlns:tal="http://xml.zope.org/namespaces/tal"
4 xmlns:metal="http://xml.zope.org/namespaces/metal"
5 xmlns:i18n="http://xml.zope.org/namespaces/i18n"
6 metal:use-macro="view/macro:page/main_only"
7 i18n:domain="launchpad">
8
9<body>
10
11 <div metal:fill-slot="main">
12
13 <tal:navigation
14 condition="view/batchnav/has_multiple_pages"
15 replace="structure view/batchnav/@@+navigation-links-upper" />
16 <table id="charm-recipe-table" class="listing sortable">
17 <thead>
18 <tr>
19 <th colspan="2">Name</th>
20 <th tal:condition="view/owner_enabled">Owner</th>
21 <th tal:condition="view/source_enabled">Source</th>
22 <th>Registered</th>
23 </tr>
24 </thead>
25 <tbody>
26 <tal:recipes repeat="recipe view/batchnav/currentBatch">
27 <tr>
28 <td colspan="2">
29 <a tal:attributes="href recipe/fmt:url" tal:content="recipe/name" />
30 </td>
31 <td tal:condition="view/owner_enabled"
32 tal:content="structure recipe/owner/fmt:link" />
33 <td tal:condition="view/source_enabled"
34 tal:content="structure recipe/source/fmt:link" />
35 <td tal:content="recipe/date_created/fmt:datetime" />
36 </tr>
37 </tal:recipes>
38 </tbody>
39 </table>
40 <tal:navigation
41 condition="view/batchnav/has_multiple_pages"
42 replace="structure view/batchnav/@@+navigation-links-lower" />
43
44 </div>
45</body>
46</html>
diff --git a/lib/lp/charms/templates/charmrecipe-macros.pt b/lib/lp/charms/templates/charmrecipe-macros.pt
0new file mode 10064447new file mode 100644
index 0000000..93b4d69
--- /dev/null
+++ b/lib/lp/charms/templates/charmrecipe-macros.pt
@@ -0,0 +1,22 @@
1<tal:root
2 xmlns:tal="http://xml.zope.org/namespaces/tal"
3 xmlns:metal="http://xml.zope.org/namespaces/metal"
4 xmlns:i18n="http://xml.zope.org/namespaces/i18n"
5 omit-tag="">
6
7<div
8 metal:define-macro="related-charm-recipes"
9 tal:define="context_menu context/menu:context"
10 id="related-charm-recipes">
11
12 <h3>Related charm recipes</h3>
13
14 <div id="charm-recipe-links" class="actions">
15 <div id="charm-recipe-summary">
16 <tal:charm_recipes replace="structure view/charm_recipes_link" />
17 </div>
18 </div>
19
20</div>
21
22</tal:root>
diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py
index 173e024..af7f1e3 100644
--- a/lib/lp/charms/tests/test_charmrecipe.py
+++ b/lib/lp/charms/tests/test_charmrecipe.py
@@ -34,6 +34,7 @@ from lp.buildmaster.interfaces.processor import (
34 )34 )
35from lp.buildmaster.model.buildqueue import BuildQueue35from lp.buildmaster.model.buildqueue import BuildQueue
36from lp.charms.interfaces.charmrecipe import (36from lp.charms.interfaces.charmrecipe import (
37 BadCharmRecipeSearchContext,
37 CHARM_RECIPE_ALLOW_CREATE,38 CHARM_RECIPE_ALLOW_CREATE,
38 CHARM_RECIPE_BUILD_DISTRIBUTION,39 CHARM_RECIPE_BUILD_DISTRIBUTION,
39 CharmRecipeBuildAlreadyPending,40 CharmRecipeBuildAlreadyPending,
@@ -583,6 +584,40 @@ class TestCharmRecipeSet(TestCaseWithFactory):
583 getUtility(ICharmRecipeSet).getByName(584 getUtility(ICharmRecipeSet).getByName(
584 owner, project, "proj-charm"))585 owner, project, "proj-charm"))
585586
587 def test_findByPerson(self):
588 # ICharmRecipeSet.findByPerson returns all charm recipes with the
589 # given owner or based on repositories with the given owner.
590 owners = [self.factory.makePerson() for i in range(2)]
591 recipes = []
592 for owner in owners:
593 recipes.append(self.factory.makeCharmRecipe(
594 registrant=owner, owner=owner))
595 [ref] = self.factory.makeGitRefs(owner=owner)
596 recipes.append(self.factory.makeCharmRecipe(git_ref=ref))
597 recipe_set = getUtility(ICharmRecipeSet)
598 self.assertContentEqual(
599 recipes[:2], recipe_set.findByPerson(owners[0]))
600 self.assertContentEqual(
601 recipes[2:], recipe_set.findByPerson(owners[1]))
602
603 def test_findByProject(self):
604 # ICharmRecipeSet.findByProject returns all charm recipes based on
605 # repositories for the given project, and charm recipes associated
606 # directly with the project.
607 projects = [self.factory.makeProduct() for i in range(2)]
608 recipes = []
609 for project in projects:
610 [ref] = self.factory.makeGitRefs(target=project)
611 recipes.append(self.factory.makeCharmRecipe(git_ref=ref))
612 recipes.append(self.factory.makeCharmRecipe(project=project))
613 [ref] = self.factory.makeGitRefs(target=None)
614 recipes.append(self.factory.makeCharmRecipe(git_ref=ref))
615 recipe_set = getUtility(ICharmRecipeSet)
616 self.assertContentEqual(
617 recipes[:2], recipe_set.findByProject(projects[0]))
618 self.assertContentEqual(
619 recipes[2:4], recipe_set.findByProject(projects[1]))
620
586 def test_findByGitRepository(self):621 def test_findByGitRepository(self):
587 # ICharmRecipeSet.findByGitRepository returns all charm recipes with622 # ICharmRecipeSet.findByGitRepository returns all charm recipes with
588 # the given Git repository.623 # the given Git repository.
@@ -620,6 +655,55 @@ class TestCharmRecipeSet(TestCaseWithFactory):
620 repositories[0],655 repositories[0],
621 paths=[recipes[0].git_ref.path, recipes[1].git_ref.path]))656 paths=[recipes[0].git_ref.path, recipes[1].git_ref.path]))
622657
658 def test_findByGitRef(self):
659 # ICharmRecipeSet.findByGitRef returns all charm recipes with the
660 # given Git reference.
661 repositories = [self.factory.makeGitRepository() for i in range(2)]
662 refs = []
663 recipes = []
664 for repository in repositories:
665 refs.extend(self.factory.makeGitRefs(
666 paths=["refs/heads/master", "refs/heads/other"]))
667 recipes.append(self.factory.makeCharmRecipe(git_ref=refs[-2]))
668 recipes.append(self.factory.makeCharmRecipe(git_ref=refs[-1]))
669 recipe_set = getUtility(ICharmRecipeSet)
670 for ref, recipe in zip(refs, recipes):
671 self.assertContentEqual([recipe], recipe_set.findByGitRef(ref))
672
673 def test_findByContext(self):
674 # ICharmRecipeSet.findByContext returns all charm recipes with the
675 # given context.
676 person = self.factory.makePerson()
677 project = self.factory.makeProduct()
678 repository = self.factory.makeGitRepository(
679 owner=person, target=project)
680 refs = self.factory.makeGitRefs(
681 repository=repository,
682 paths=["refs/heads/master", "refs/heads/other"])
683 other_repository = self.factory.makeGitRepository()
684 other_refs = self.factory.makeGitRefs(
685 repository=other_repository,
686 paths=["refs/heads/master", "refs/heads/other"])
687 recipes = []
688 recipes.append(self.factory.makeCharmRecipe(git_ref=refs[0]))
689 recipes.append(self.factory.makeCharmRecipe(git_ref=refs[1]))
690 recipes.append(self.factory.makeCharmRecipe(
691 registrant=person, owner=person, git_ref=other_refs[0]))
692 recipes.append(self.factory.makeCharmRecipe(
693 project=project, git_ref=other_refs[1]))
694 recipe_set = getUtility(ICharmRecipeSet)
695 self.assertContentEqual(recipes[:3], recipe_set.findByContext(person))
696 self.assertContentEqual(
697 [recipes[0], recipes[1], recipes[3]],
698 recipe_set.findByContext(project))
699 self.assertContentEqual(
700 recipes[:2], recipe_set.findByContext(repository))
701 self.assertContentEqual(
702 [recipes[0]], recipe_set.findByContext(refs[0]))
703 self.assertRaises(
704 BadCharmRecipeSearchContext, recipe_set.findByContext,
705 self.factory.makeDistribution())
706
623 def test_detachFromGitRepository(self):707 def test_detachFromGitRepository(self):
624 # ICharmRecipeSet.detachFromGitRepository clears the given Git708 # ICharmRecipeSet.detachFromGitRepository clears the given Git
625 # repository from all charm recipes.709 # repository from all charm recipes.
diff --git a/lib/lp/code/browser/gitref.py b/lib/lp/code/browser/gitref.py
index b33dd20..c53f8f1 100644
--- a/lib/lp/code/browser/gitref.py
+++ b/lib/lp/code/browser/gitref.py
@@ -35,6 +35,10 @@ from lp.app.browser.launchpadform import (
35 action,35 action,
36 LaunchpadFormView,36 LaunchpadFormView,
37 )37 )
38from lp.charms.browser.hascharmrecipes import (
39 HasCharmRecipesMenuMixin,
40 HasCharmRecipesViewMixin,
41 )
38from lp.code.browser.branchmergeproposal import (42from lp.code.browser.branchmergeproposal import (
39 latest_proposals_for_each_branch,43 latest_proposals_for_each_branch,
40 )44 )
@@ -71,7 +75,9 @@ from lp.snappy.browser.hassnaps import (
71 )75 )
7276
7377
74class GitRefContextMenu(ContextMenu, HasRecipesMenuMixin, HasSnapsMenuMixin):78class GitRefContextMenu(
79 ContextMenu, HasRecipesMenuMixin, HasSnapsMenuMixin,
80 HasCharmRecipesMenuMixin):
75 """Context menu for Git references."""81 """Context menu for Git references."""
7682
77 usedfor = IGitRef83 usedfor = IGitRef
@@ -82,6 +88,7 @@ class GitRefContextMenu(ContextMenu, HasRecipesMenuMixin, HasSnapsMenuMixin):
82 'create_snap',88 'create_snap',
83 'register_merge',89 'register_merge',
84 'source',90 'source',
91 'view_charm_recipes',
85 'view_recipes',92 'view_recipes',
86 ]93 ]
8794
@@ -111,7 +118,7 @@ class GitRefContextMenu(ContextMenu, HasRecipesMenuMixin, HasSnapsMenuMixin):
111 return Link("+new-recipe", text, enabled=enabled, icon="add")118 return Link("+new-recipe", text, enabled=enabled, icon="add")
112119
113120
114class GitRefView(LaunchpadView, HasSnapsViewMixin):121class GitRefView(LaunchpadView, HasSnapsViewMixin, HasCharmRecipesViewMixin):
115122
116 # This is set at self.commit_infos, and should be accessed by the view123 # This is set at self.commit_infos, and should be accessed by the view
117 # as self.commit_info_message.124 # as self.commit_info_message.
diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py
index c41cafe..fb51ca1 100644
--- a/lib/lp/code/browser/gitrepository.py
+++ b/lib/lp/code/browser/gitrepository.py
@@ -75,6 +75,7 @@ from lp.app.errors import (
75 )75 )
76from lp.app.vocabularies import InformationTypeVocabulary76from lp.app.vocabularies import InformationTypeVocabulary
77from lp.app.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription77from lp.app.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription
78from lp.charms.browser.hascharmrecipes import HasCharmRecipesViewMixin
78from lp.code.browser.branch import CodeEditOwnerMixin79from lp.code.browser.branch import CodeEditOwnerMixin
79from lp.code.browser.branchmergeproposal import (80from lp.code.browser.branchmergeproposal import (
80 latest_proposals_for_each_branch,81 latest_proposals_for_each_branch,
@@ -368,7 +369,8 @@ class GitRefBatchNavigator(TableBatchNavigator):
368369
369370
370class GitRepositoryView(InformationTypePortletMixin, LaunchpadView,371class GitRepositoryView(InformationTypePortletMixin, LaunchpadView,
371 HasSnapsViewMixin, CodeImportTargetMixin):372 HasSnapsViewMixin, HasCharmRecipesViewMixin,
373 CodeImportTargetMixin):
372374
373 @property375 @property
374 def page_title(self):376 def page_title(self):
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index 0a98bd3..d5a5b38 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -793,6 +793,12 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
793 Store.of(self).find(793 Store.of(self).find(
794 GitRef,794 GitRef,
795 GitRef.repository == self, GitRef.path.is_in(paths)).remove()795 GitRef.repository == self, GitRef.path.is_in(paths)).remove()
796 # Clear cached references to the removed refs.
797 # XXX cjwatson 2021-06-08: We should probably do something similar
798 # for OCIRecipe, and for Snap if we start caching git_ref there.
799 for recipe in getUtility(ICharmRecipeSet).findByGitRepository(
800 self, paths=paths):
801 get_property_cache(recipe)._git_ref = None
796 self.date_last_modified = UTC_NOW802 self.date_last_modified = UTC_NOW
797803
798 def planRefChanges(self, hosting_path, logger=None):804 def planRefChanges(self, hosting_path, logger=None):
diff --git a/lib/lp/code/templates/gitref-index.pt b/lib/lp/code/templates/gitref-index.pt
index 548ef39..1916bb0 100644
--- a/lib/lp/code/templates/gitref-index.pt
+++ b/lib/lp/code/templates/gitref-index.pt
@@ -38,6 +38,7 @@
38 replace="structure context/@@++ref-pending-merges" />38 replace="structure context/@@++ref-pending-merges" />
39 <tal:ref-recipes replace="structure context/@@++ref-recipes" />39 <tal:ref-recipes replace="structure context/@@++ref-recipes" />
40 <div metal:use-macro="context/@@+snap-macros/related-snaps" />40 <div metal:use-macro="context/@@+snap-macros/related-snaps" />
41 <div metal:use-macro="context/@@+charm-recipe-macros/related-charm-recipes" />
41 </div>42 </div>
42 </div>43 </div>
4344
diff --git a/lib/lp/code/templates/gitrepository-index.pt b/lib/lp/code/templates/gitrepository-index.pt
index 89f202f..c7da7d1 100644
--- a/lib/lp/code/templates/gitrepository-index.pt
+++ b/lib/lp/code/templates/gitrepository-index.pt
@@ -65,6 +65,7 @@
65 <div metal:use-macro="context/@@+snap-macros/related-snaps">65 <div metal:use-macro="context/@@+snap-macros/related-snaps">
66 <metal:context-type fill-slot="context_type">repository</metal:context-type>66 <metal:context-type fill-slot="context_type">repository</metal:context-type>
67 </div>67 </div>
68 <div metal:use-macro="context/@@+charm-recipe-macros/related-charm-recipes" />
68 </div>69 </div>
69 </div>70 </div>
7071
diff --git a/lib/lp/registry/browser/person.py b/lib/lp/registry/browser/person.py
index d0766c5..233113d 100644
--- a/lib/lp/registry/browser/person.py
+++ b/lib/lp/registry/browser/person.py
@@ -135,6 +135,7 @@ from lp.bugs.interfaces.bugsupervisor import IHasBugSupervisor
135from lp.bugs.interfaces.bugtask import BugTaskStatus135from lp.bugs.interfaces.bugtask import BugTaskStatus
136from lp.bugs.interfaces.bugtasksearch import BugTaskSearchParams136from lp.bugs.interfaces.bugtasksearch import BugTaskSearchParams
137from lp.buildmaster.enums import BuildStatus137from lp.buildmaster.enums import BuildStatus
138from lp.charms.browser.hascharmrecipes import HasCharmRecipesMenuMixin
138from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin139from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
139from lp.code.errors import InvalidNamespace140from lp.code.errors import InvalidNamespace
140from lp.code.interfaces.branchnamespace import IBranchNamespaceSet141from lp.code.interfaces.branchnamespace import IBranchNamespaceSet
@@ -812,7 +813,8 @@ class PersonMenuMixin(CommonMenuLinks):
812813
813814
814class PersonOverviewMenu(ApplicationMenu, PersonMenuMixin, HasRecipesMenuMixin,815class PersonOverviewMenu(ApplicationMenu, PersonMenuMixin, HasRecipesMenuMixin,
815 HasSnapsMenuMixin, HasOCIRecipesMenuMixin):816 HasSnapsMenuMixin, HasOCIRecipesMenuMixin,
817 HasCharmRecipesMenuMixin):
816818
817 usedfor = IPerson819 usedfor = IPerson
818 facet = 'overview'820 facet = 'overview'
@@ -842,6 +844,7 @@ class PersonOverviewMenu(ApplicationMenu, PersonMenuMixin, HasRecipesMenuMixin,
842 'oauth_tokens',844 'oauth_tokens',
843 'oci_registry_credentials',845 'oci_registry_credentials',
844 'related_software_summary',846 'related_software_summary',
847 'view_charm_recipes',
845 'view_recipes',848 'view_recipes',
846 'view_snaps',849 'view_snaps',
847 'view_oci_recipes',850 'view_oci_recipes',
diff --git a/lib/lp/registry/browser/product.py b/lib/lp/registry/browser/product.py
index df7ee7a..628fe0e 100644
--- a/lib/lp/registry/browser/product.py
+++ b/lib/lp/registry/browser/product.py
@@ -139,6 +139,7 @@ from lp.bugs.browser.structuralsubscription import (
139 StructuralSubscriptionTargetTraversalMixin,139 StructuralSubscriptionTargetTraversalMixin,
140 )140 )
141from lp.bugs.interfaces.bugtask import RESOLVED_BUGTASK_STATUSES141from lp.bugs.interfaces.bugtask import RESOLVED_BUGTASK_STATUSES
142from lp.charms.browser.hascharmrecipes import HasCharmRecipesMenuMixin
142from lp.code.browser.branchref import BranchRef143from lp.code.browser.branchref import BranchRef
143from lp.code.browser.codeimport import (144from lp.code.browser.codeimport import (
144 CodeImportNameValidationMixin,145 CodeImportNameValidationMixin,
@@ -233,7 +234,6 @@ from lp.services.webapp.vhosts import allvhosts
233from lp.services.worlddata.helpers import browser_languages234from lp.services.worlddata.helpers import browser_languages
234from lp.services.worlddata.interfaces.country import ICountry235from lp.services.worlddata.interfaces.country import ICountry
235from lp.snappy.browser.hassnaps import HasSnapsMenuMixin236from lp.snappy.browser.hassnaps import HasSnapsMenuMixin
236from lp.snappy.interfaces.snap import ISnapSet
237from lp.translations.browser.customlanguagecode import (237from lp.translations.browser.customlanguagecode import (
238 HasCustomLanguageCodesTraversalMixin,238 HasCustomLanguageCodesTraversalMixin,
239 )239 )
@@ -559,7 +559,8 @@ class ProductActionNavigationMenu(NavigationMenu, ProductEditLinksMixin):
559559
560560
561class ProductOverviewMenu(ApplicationMenu, ProductEditLinksMixin,561class ProductOverviewMenu(ApplicationMenu, ProductEditLinksMixin,
562 HasRecipesMenuMixin, HasSnapsMenuMixin):562 HasRecipesMenuMixin, HasSnapsMenuMixin,
563 HasCharmRecipesMenuMixin):
563564
564 usedfor = IProduct565 usedfor = IProduct
565 facet = 'overview'566 facet = 'overview'
@@ -584,6 +585,7 @@ class ProductOverviewMenu(ApplicationMenu, ProductEditLinksMixin,
584 'review_license',585 'review_license',
585 'rdf',586 'rdf',
586 'branding',587 'branding',
588 'view_charm_recipes',
587 'view_recipes',589 'view_recipes',
588 'view_snaps',590 'view_snaps',
589 'create_snap',591 'create_snap',
diff --git a/lib/lp/registry/browser/team.py b/lib/lp/registry/browser/team.py
index d419603..980ee1e 100644
--- a/lib/lp/registry/browser/team.py
+++ b/lib/lp/registry/browser/team.py
@@ -93,6 +93,7 @@ from lp.app.widgets.itemswidgets import (
93 )93 )
94from lp.app.widgets.owner import HiddenUserWidget94from lp.app.widgets.owner import HiddenUserWidget
95from lp.app.widgets.popup import PersonPickerWidget95from lp.app.widgets.popup import PersonPickerWidget
96from lp.charms.browser.hascharmrecipes import HasCharmRecipesMenuMixin
96from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin97from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
97from lp.oci.browser.hasocirecipes import HasOCIRecipesMenuMixin98from lp.oci.browser.hasocirecipes import HasOCIRecipesMenuMixin
98from lp.registry.browser.branding import BrandingChangeView99from lp.registry.browser.branding import BrandingChangeView
@@ -1624,7 +1625,8 @@ class TeamMenuMixin(PPANavigationMenuMixIn, CommonMenuLinks):
16241625
16251626
1626class TeamOverviewMenu(ApplicationMenu, TeamMenuMixin, HasRecipesMenuMixin,1627class TeamOverviewMenu(ApplicationMenu, TeamMenuMixin, HasRecipesMenuMixin,
1627 HasSnapsMenuMixin, HasOCIRecipesMenuMixin):1628 HasSnapsMenuMixin, HasOCIRecipesMenuMixin,
1629 HasCharmRecipesMenuMixin):
16281630
1629 usedfor = ITeam1631 usedfor = ITeam
1630 facet = 'overview'1632 facet = 'overview'
@@ -1652,6 +1654,7 @@ class TeamOverviewMenu(ApplicationMenu, TeamMenuMixin, HasRecipesMenuMixin,
1652 'maintained',1654 'maintained',
1653 'ppa',1655 'ppa',
1654 'related_software_summary',1656 'related_software_summary',
1657 'view_charm_recipes',
1655 'view_recipes',1658 'view_recipes',
1656 'view_snaps',1659 'view_snaps',
1657 'view_oci_recipes',1660 'view_oci_recipes',
diff --git a/lib/lp/registry/templates/product-index.pt b/lib/lp/registry/templates/product-index.pt
index 7d60223..242483d 100644
--- a/lib/lp/registry/templates/product-index.pt
+++ b/lib/lp/registry/templates/product-index.pt
@@ -188,6 +188,10 @@
188 tal:condition="link/enabled">188 tal:condition="link/enabled">
189 <a tal:replace="structure link/fmt:link" />189 <a tal:replace="structure link/fmt:link" />
190 </li>190 </li>
191 <li tal:define="link context/menu:overview/view_charm_recipes"
192 tal:condition="link/enabled">
193 <a tal:replace="structure link/fmt:link" />
194 </li>
191 </ul>195 </ul>
192 </div>196 </div>
193 </div>197 </div>
diff --git a/lib/lp/soyuz/templates/person-portlet-ppas.pt b/lib/lp/soyuz/templates/person-portlet-ppas.pt
index 19b7e6e..a97a9d5 100644
--- a/lib/lp/soyuz/templates/person-portlet-ppas.pt
+++ b/lib/lp/soyuz/templates/person-portlet-ppas.pt
@@ -34,10 +34,12 @@
34 <ul class="horizontal" style="margin-top: 0;"34 <ul class="horizontal" style="margin-top: 0;"
35 tal:define="recipes_link context/menu:overview/view_recipes;35 tal:define="recipes_link context/menu:overview/view_recipes;
36 snaps_link context/menu:overview/view_snaps;36 snaps_link context/menu:overview/view_snaps;
37 oci_recipes_link context/menu:overview/view_oci_recipes"37 oci_recipes_link context/menu:overview/view_oci_recipes;
38 charm_recipes_link context/menu:overview/view_charm_recipes"
38 tal:condition="python: recipes_link.enabled39 tal:condition="python: recipes_link.enabled
39 or snaps_link.enabled40 or snaps_link.enabled
40 or oci_recipes_link.enabled">41 or oci_recipes_link.enabled
42 or charm_recipes_link.enabled">
41 <li tal:condition="recipes_link/enabled">43 <li tal:condition="recipes_link/enabled">
42 <a tal:replace="structure recipes_link/fmt:link" />44 <a tal:replace="structure recipes_link/fmt:link" />
43 </li>45 </li>
@@ -47,5 +49,8 @@
47 <li tal:condition="oci_recipes_link/enabled">49 <li tal:condition="oci_recipes_link/enabled">
48 <a tal:replace="structure oci_recipes_link/fmt:link" />50 <a tal:replace="structure oci_recipes_link/fmt:link" />
49 </li>51 </li>
52 <li tal:condition="charm_recipes_link/enabled">
53 <a tal:replace="structure charm_recipes_link/fmt:link" />
54 </li>
50 </ul>55 </ul>
51</tal:root>56</tal:root>

Subscribers

People subscribed via source and target branches

to status/vote changes: