launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #27193
[Merge] ~cjwatson/launchpad:charm-recipe-listing-views into launchpad:master
Colin Watson has proposed merging ~cjwatson/launchpad:charm-recipe-listing-views into launchpad:master with ~cjwatson/launchpad:charm-recipe-build-basic-browser as a prerequisite.
Commit message:
Add charm recipe listing views
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/403878
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charm-recipe-listing-views into launchpad:master.
diff --git a/lib/lp/charms/browser/charmrecipelisting.py b/lib/lp/charms/browser/charmrecipelisting.py
new file mode 100644
index 0000000..d9b3edd
--- /dev/null
+++ b/lib/lp/charms/browser/charmrecipelisting.py
@@ -0,0 +1,73 @@
+# Copyright 2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Base class view for charm recipe listings."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+__all__ = [
+ "GitCharmRecipeListingView",
+ "PersonCharmRecipeListingView",
+ "ProjectCharmRecipeListingView",
+ ]
+
+from functools import partial
+
+from zope.component import getUtility
+
+from lp.charms.interfaces.charmrecipe import ICharmRecipeSet
+from lp.services.database.decoratedresultset import DecoratedResultSet
+from lp.services.feeds.browser import FeedsMixin
+from lp.services.propertycache import cachedproperty
+from lp.services.webapp import LaunchpadView
+from lp.services.webapp.batching import BatchNavigator
+
+
+class CharmRecipeListingView(LaunchpadView, FeedsMixin):
+
+ feed_types = ()
+
+ source_enabled = True
+ owner_enabled = True
+
+ @property
+ def page_title(self):
+ return "Charm recipes"
+
+ @property
+ def label(self):
+ return "Charm recipes for %(displayname)s" % {
+ "displayname": self.context.displayname}
+
+ def initialize(self):
+ super(CharmRecipeListingView, self).initialize()
+ recipes = getUtility(ICharmRecipeSet).findByContext(
+ self.context, visible_by_user=self.user)
+ loader = partial(
+ getUtility(ICharmRecipeSet).preloadDataForRecipes, user=self.user)
+ self.recipes = DecoratedResultSet(recipes, pre_iter_hook=loader)
+
+ @cachedproperty
+ def batchnav(self):
+ return BatchNavigator(self.recipes, self.request)
+
+
+class GitCharmRecipeListingView(CharmRecipeListingView):
+
+ source_enabled = False
+
+ @property
+ def label(self):
+ return "Charm recipes for %(display_name)s" % {
+ "display_name": self.context.display_name}
+
+
+class PersonCharmRecipeListingView(CharmRecipeListingView):
+
+ owner_enabled = False
+
+
+class ProjectCharmRecipeListingView(CharmRecipeListingView):
+ pass
diff --git a/lib/lp/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml
index 0475288..3c54b6e 100644
--- a/lib/lp/charms/browser/configure.zcml
+++ b/lib/lp/charms/browser/configure.zcml
@@ -76,5 +76,36 @@
for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild"
factory="lp.services.webapp.breadcrumb.TitleBreadcrumb"
permission="zope.Public" />
+
+ <browser:page
+ for="*"
+ class="lp.app.browser.launchpad.Macro"
+ permission="zope.Public"
+ name="+charm-recipe-macros"
+ template="../templates/charmrecipe-macros.pt" />
+ <browser:page
+ for="lp.code.interfaces.gitrepository.IGitRepository"
+ class="lp.charms.browser.charmrecipelisting.GitCharmRecipeListingView"
+ permission="launchpad.View"
+ name="+charm-recipes"
+ template="../templates/charmrecipe-listing.pt" />
+ <browser:page
+ for="lp.code.interfaces.gitref.IGitRef"
+ class="lp.charms.browser.charmrecipelisting.GitCharmRecipeListingView"
+ permission="launchpad.View"
+ name="+charm-recipes"
+ template="../templates/charmrecipe-listing.pt" />
+ <browser:page
+ for="lp.registry.interfaces.person.IPerson"
+ class="lp.charms.browser.charmrecipelisting.PersonCharmRecipeListingView"
+ permission="launchpad.View"
+ name="+charm-recipes"
+ template="../templates/charmrecipe-listing.pt" />
+ <browser:page
+ for="lp.registry.interfaces.product.IProduct"
+ class="lp.charms.browser.charmrecipelisting.ProjectCharmRecipeListingView"
+ permission="launchpad.View"
+ name="+charm-recipes"
+ template="../templates/charmrecipe-listing.pt" />
</facet>
</configure>
diff --git a/lib/lp/charms/browser/hascharmrecipes.py b/lib/lp/charms/browser/hascharmrecipes.py
new file mode 100644
index 0000000..5c395b5
--- /dev/null
+++ b/lib/lp/charms/browser/hascharmrecipes.py
@@ -0,0 +1,64 @@
+# Copyright 2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Mixins for browser classes for objects that have charm recipes."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ "HasCharmRecipesMenuMixin",
+ "HasCharmRecipesViewMixin",
+ ]
+
+from zope.component import getUtility
+
+from lp.charms.interfaces.charmrecipe import ICharmRecipeSet
+from lp.code.interfaces.gitrepository import IGitRepository
+from lp.services.webapp import (
+ canonical_url,
+ Link,
+ )
+from lp.services.webapp.escaping import structured
+
+
+class HasCharmRecipesMenuMixin:
+ """A mixin for context menus for objects that have charm recipes."""
+
+ def view_charm_recipes(self):
+ text = "View charm recipes"
+ enabled = not getUtility(ICharmRecipeSet).findByContext(
+ self.context, visible_by_user=self.user).is_empty()
+ return Link("+charm-recipes", text, icon="info", enabled=enabled)
+
+
+class HasCharmRecipesViewMixin:
+ """A view mixin for objects that have charm recipes."""
+
+ @property
+ def charm_recipes(self):
+ return getUtility(ICharmRecipeSet).findByContext(
+ self.context, visible_by_user=self.user)
+
+ @property
+ def charm_recipes_link(self):
+ """A link to charm recipes for this object."""
+ count = self.charm_recipes.count()
+ if IGitRepository.providedBy(self.context):
+ context_type = "repository"
+ else:
+ context_type = "branch"
+ if count == 0:
+ # Nothing to link to.
+ return "No charm recipes using this %s." % context_type
+ elif count == 1:
+ # Link to the single charm recipe.
+ return structured(
+ '<a href="%s">1 charm recipe</a> using this %s.',
+ canonical_url(self.charm_recipes.one()),
+ context_type).escapedtext
+ else:
+ # Link to a charm recipe listing.
+ return structured(
+ '<a href="+charm-recipes">%s charm recipes</a> using this %s.',
+ count, context_type).escapedtext
diff --git a/lib/lp/charms/browser/tests/test_charmrecipelisting.py b/lib/lp/charms/browser/tests/test_charmrecipelisting.py
new file mode 100644
index 0000000..5bfd233
--- /dev/null
+++ b/lib/lp/charms/browser/tests/test_charmrecipelisting.py
@@ -0,0 +1,276 @@
+# Copyright 2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test charm recipe listings."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from datetime import (
+ datetime,
+ timedelta,
+ )
+from functools import partial
+
+import pytz
+import soupmatchers
+from testtools.matchers import (
+ MatchesAll,
+ Not,
+ )
+from zope.security.proxy import removeSecurityProxy
+
+from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
+from lp.code.tests.helpers import GitHostingFixture
+from lp.services.database.constants import (
+ ONE_DAY_AGO,
+ UTC_NOW,
+ )
+from lp.services.features.testing import MemoryFeatureFixture
+from lp.services.webapp import canonical_url
+from lp.testing import (
+ ANONYMOUS,
+ BrowserTestCase,
+ login,
+ person_logged_in,
+ record_two_runs,
+ )
+from lp.testing.layers import LaunchpadFunctionalLayer
+from lp.testing.matchers import HasQueryCount
+from lp.testing.views import create_initialized_view
+
+
+class TestCharmRecipeListing(BrowserTestCase):
+
+ layer = LaunchpadFunctionalLayer
+
+ def assertCharmRecipesLink(self, context, link_text,
+ link_has_context=False, **kwargs):
+ if link_has_context:
+ expected_href = canonical_url(context, view_name="+charm-recipes")
+ else:
+ expected_href = "+charm-recipes"
+ matcher = soupmatchers.HTMLContains(
+ soupmatchers.Tag(
+ "View charm recipes link", "a", text=link_text,
+ attrs={"href": expected_href}))
+ self.assertThat(self.getViewBrowser(context).contents, Not(matcher))
+ login(ANONYMOUS)
+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
+ self.factory.makeCharmRecipe(**kwargs)
+ self.factory.makeCharmRecipe(**kwargs)
+ self.assertThat(self.getViewBrowser(context).contents, matcher)
+
+ def test_git_repository_links_to_recipes(self):
+ repository = self.factory.makeGitRepository()
+ [ref] = self.factory.makeGitRefs(repository=repository)
+ self.assertCharmRecipesLink(repository, "2 charm recipes", git_ref=ref)
+
+ def test_git_ref_links_to_recipes(self):
+ self.useFixture(GitHostingFixture())
+ [ref] = self.factory.makeGitRefs()
+ self.assertCharmRecipesLink(ref, "2 charm recipes", git_ref=ref)
+
+ def test_person_links_to_recipes(self):
+ person = self.factory.makePerson()
+ self.assertCharmRecipesLink(
+ person, "View charm recipes", link_has_context=True,
+ registrant=person, owner=person)
+
+ def test_project_links_to_recipes(self):
+ project = self.factory.makeProduct()
+ [ref] = self.factory.makeGitRefs(target=project)
+ self.assertCharmRecipesLink(
+ project, "View charm recipes", link_has_context=True, git_ref=ref)
+
+ def test_git_repository_recipe_listing(self):
+ # We can see charm recipes for a Git repository.
+ repository = self.factory.makeGitRepository()
+ [ref] = self.factory.makeGitRefs(repository=repository)
+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
+ self.factory.makeCharmRecipe(git_ref=ref)
+ text = self.getMainText(repository, "+charm-recipes")
+ self.assertTextMatchesExpressionIgnoreWhitespace("""
+ Charm recipes for lp:~.*
+ Name Owner Registered
+ charm-name.* Team Name.* .*""", text)
+
+ def test_git_ref_recipe_listing(self):
+ # We can see charm recipes for a Git reference.
+ [ref] = self.factory.makeGitRefs()
+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
+ self.factory.makeCharmRecipe(git_ref=ref)
+ text = self.getMainText(ref, "+charm-recipes")
+ self.assertTextMatchesExpressionIgnoreWhitespace("""
+ Charm recipes for ~.*:.*
+ Name Owner Registered
+ charm-name.* Team Name.* .*""", text)
+
+ def test_person_recipe_listing(self):
+ # We can see charm recipes for a person.
+ owner = self.factory.makePerson(displayname="Charm Owner")
+ [ref] = self.factory.makeGitRefs()
+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
+ self.factory.makeCharmRecipe(
+ registrant=owner, owner=owner, git_ref=ref,
+ date_created=ONE_DAY_AGO)
+ text = self.getMainText(owner, "+charm-recipes")
+ self.assertTextMatchesExpressionIgnoreWhitespace("""
+ Charm recipes for Charm Owner
+ Name Source Registered
+ charm-name.* ~.*:.* .*""", text)
+
+ def test_project_recipe_listing(self):
+ # We can see charm recipes for a project.
+ project = self.factory.makeProduct(displayname="Charmable")
+ [ref] = self.factory.makeGitRefs(target=project)
+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
+ self.factory.makeCharmRecipe(git_ref=ref, date_created=UTC_NOW)
+ text = self.getMainText(project, "+charm-recipes")
+ self.assertTextMatchesExpressionIgnoreWhitespace("""
+ Charm recipes for Charmable
+ Name Owner Source Registered
+ charm-name.* Team Name.* ~.*:.* .*""", text)
+
+ def assertCharmRecipesQueryCount(self, context, item_creator):
+ self.pushConfig("launchpad", default_batch_size=10)
+ recorder1, recorder2 = record_two_runs(
+ lambda: self.getMainText(context, "+charm-recipes"),
+ item_creator, 5)
+ self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
+
+ def test_git_repository_query_count(self):
+ # The number of queries required to render the list of all charm
+ # recipes for a Git repository is constant in the number of owners
+ # and charm recipes.
+ person = self.factory.makePerson()
+ repository = self.factory.makeGitRepository(owner=person)
+
+ def create_recipe():
+ with person_logged_in(person):
+ [ref] = self.factory.makeGitRefs(repository=repository)
+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
+ self.factory.makeCharmRecipe(git_ref=ref)
+
+ self.assertCharmRecipesQueryCount(repository, create_recipe)
+
+ def test_git_ref_query_count(self):
+ # The number of queries required to render the list of all charm
+ # recipes for a Git reference is constant in the number of owners
+ # and charm recipes.
+ person = self.factory.makePerson()
+ [ref] = self.factory.makeGitRefs(owner=person)
+
+ def create_recipe():
+ with person_logged_in(person):
+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
+ self.factory.makeCharmRecipe(git_ref=ref)
+
+ self.assertCharmRecipesQueryCount(ref, create_recipe)
+
+ def test_person_query_count(self):
+ # The number of queries required to render the list of all charm
+ # recipes for a person is constant in the number of projects,
+ # sources, and charm recipes.
+ person = self.factory.makePerson()
+
+ def create_recipe():
+ with person_logged_in(person):
+ project = self.factory.makeProduct()
+ [ref] = self.factory.makeGitRefs(owner=person, target=project)
+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
+ self.factory.makeCharmRecipe(git_ref=ref)
+
+ self.assertCharmRecipesQueryCount(person, create_recipe)
+
+ def test_project_query_count(self):
+ # The number of queries required to render the list of all charm
+ # recipes for a person is constant in the number of owners, sources,
+ # and charm recipes.
+ person = self.factory.makePerson()
+ project = self.factory.makeProduct(owner=person)
+
+ def create_recipe():
+ with person_logged_in(person):
+ [ref] = self.factory.makeGitRefs(target=project)
+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
+ self.factory.makeCharmRecipe(git_ref=ref)
+
+ self.assertCharmRecipesQueryCount(project, create_recipe)
+
+ def makeCharmRecipesAndMatchers(self, create_recipe, count, start_time):
+ with MemoryFeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}):
+ recipes = [create_recipe() for i in range(count)]
+ for i, recipe in enumerate(recipes):
+ removeSecurityProxy(recipe).date_last_modified = (
+ start_time - timedelta(seconds=i))
+ return [
+ soupmatchers.Tag(
+ "charm recipe link", "a", text=recipe.name,
+ attrs={
+ "href": canonical_url(recipe, path_only_if_possible=True)})
+ for recipe in recipes]
+
+ def assertBatches(self, context, link_matchers, batched, start, size):
+ view = create_initialized_view(context, "+charm-recipes")
+ listing_tag = soupmatchers.Tag(
+ "charm recipe listing", "table",
+ attrs={"class": "listing sortable"})
+ batch_nav_tag = soupmatchers.Tag(
+ "batch nav links", "td",
+ attrs={"class": "batch-navigation-links"})
+ present_links = ([batch_nav_tag] if batched else []) + [
+ matcher for i, matcher in enumerate(link_matchers)
+ if i in range(start, start + size)]
+ absent_links = ([] if batched else [batch_nav_tag]) + [
+ matcher for i, matcher in enumerate(link_matchers)
+ if i not in range(start, start + size)]
+ self.assertThat(
+ view.render(),
+ MatchesAll(
+ soupmatchers.HTMLContains(listing_tag, *present_links),
+ Not(soupmatchers.HTMLContains(*absent_links))))
+
+ def test_git_repository_batches_recipes(self):
+ repository = self.factory.makeGitRepository()
+ [ref] = self.factory.makeGitRefs(repository=repository)
+ create_recipe = partial(self.factory.makeCharmRecipe, git_ref=ref)
+ now = datetime.now(pytz.UTC)
+ link_matchers = self.makeCharmRecipesAndMatchers(create_recipe, 3, now)
+ self.assertBatches(repository, link_matchers, False, 0, 3)
+ link_matchers.extend(self.makeCharmRecipesAndMatchers(
+ create_recipe, 7, now - timedelta(seconds=3)))
+ self.assertBatches(repository, link_matchers, True, 0, 5)
+
+ def test_git_ref_batches_recipes(self):
+ [ref] = self.factory.makeGitRefs()
+ create_recipe = partial(self.factory.makeCharmRecipe, git_ref=ref)
+ now = datetime.now(pytz.UTC)
+ link_matchers = self.makeCharmRecipesAndMatchers(create_recipe, 3, now)
+ self.assertBatches(ref, link_matchers, False, 0, 3)
+ link_matchers.extend(self.makeCharmRecipesAndMatchers(
+ create_recipe, 7, now - timedelta(seconds=3)))
+ self.assertBatches(ref, link_matchers, True, 0, 5)
+
+ def test_person_batches_recipes(self):
+ owner = self.factory.makePerson()
+ create_recipe = partial(
+ self.factory.makeCharmRecipe, registrant=owner, owner=owner)
+ now = datetime.now(pytz.UTC)
+ link_matchers = self.makeCharmRecipesAndMatchers(create_recipe, 3, now)
+ self.assertBatches(owner, link_matchers, False, 0, 3)
+ link_matchers.extend(self.makeCharmRecipesAndMatchers(
+ create_recipe, 7, now - timedelta(seconds=3)))
+ self.assertBatches(owner, link_matchers, True, 0, 5)
+
+ def test_project_batches_recipes(self):
+ project = self.factory.makeProduct()
+ [ref] = self.factory.makeGitRefs(target=project)
+ create_recipe = partial(self.factory.makeCharmRecipe, git_ref=ref)
+ now = datetime.now(pytz.UTC)
+ link_matchers = self.makeCharmRecipesAndMatchers(create_recipe, 3, now)
+ self.assertBatches(project, link_matchers, False, 0, 3)
+ link_matchers.extend(self.makeCharmRecipesAndMatchers(
+ create_recipe, 7, now - timedelta(seconds=3)))
+ self.assertBatches(project, link_matchers, True, 0, 5)
diff --git a/lib/lp/charms/browser/tests/test_hascharmrecipes.py b/lib/lp/charms/browser/tests/test_hascharmrecipes.py
new file mode 100644
index 0000000..6374893
--- /dev/null
+++ b/lib/lp/charms/browser/tests/test_hascharmrecipes.py
@@ -0,0 +1,86 @@
+# Copyright 2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test views for objects that have charm recipes."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from testscenarios import (
+ load_tests_apply_scenarios,
+ WithScenarios,
+ )
+
+from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
+from lp.code.interfaces.gitrepository import IGitRepository
+from lp.services.features.testing import FeatureFixture
+from lp.services.webapp import canonical_url
+from lp.testing import TestCaseWithFactory
+from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.views import create_initialized_view
+
+
+def make_git_repository(test_case):
+ return test_case.factory.makeGitRepository()
+
+
+def make_git_ref(test_case):
+ return test_case.factory.makeGitRefs()[0]
+
+
+class TestHasCharmRecipesView(WithScenarios, TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ scenarios = [
+ ("GitRepository", {
+ "context_type": "repository",
+ "context_factory": make_git_repository,
+ }),
+ ("GitRef", {
+ "context_type": "branch",
+ "context_factory": make_git_ref,
+ }),
+ ]
+
+ def setUp(self):
+ super(TestHasCharmRecipesView, self).setUp()
+ self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+
+ def makeCharmRecipe(self, context):
+ if IGitRepository.providedBy(context):
+ [context] = self.factory.makeGitRefs(repository=context)
+ return self.factory.makeCharmRecipe(git_ref=context)
+
+ def test_charm_recipes_link_no_recipes(self):
+ # An object with no charm recipes does not show a charm recipes link.
+ context = self.context_factory(self)
+ view = create_initialized_view(context, "+index")
+ self.assertEqual(
+ "No charm recipes using this %s." % self.context_type,
+ view.charm_recipes_link)
+
+ def test_charm_recipes_link_one_recipe(self):
+ # An object with one charm recipe shows a link to that recipe.
+ context = self.context_factory(self)
+ recipe = self.makeCharmRecipe(context)
+ view = create_initialized_view(context, "+index")
+ expected_link = (
+ '<a href="%s">1 charm recipe</a> using this %s.' %
+ (canonical_url(recipe), self.context_type))
+ self.assertEqual(expected_link, view.charm_recipes_link)
+
+ def test_charm_recipes_link_more_recipes(self):
+ # An object with more than one charm recipe shows a link to a listing.
+ context = self.context_factory(self)
+ self.makeCharmRecipe(context)
+ self.makeCharmRecipe(context)
+ view = create_initialized_view(context, "+index")
+ expected_link = (
+ '<a href="+charm-recipes">2 charm recipes</a> using this %s.' %
+ self.context_type)
+ self.assertEqual(expected_link, view.charm_recipes_link)
+
+
+load_tests = load_tests_apply_scenarios
diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
index 6b70d1b..c972097 100644
--- a/lib/lp/charms/interfaces/charmrecipe.py
+++ b/lib/lp/charms/interfaces/charmrecipe.py
@@ -412,7 +412,7 @@ class ICharmRecipeEditableAttributes(Interface):
"recipe."))
git_path = TextLine(
- title=_("Git branch path"), required=False, readonly=False,
+ title=_("Git branch path"), required=False, readonly=True,
description=_(
"The path of the Git branch containing a charmcraft.yaml "
"recipe."))
@@ -513,6 +513,49 @@ class ICharmRecipeSet(Interface):
def getByName(owner, project, name):
"""Returns the appropriate `ICharmRecipe` for the given objects."""
+ def findByPerson(person, visible_by_user=None):
+ """Return all charm recipes relevant to `person`.
+
+ This returns charm recipes for Git branches owned by `person`, or
+ where `person` is the owner of the charm recipe.
+
+ :param person: An `IPerson`.
+ :param visible_by_user: If not None, only return recipes visible by
+ this user; otherwise, only return publicly-visible recipes.
+ """
+
+ def findByProject(project, visible_by_user=None):
+ """Return all charm recipes for the given project.
+
+ :param project: An `IProduct`.
+ :param visible_by_user: If not None, only return recipes visible by
+ this user; otherwise, only return publicly-visible recipes.
+ """
+
+ def findByGitRepository(repository, paths=None, check_permissions=True):
+ """Return all charm recipes for the given Git repository.
+
+ :param repository: An `IGitRepository`.
+ :param paths: If not None, only return charm recipes for one of
+ these Git reference paths.
+ """
+
+ def findByGitRef(ref):
+ """Return all charm recipes for the given Git reference."""
+
+ def findByContext(context, visible_by_user=None, order_by_date=True):
+ """Return all charm recipes for the given context.
+
+ :param context: An `IPerson`, `IProduct`, `IGitRepository`, or
+ `IGitRef`.
+ :param visible_by_user: If not None, only return recipes visible by
+ this user; otherwise, only return publicly-visible recipes.
+ :param order_by_date: If True, order recipes by descending
+ modification date.
+ :raises BadCharmRecipeSearchContext: if the context is not
+ understood.
+ """
+
def isValidInformationType(information_type, owner, git_ref=None):
"""Whether the information type context is valid."""
@@ -536,14 +579,6 @@ class ICharmRecipeSet(Interface):
cannot be parsed.
"""
- def findByGitRepository(repository, paths=None):
- """Return all charm recipes for the given Git repository.
-
- :param repository: An `IGitRepository`.
- :param paths: If not None, only return charm recipes for one of
- these Git reference paths.
- """
-
def detachFromGitRepository(repository):
"""Detach all charm recipes from the given Git repository.
diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
index da24bf6..d9c9b5c 100644
--- a/lib/lp/charms/model/charmrecipe.py
+++ b/lib/lp/charms/model/charmrecipe.py
@@ -8,6 +8,7 @@ from __future__ import absolute_import, print_function, unicode_literals
__metaclass__ = type
__all__ = [
"CharmRecipe",
+ "get_charm_recipe_privacy_filter",
]
from operator import (
@@ -47,6 +48,7 @@ from lp.buildmaster.interfaces.buildqueue import IBuildQueueSet
from lp.buildmaster.model.builder import Builder
from lp.charms.adapters.buildarch import determine_instances_to_build
from lp.charms.interfaces.charmrecipe import (
+ BadCharmRecipeSearchContext,
CannotFetchCharmcraftYaml,
CannotParseCharmcraftYaml,
CHARM_RECIPE_ALLOW_CREATE,
@@ -75,16 +77,26 @@ from lp.code.errors import (
GitRepositoryBlobNotFound,
GitRepositoryScanFault,
)
+from lp.code.interfaces.gitcollection import (
+ IAllGitRepositories,
+ IGitCollection,
+ )
+from lp.code.interfaces.gitref import IGitRef
+from lp.code.interfaces.gitrepository import IGitRepository
from lp.code.model.gitcollection import GenericGitCollection
+from lp.code.model.gitref import GitRef
from lp.code.model.gitrepository import GitRepository
from lp.registry.errors import PrivatePersonLinkageError
from lp.registry.interfaces.distribution import IDistributionSet
from lp.registry.interfaces.person import (
+ IPerson,
IPersonSet,
validate_public_person,
)
+from lp.registry.interfaces.product import IProduct
from lp.registry.model.distribution import Distribution
from lp.registry.model.distroseries import DistroSeries
+from lp.registry.model.product import Product
from lp.registry.model.series import ACTIVE_STATUSES
from lp.services.database.bulk import load_related
from lp.services.database.constants import (
@@ -314,14 +326,18 @@ class CharmRecipe(StormBase):
"""See `ICharmRecipe`."""
return self.information_type not in PUBLIC_INFORMATION_TYPES
- @property
- def git_ref(self):
- """See `ICharmRecipe`."""
+ @cachedproperty
+ def _git_ref(self):
if self.git_repository is not None:
return self.git_repository.getRefByPath(self.git_path)
else:
return None
+ @property
+ def git_ref(self):
+ """See `ICharmRecipe`."""
+ return self._git_ref
+
@git_ref.setter
def git_ref(self, value):
"""See `ICharmRecipe`."""
@@ -331,6 +347,7 @@ class CharmRecipe(StormBase):
else:
self.git_repository = None
self.git_path = None
+ get_property_cache(self)._git_ref = value
@property
def source(self):
@@ -384,9 +401,12 @@ class CharmRecipe(StormBase):
"""See `ICharmRecipe`."""
if self.information_type in PUBLIC_INFORMATION_TYPES:
return True
- # XXX cjwatson 2021-05-27: Finish implementing this once we have
- # more privacy infrastructure.
- return False
+ if user is None:
+ return False
+ return not IStore(CharmRecipe).find(
+ CharmRecipe,
+ CharmRecipe.id == self.id,
+ get_charm_recipe_privacy_filter(user)).is_empty()
def _isBuildableArchitectureAllowed(self, das):
"""Check whether we may build for a buildable `DistroArchSeries`.
@@ -675,6 +695,82 @@ class CharmRecipeSet:
return IStore(CharmRecipe).find(
CharmRecipe, owner=owner, project=project, name=name).one()
+ def _getRecipesFromCollection(self, collection, owner=None,
+ visible_by_user=None):
+ id_column = CharmRecipe.git_repository_id
+ ids = collection.getRepositoryIds()
+ expressions = [id_column.is_in(ids._get_select())]
+ if owner is not None:
+ expressions.append(CharmRecipe.owner == owner)
+ expressions.append(get_charm_recipe_privacy_filter(visible_by_user))
+ return IStore(CharmRecipe).find(CharmRecipe, *expressions)
+
+ def findByPerson(self, person, visible_by_user=None):
+ """See `ICharmRecipeSet`."""
+ def _getRecipes(collection):
+ collection = collection.visibleByUser(visible_by_user)
+ owned = self._getRecipesFromCollection(
+ collection.ownedBy(person), visible_by_user=visible_by_user)
+ packaged = self._getRecipesFromCollection(
+ collection, owner=person, visible_by_user=visible_by_user)
+ return owned.union(packaged)
+
+ git_collection = removeSecurityProxy(getUtility(IAllGitRepositories))
+ git_recipes = _getRecipes(git_collection)
+ return git_recipes
+
+ def findByProject(self, project, visible_by_user=None):
+ """See `ICharmRecipeSet`."""
+ def _getRecipes(collection):
+ return self._getRecipesFromCollection(
+ collection.visibleByUser(visible_by_user),
+ visible_by_user=visible_by_user)
+
+ recipes_for_project = IStore(CharmRecipe).find(
+ CharmRecipe,
+ CharmRecipe.project == project,
+ get_charm_recipe_privacy_filter(visible_by_user))
+ git_collection = removeSecurityProxy(IGitCollection(project))
+ return recipes_for_project.union(_getRecipes(git_collection))
+
+ def findByGitRepository(self, repository, paths=None,
+ visible_by_user=None, check_permissions=True):
+ """See `ICharmRecipeSet`."""
+ clauses = [CharmRecipe.git_repository == repository]
+ if paths is not None:
+ clauses.append(CharmRecipe.git_path.is_in(paths))
+ if check_permissions:
+ clauses.append(get_charm_recipe_privacy_filter(visible_by_user))
+ return IStore(CharmRecipe).find(CharmRecipe, *clauses)
+
+ def findByGitRef(self, ref, visible_by_user=None):
+ """See `ICharmRecipeSet`."""
+ return IStore(CharmRecipe).find(
+ CharmRecipe,
+ CharmRecipe.git_repository == ref.repository,
+ CharmRecipe.git_path == ref.path,
+ get_charm_recipe_privacy_filter(visible_by_user))
+
+ def findByContext(self, context, visible_by_user=None, order_by_date=True):
+ """See `ICharmRecipeSet`."""
+ if IPerson.providedBy(context):
+ recipes = self.findByPerson(
+ context, visible_by_user=visible_by_user)
+ elif IProduct.providedBy(context):
+ recipes = self.findByProject(
+ context, visible_by_user=visible_by_user)
+ elif IGitRepository.providedBy(context):
+ recipes = self.findByGitRepository(
+ context, visible_by_user=visible_by_user)
+ elif IGitRef.providedBy(context):
+ recipes = self.findByGitRef(
+ context, visible_by_user=visible_by_user)
+ else:
+ raise BadCharmRecipeSearchContext(context)
+ if order_by_date:
+ recipes = recipes.order_by(Desc(CharmRecipe.date_last_modified))
+ return recipes
+
def isValidInformationType(self, information_type, owner, git_ref=None):
"""See `ICharmRecipeSet`."""
private = information_type not in PUBLIC_INFORMATION_TYPES
@@ -698,6 +794,8 @@ class CharmRecipeSet:
"""See `ICharmRecipeSet`."""
recipes = [removeSecurityProxy(recipe) for recipe in recipes]
+ load_related(Product, recipes, ["project_id"])
+
person_ids = set()
for recipe in recipes:
person_ids.add(recipe.registrant_id)
@@ -708,6 +806,13 @@ class CharmRecipeSet:
if repositories:
GenericGitCollection.preloadDataForRepositories(repositories)
+ git_refs = GitRef.findByReposAndPaths(
+ [(recipe.git_repository, recipe.git_path) for recipe in recipes])
+ for recipe in recipes:
+ git_ref = git_refs.get((recipe.git_repository, recipe.git_path))
+ if git_ref is not None:
+ get_property_cache(recipe)._git_ref = git_ref
+
# Add repository owners to the list of pre-loaded persons. We need
# the target repository owner as well, since repository unique names
# aren't trigger-maintained.
@@ -760,16 +865,20 @@ class CharmRecipeSet:
return charmcraft_data
- def findByGitRepository(self, repository, paths=None):
- """See `ICharmRecipeSet`."""
- clauses = [CharmRecipe.git_repository == repository]
- if paths is not None:
- clauses.append(CharmRecipe.git_path.is_in(paths))
- # XXX cjwatson 2021-05-26: Check permissions once we have some
- # privacy infrastructure.
- return IStore(CharmRecipe).find(CharmRecipe, *clauses)
-
def detachFromGitRepository(self, repository):
"""See `ICharmRecipeSet`."""
- self.findByGitRepository(repository).set(
+ recipes = self.findByGitRepository(repository)
+ for recipe in recipes:
+ get_property_cache(recipe)._git_ref = None
+ recipes.set(
git_repository_id=None, git_path=None, date_last_modified=UTC_NOW)
+
+
+def get_charm_recipe_privacy_filter(user):
+ """Return a Storm query filter to find charm recipes visible to `user`."""
+ public_filter = CharmRecipe.information_type.is_in(
+ PUBLIC_INFORMATION_TYPES)
+
+ # XXX cjwatson 2021-06-07: Flesh this out once we have more privacy
+ # infrastructure.
+ return [public_filter]
diff --git a/lib/lp/charms/model/charmrecipebuild.py b/lib/lp/charms/model/charmrecipebuild.py
index 24d7ff7..088b7b1 100644
--- a/lib/lp/charms/model/charmrecipebuild.py
+++ b/lib/lp/charms/model/charmrecipebuild.py
@@ -47,6 +47,8 @@ from lp.charms.interfaces.charmrecipebuild import (
from lp.charms.mail.charmrecipebuild import CharmRecipeBuildMailer
from lp.registry.interfaces.pocket import PackagePublishingPocket
from lp.registry.interfaces.series import SeriesStatus
+from lp.registry.model.distribution import Distribution
+from lp.registry.model.distroseries import DistroSeries
from lp.registry.model.person import Person
from lp.services.config import config
from lp.services.database.bulk import load_related
@@ -67,6 +69,7 @@ from lp.services.propertycache import (
get_property_cache,
)
from lp.services.webapp.snapshot import notify_modified
+from lp.soyuz.model.distroarchseries import DistroArchSeries
@implementer(ICharmRecipeBuild)
@@ -431,6 +434,11 @@ class CharmRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
load_related(Person, builds, ["requester_id"])
lfas = load_related(LibraryFileAlias, builds, ["log_id"])
load_related(LibraryFileContent, lfas, ["contentID"])
+ distroarchserieses = load_related(
+ DistroArchSeries, builds, ["distro_arch_series_id"])
+ distroserieses = load_related(
+ DistroSeries, distroarchserieses, ["distroseriesID"])
+ load_related(Distribution, distroserieses, ["distributionID"])
recipes = load_related(CharmRecipe, builds, ["recipe_id"])
getUtility(ICharmRecipeSet).preloadDataForRecipes(recipes)
diff --git a/lib/lp/charms/templates/charmrecipe-listing.pt b/lib/lp/charms/templates/charmrecipe-listing.pt
new file mode 100644
index 0000000..8f163d7
--- /dev/null
+++ b/lib/lp/charms/templates/charmrecipe-listing.pt
@@ -0,0 +1,46 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ metal:use-macro="view/macro:page/main_only"
+ i18n:domain="launchpad">
+
+<body>
+
+ <div metal:fill-slot="main">
+
+ <tal:navigation
+ condition="view/batchnav/has_multiple_pages"
+ replace="structure view/batchnav/@@+navigation-links-upper" />
+ <table id="charm-recipe-table" class="listing sortable">
+ <thead>
+ <tr>
+ <th colspan="2">Name</th>
+ <th tal:condition="view/owner_enabled">Owner</th>
+ <th tal:condition="view/source_enabled">Source</th>
+ <th>Registered</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tal:recipes repeat="recipe view/batchnav/currentBatch">
+ <tr>
+ <td colspan="2">
+ <a tal:attributes="href recipe/fmt:url" tal:content="recipe/name" />
+ </td>
+ <td tal:condition="view/owner_enabled"
+ tal:content="structure recipe/owner/fmt:link" />
+ <td tal:condition="view/source_enabled"
+ tal:content="structure recipe/source/fmt:link" />
+ <td tal:content="recipe/date_created/fmt:datetime" />
+ </tr>
+ </tal:recipes>
+ </tbody>
+ </table>
+ <tal:navigation
+ condition="view/batchnav/has_multiple_pages"
+ replace="structure view/batchnav/@@+navigation-links-lower" />
+
+ </div>
+</body>
+</html>
diff --git a/lib/lp/charms/templates/charmrecipe-macros.pt b/lib/lp/charms/templates/charmrecipe-macros.pt
new file mode 100644
index 0000000..93b4d69
--- /dev/null
+++ b/lib/lp/charms/templates/charmrecipe-macros.pt
@@ -0,0 +1,22 @@
+<tal:root
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ omit-tag="">
+
+<div
+ metal:define-macro="related-charm-recipes"
+ tal:define="context_menu context/menu:context"
+ id="related-charm-recipes">
+
+ <h3>Related charm recipes</h3>
+
+ <div id="charm-recipe-links" class="actions">
+ <div id="charm-recipe-summary">
+ <tal:charm_recipes replace="structure view/charm_recipes_link" />
+ </div>
+ </div>
+
+</div>
+
+</tal:root>
diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py
index 173e024..af7f1e3 100644
--- a/lib/lp/charms/tests/test_charmrecipe.py
+++ b/lib/lp/charms/tests/test_charmrecipe.py
@@ -34,6 +34,7 @@ from lp.buildmaster.interfaces.processor import (
)
from lp.buildmaster.model.buildqueue import BuildQueue
from lp.charms.interfaces.charmrecipe import (
+ BadCharmRecipeSearchContext,
CHARM_RECIPE_ALLOW_CREATE,
CHARM_RECIPE_BUILD_DISTRIBUTION,
CharmRecipeBuildAlreadyPending,
@@ -583,6 +584,40 @@ class TestCharmRecipeSet(TestCaseWithFactory):
getUtility(ICharmRecipeSet).getByName(
owner, project, "proj-charm"))
+ def test_findByPerson(self):
+ # ICharmRecipeSet.findByPerson returns all charm recipes with the
+ # given owner or based on repositories with the given owner.
+ owners = [self.factory.makePerson() for i in range(2)]
+ recipes = []
+ for owner in owners:
+ recipes.append(self.factory.makeCharmRecipe(
+ registrant=owner, owner=owner))
+ [ref] = self.factory.makeGitRefs(owner=owner)
+ recipes.append(self.factory.makeCharmRecipe(git_ref=ref))
+ recipe_set = getUtility(ICharmRecipeSet)
+ self.assertContentEqual(
+ recipes[:2], recipe_set.findByPerson(owners[0]))
+ self.assertContentEqual(
+ recipes[2:], recipe_set.findByPerson(owners[1]))
+
+ def test_findByProject(self):
+ # ICharmRecipeSet.findByProject returns all charm recipes based on
+ # repositories for the given project, and charm recipes associated
+ # directly with the project.
+ projects = [self.factory.makeProduct() for i in range(2)]
+ recipes = []
+ for project in projects:
+ [ref] = self.factory.makeGitRefs(target=project)
+ recipes.append(self.factory.makeCharmRecipe(git_ref=ref))
+ recipes.append(self.factory.makeCharmRecipe(project=project))
+ [ref] = self.factory.makeGitRefs(target=None)
+ recipes.append(self.factory.makeCharmRecipe(git_ref=ref))
+ recipe_set = getUtility(ICharmRecipeSet)
+ self.assertContentEqual(
+ recipes[:2], recipe_set.findByProject(projects[0]))
+ self.assertContentEqual(
+ recipes[2:4], recipe_set.findByProject(projects[1]))
+
def test_findByGitRepository(self):
# ICharmRecipeSet.findByGitRepository returns all charm recipes with
# the given Git repository.
@@ -620,6 +655,55 @@ class TestCharmRecipeSet(TestCaseWithFactory):
repositories[0],
paths=[recipes[0].git_ref.path, recipes[1].git_ref.path]))
+ def test_findByGitRef(self):
+ # ICharmRecipeSet.findByGitRef returns all charm recipes with the
+ # given Git reference.
+ repositories = [self.factory.makeGitRepository() for i in range(2)]
+ refs = []
+ recipes = []
+ for repository in repositories:
+ refs.extend(self.factory.makeGitRefs(
+ paths=["refs/heads/master", "refs/heads/other"]))
+ recipes.append(self.factory.makeCharmRecipe(git_ref=refs[-2]))
+ recipes.append(self.factory.makeCharmRecipe(git_ref=refs[-1]))
+ recipe_set = getUtility(ICharmRecipeSet)
+ for ref, recipe in zip(refs, recipes):
+ self.assertContentEqual([recipe], recipe_set.findByGitRef(ref))
+
+ def test_findByContext(self):
+ # ICharmRecipeSet.findByContext returns all charm recipes with the
+ # given context.
+ person = self.factory.makePerson()
+ project = self.factory.makeProduct()
+ repository = self.factory.makeGitRepository(
+ owner=person, target=project)
+ refs = self.factory.makeGitRefs(
+ repository=repository,
+ paths=["refs/heads/master", "refs/heads/other"])
+ other_repository = self.factory.makeGitRepository()
+ other_refs = self.factory.makeGitRefs(
+ repository=other_repository,
+ paths=["refs/heads/master", "refs/heads/other"])
+ recipes = []
+ recipes.append(self.factory.makeCharmRecipe(git_ref=refs[0]))
+ recipes.append(self.factory.makeCharmRecipe(git_ref=refs[1]))
+ recipes.append(self.factory.makeCharmRecipe(
+ registrant=person, owner=person, git_ref=other_refs[0]))
+ recipes.append(self.factory.makeCharmRecipe(
+ project=project, git_ref=other_refs[1]))
+ recipe_set = getUtility(ICharmRecipeSet)
+ self.assertContentEqual(recipes[:3], recipe_set.findByContext(person))
+ self.assertContentEqual(
+ [recipes[0], recipes[1], recipes[3]],
+ recipe_set.findByContext(project))
+ self.assertContentEqual(
+ recipes[:2], recipe_set.findByContext(repository))
+ self.assertContentEqual(
+ [recipes[0]], recipe_set.findByContext(refs[0]))
+ self.assertRaises(
+ BadCharmRecipeSearchContext, recipe_set.findByContext,
+ self.factory.makeDistribution())
+
def test_detachFromGitRepository(self):
# ICharmRecipeSet.detachFromGitRepository clears the given Git
# repository from all charm recipes.
diff --git a/lib/lp/code/browser/gitref.py b/lib/lp/code/browser/gitref.py
index b33dd20..c53f8f1 100644
--- a/lib/lp/code/browser/gitref.py
+++ b/lib/lp/code/browser/gitref.py
@@ -35,6 +35,10 @@ from lp.app.browser.launchpadform import (
action,
LaunchpadFormView,
)
+from lp.charms.browser.hascharmrecipes import (
+ HasCharmRecipesMenuMixin,
+ HasCharmRecipesViewMixin,
+ )
from lp.code.browser.branchmergeproposal import (
latest_proposals_for_each_branch,
)
@@ -71,7 +75,9 @@ from lp.snappy.browser.hassnaps import (
)
-class GitRefContextMenu(ContextMenu, HasRecipesMenuMixin, HasSnapsMenuMixin):
+class GitRefContextMenu(
+ ContextMenu, HasRecipesMenuMixin, HasSnapsMenuMixin,
+ HasCharmRecipesMenuMixin):
"""Context menu for Git references."""
usedfor = IGitRef
@@ -82,6 +88,7 @@ class GitRefContextMenu(ContextMenu, HasRecipesMenuMixin, HasSnapsMenuMixin):
'create_snap',
'register_merge',
'source',
+ 'view_charm_recipes',
'view_recipes',
]
@@ -111,7 +118,7 @@ class GitRefContextMenu(ContextMenu, HasRecipesMenuMixin, HasSnapsMenuMixin):
return Link("+new-recipe", text, enabled=enabled, icon="add")
-class GitRefView(LaunchpadView, HasSnapsViewMixin):
+class GitRefView(LaunchpadView, HasSnapsViewMixin, HasCharmRecipesViewMixin):
# This is set at self.commit_infos, and should be accessed by the view
# as self.commit_info_message.
diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py
index c41cafe..fb51ca1 100644
--- a/lib/lp/code/browser/gitrepository.py
+++ b/lib/lp/code/browser/gitrepository.py
@@ -75,6 +75,7 @@ from lp.app.errors import (
)
from lp.app.vocabularies import InformationTypeVocabulary
from lp.app.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription
+from lp.charms.browser.hascharmrecipes import HasCharmRecipesViewMixin
from lp.code.browser.branch import CodeEditOwnerMixin
from lp.code.browser.branchmergeproposal import (
latest_proposals_for_each_branch,
@@ -368,7 +369,8 @@ class GitRefBatchNavigator(TableBatchNavigator):
class GitRepositoryView(InformationTypePortletMixin, LaunchpadView,
- HasSnapsViewMixin, CodeImportTargetMixin):
+ HasSnapsViewMixin, HasCharmRecipesViewMixin,
+ CodeImportTargetMixin):
@property
def page_title(self):
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index 0a98bd3..d5a5b38 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -793,6 +793,12 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
Store.of(self).find(
GitRef,
GitRef.repository == self, GitRef.path.is_in(paths)).remove()
+ # Clear cached references to the removed refs.
+ # XXX cjwatson 2021-06-08: We should probably do something similar
+ # for OCIRecipe, and for Snap if we start caching git_ref there.
+ for recipe in getUtility(ICharmRecipeSet).findByGitRepository(
+ self, paths=paths):
+ get_property_cache(recipe)._git_ref = None
self.date_last_modified = UTC_NOW
def planRefChanges(self, hosting_path, logger=None):
diff --git a/lib/lp/code/templates/gitref-index.pt b/lib/lp/code/templates/gitref-index.pt
index 548ef39..1916bb0 100644
--- a/lib/lp/code/templates/gitref-index.pt
+++ b/lib/lp/code/templates/gitref-index.pt
@@ -38,6 +38,7 @@
replace="structure context/@@++ref-pending-merges" />
<tal:ref-recipes replace="structure context/@@++ref-recipes" />
<div metal:use-macro="context/@@+snap-macros/related-snaps" />
+ <div metal:use-macro="context/@@+charm-recipe-macros/related-charm-recipes" />
</div>
</div>
diff --git a/lib/lp/code/templates/gitrepository-index.pt b/lib/lp/code/templates/gitrepository-index.pt
index 89f202f..c7da7d1 100644
--- a/lib/lp/code/templates/gitrepository-index.pt
+++ b/lib/lp/code/templates/gitrepository-index.pt
@@ -65,6 +65,7 @@
<div metal:use-macro="context/@@+snap-macros/related-snaps">
<metal:context-type fill-slot="context_type">repository</metal:context-type>
</div>
+ <div metal:use-macro="context/@@+charm-recipe-macros/related-charm-recipes" />
</div>
</div>
diff --git a/lib/lp/registry/browser/person.py b/lib/lp/registry/browser/person.py
index d0766c5..233113d 100644
--- a/lib/lp/registry/browser/person.py
+++ b/lib/lp/registry/browser/person.py
@@ -135,6 +135,7 @@ from lp.bugs.interfaces.bugsupervisor import IHasBugSupervisor
from lp.bugs.interfaces.bugtask import BugTaskStatus
from lp.bugs.interfaces.bugtasksearch import BugTaskSearchParams
from lp.buildmaster.enums import BuildStatus
+from lp.charms.browser.hascharmrecipes import HasCharmRecipesMenuMixin
from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
from lp.code.errors import InvalidNamespace
from lp.code.interfaces.branchnamespace import IBranchNamespaceSet
@@ -812,7 +813,8 @@ class PersonMenuMixin(CommonMenuLinks):
class PersonOverviewMenu(ApplicationMenu, PersonMenuMixin, HasRecipesMenuMixin,
- HasSnapsMenuMixin, HasOCIRecipesMenuMixin):
+ HasSnapsMenuMixin, HasOCIRecipesMenuMixin,
+ HasCharmRecipesMenuMixin):
usedfor = IPerson
facet = 'overview'
@@ -842,6 +844,7 @@ class PersonOverviewMenu(ApplicationMenu, PersonMenuMixin, HasRecipesMenuMixin,
'oauth_tokens',
'oci_registry_credentials',
'related_software_summary',
+ 'view_charm_recipes',
'view_recipes',
'view_snaps',
'view_oci_recipes',
diff --git a/lib/lp/registry/browser/product.py b/lib/lp/registry/browser/product.py
index df7ee7a..628fe0e 100644
--- a/lib/lp/registry/browser/product.py
+++ b/lib/lp/registry/browser/product.py
@@ -139,6 +139,7 @@ from lp.bugs.browser.structuralsubscription import (
StructuralSubscriptionTargetTraversalMixin,
)
from lp.bugs.interfaces.bugtask import RESOLVED_BUGTASK_STATUSES
+from lp.charms.browser.hascharmrecipes import HasCharmRecipesMenuMixin
from lp.code.browser.branchref import BranchRef
from lp.code.browser.codeimport import (
CodeImportNameValidationMixin,
@@ -233,7 +234,6 @@ from lp.services.webapp.vhosts import allvhosts
from lp.services.worlddata.helpers import browser_languages
from lp.services.worlddata.interfaces.country import ICountry
from lp.snappy.browser.hassnaps import HasSnapsMenuMixin
-from lp.snappy.interfaces.snap import ISnapSet
from lp.translations.browser.customlanguagecode import (
HasCustomLanguageCodesTraversalMixin,
)
@@ -559,7 +559,8 @@ class ProductActionNavigationMenu(NavigationMenu, ProductEditLinksMixin):
class ProductOverviewMenu(ApplicationMenu, ProductEditLinksMixin,
- HasRecipesMenuMixin, HasSnapsMenuMixin):
+ HasRecipesMenuMixin, HasSnapsMenuMixin,
+ HasCharmRecipesMenuMixin):
usedfor = IProduct
facet = 'overview'
@@ -584,6 +585,7 @@ class ProductOverviewMenu(ApplicationMenu, ProductEditLinksMixin,
'review_license',
'rdf',
'branding',
+ 'view_charm_recipes',
'view_recipes',
'view_snaps',
'create_snap',
diff --git a/lib/lp/registry/browser/team.py b/lib/lp/registry/browser/team.py
index d419603..980ee1e 100644
--- a/lib/lp/registry/browser/team.py
+++ b/lib/lp/registry/browser/team.py
@@ -93,6 +93,7 @@ from lp.app.widgets.itemswidgets import (
)
from lp.app.widgets.owner import HiddenUserWidget
from lp.app.widgets.popup import PersonPickerWidget
+from lp.charms.browser.hascharmrecipes import HasCharmRecipesMenuMixin
from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
from lp.oci.browser.hasocirecipes import HasOCIRecipesMenuMixin
from lp.registry.browser.branding import BrandingChangeView
@@ -1624,7 +1625,8 @@ class TeamMenuMixin(PPANavigationMenuMixIn, CommonMenuLinks):
class TeamOverviewMenu(ApplicationMenu, TeamMenuMixin, HasRecipesMenuMixin,
- HasSnapsMenuMixin, HasOCIRecipesMenuMixin):
+ HasSnapsMenuMixin, HasOCIRecipesMenuMixin,
+ HasCharmRecipesMenuMixin):
usedfor = ITeam
facet = 'overview'
@@ -1652,6 +1654,7 @@ class TeamOverviewMenu(ApplicationMenu, TeamMenuMixin, HasRecipesMenuMixin,
'maintained',
'ppa',
'related_software_summary',
+ 'view_charm_recipes',
'view_recipes',
'view_snaps',
'view_oci_recipes',
diff --git a/lib/lp/registry/templates/product-index.pt b/lib/lp/registry/templates/product-index.pt
index 7d60223..242483d 100644
--- a/lib/lp/registry/templates/product-index.pt
+++ b/lib/lp/registry/templates/product-index.pt
@@ -188,6 +188,10 @@
tal:condition="link/enabled">
<a tal:replace="structure link/fmt:link" />
</li>
+ <li tal:define="link context/menu:overview/view_charm_recipes"
+ tal:condition="link/enabled">
+ <a tal:replace="structure link/fmt:link" />
+ </li>
</ul>
</div>
</div>
diff --git a/lib/lp/soyuz/templates/person-portlet-ppas.pt b/lib/lp/soyuz/templates/person-portlet-ppas.pt
index 19b7e6e..a97a9d5 100644
--- a/lib/lp/soyuz/templates/person-portlet-ppas.pt
+++ b/lib/lp/soyuz/templates/person-portlet-ppas.pt
@@ -34,10 +34,12 @@
<ul class="horizontal" style="margin-top: 0;"
tal:define="recipes_link context/menu:overview/view_recipes;
snaps_link context/menu:overview/view_snaps;
- oci_recipes_link context/menu:overview/view_oci_recipes"
+ oci_recipes_link context/menu:overview/view_oci_recipes;
+ charm_recipes_link context/menu:overview/view_charm_recipes"
tal:condition="python: recipes_link.enabled
or snaps_link.enabled
- or oci_recipes_link.enabled">
+ or oci_recipes_link.enabled
+ or charm_recipes_link.enabled">
<li tal:condition="recipes_link/enabled">
<a tal:replace="structure recipes_link/fmt:link" />
</li>
@@ -47,5 +49,8 @@
<li tal:condition="oci_recipes_link/enabled">
<a tal:replace="structure oci_recipes_link/fmt:link" />
</li>
+ <li tal:condition="charm_recipes_link/enabled">
+ <a tal:replace="structure charm_recipes_link/fmt:link" />
+ </li>
</ul>
</tal:root>