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