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