← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:charm-recipe-edit-views into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:charm-recipe-edit-views into launchpad:master with ~cjwatson/launchpad:charm-recipe-delete-builds as a prerequisite.

Commit message:
Add charm recipe +edit, +admin, and +delete views

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/403966
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charm-recipe-edit-views into launchpad:master.
diff --git a/lib/lp/charms/browser/charmrecipe.py b/lib/lp/charms/browser/charmrecipe.py
index e247bf0..6511424 100644
--- a/lib/lp/charms/browser/charmrecipe.py
+++ b/lib/lp/charms/browser/charmrecipe.py
@@ -7,23 +7,52 @@ from __future__ import absolute_import, print_function, unicode_literals
 
 __metaclass__ = type
 __all__ = [
+    "CharmRecipeAdminView",
+    "CharmRecipeDeleteView",
+    "CharmRecipeEditView",
     "CharmRecipeNavigation",
+    "CharmRecipeNavigationMenu",
     "CharmRecipeURL",
     "CharmRecipeView",
     ]
 
+from lazr.restful.interface import (
+    copy_field,
+    use_template,
+    )
 from zope.component import getUtility
-from zope.interface import implementer
+from zope.interface import (
+    implementer,
+    Interface,
+    )
 from zope.security.interfaces import Unauthorized
 
-from lp.charms.interfaces.charmrecipe import ICharmRecipe
+from lp.app.browser.launchpadform import (
+    action,
+    LaunchpadEditFormView,
+    )
+from lp.app.browser.lazrjs import InlinePersonEditPickerWidget
+from lp.app.browser.tales import format_link
+from lp.charms.browser.widgets.charmrecipebuildchannels import (
+    CharmRecipeBuildChannelsWidget,
+    )
+from lp.charms.interfaces.charmrecipe import (
+    ICharmRecipe,
+    ICharmRecipeSet,
+    NoSuchCharmRecipe,
+    )
 from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuildSet
+from lp.code.browser.widgets.gitref import GitRefWidget
 from lp.registry.interfaces.personproduct import IPersonProductFactory
 from lp.services.propertycache import cachedproperty
 from lp.services.utils import seconds_since_epoch
 from lp.services.webapp import (
+    canonical_url,
+    enabled_with_permission,
     LaunchpadView,
+    Link,
     Navigation,
+    NavigationMenu,
     stepthrough,
     )
 from lp.services.webapp.breadcrumb import (
@@ -31,6 +60,7 @@ from lp.services.webapp.breadcrumb import (
     NameBreadcrumb,
     )
 from lp.services.webapp.interfaces import ICanonicalUrlData
+from lp.snappy.browser.widgets.storechannels import StoreChannelsWidget
 from lp.soyuz.browser.build import get_build_by_id_str
 
 
@@ -83,6 +113,28 @@ class CharmRecipeBreadcrumb(NameBreadcrumb):
             inside=self.context.project)
 
 
+class CharmRecipeNavigationMenu(NavigationMenu):
+    """Navigation menu for charm recipes."""
+
+    usedfor = ICharmRecipe
+
+    facet = "overview"
+
+    links = ("admin", "edit", "delete")
+
+    @enabled_with_permission("launchpad.Admin")
+    def admin(self):
+        return Link("+admin", "Administer charm recipe", icon="edit")
+
+    @enabled_with_permission("launchpad.Edit")
+    def edit(self):
+        return Link("+edit", "Edit charm recipe", icon="edit")
+
+    @enabled_with_permission("launchpad.Edit")
+    def delete(self):
+        return Link("+delete", "Delete charm recipe", icon="trash-icon")
+
+
 class CharmRecipeView(LaunchpadView):
     """Default view of a charm recipe."""
 
@@ -91,6 +143,15 @@ class CharmRecipeView(LaunchpadView):
         return builds_and_requests_for_recipe(self.context)
 
     @property
+    def person_picker(self):
+        field = copy_field(
+            ICharmRecipe["owner"],
+            vocabularyName="AllUserTeamsParticipationPlusSelfSimpleDisplay")
+        return InlinePersonEditPickerWidget(
+            self.context, field, format_link(self.context.owner),
+            header="Change owner", step_title="Select a new owner")
+
+    @property
     def build_frequency(self):
         if self.context.auto_build:
             return "Built automatically"
@@ -156,3 +217,154 @@ def builds_and_requests_for_recipe(recipe):
                 "date_created", "date_requested"))
         items.extend(recent_items[:10 - len(items)])
     return items
+
+
+class ICharmRecipeEditSchema(Interface):
+    """Schema for adding or editing a charm recipe."""
+
+    use_template(ICharmRecipe, include=[
+        "owner",
+        "name",
+        "project",
+        "require_virtualized",
+        "auto_build",
+        "auto_build_channels",
+        "store_upload",
+        ])
+
+    git_ref = copy_field(ICharmRecipe["git_ref"], required=True)
+
+    # This is only required if store_upload is True.  Later validation takes
+    # care of adjusting the required attribute.
+    store_name = copy_field(ICharmRecipe["store_name"], required=True)
+    store_channels = copy_field(ICharmRecipe["store_channels"], required=True)
+
+
+class BaseCharmRecipeEditView(LaunchpadEditFormView):
+
+    schema = ICharmRecipeEditSchema
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
+    def validate_widgets(self, data, names=None):
+        """See `LaunchpadFormView`."""
+        if self.widgets.get("store_upload") is not None:
+            # Set widgets as required or optional depending on the
+            # store_upload field.
+            super(BaseCharmRecipeEditView, self).validate_widgets(
+                data, ["store_upload"])
+            store_upload = data.get("store_upload", False)
+            self.widgets["store_name"].context.required = store_upload
+            self.widgets["store_channels"].context.required = store_upload
+        super(BaseCharmRecipeEditView, self).validate_widgets(
+            data, names=names)
+
+    def validate(self, data):
+        super(BaseCharmRecipeEditView, self).validate(data)
+        # These are the requirements for public snaps.
+        if "owner" in data:
+            owner = data.get("owner", self.context.owner)
+            if owner is not None and owner.private:
+                self.setFieldError(
+                    "owner",
+                    "A public charm recipe cannot have a private owner.")
+        if "git_ref" in data:
+            ref = data.get("git_ref", self.context.git_ref)
+            if ref is not None and ref.private:
+                self.setFieldError(
+                    "git_ref",
+                    "A public charm recipe cannot have a private repository.")
+
+    @action("Update charm recipe", name="update")
+    def request_action(self, action, data):
+        if not data.get("auto_build", False):
+            if "auto_build_channels" in data:
+                del data["auto_build_channels"]
+        store_upload = data.get("store_upload", False)
+        if not store_upload:
+            if "store_name" in data:
+                del data["store_name"]
+            if "store_channels" in data:
+                del data["store_channels"]
+        self.updateContextFromData(data)
+        self.next_url = canonical_url(self.context)
+
+    @property
+    def adapters(self):
+        """See `LaunchpadFormView`."""
+        return {ICharmRecipeEditSchema: self.context}
+
+
+class CharmRecipeAdminView(BaseCharmRecipeEditView):
+    """View for administering charm recipes."""
+
+    @property
+    def label(self):
+        return "Administer %s charm recipe" % self.context.name
+
+    page_title = "Administer"
+
+    field_names = ["require_virtualized"]
+
+
+class CharmRecipeEditView(BaseCharmRecipeEditView):
+    """View for editing charm recipes."""
+
+    @property
+    def label(self):
+        return "Edit %s charm recipe" % self.context.name
+
+    page_title = "Edit"
+
+    field_names = [
+        "owner",
+        "name",
+        "project",
+        "git_ref",
+        "auto_build",
+        "auto_build_channels",
+        "store_upload",
+        "store_name",
+        "store_channels",
+        ]
+    custom_widget_git_ref = GitRefWidget
+    custom_widget_auto_build_channels = CharmRecipeBuildChannelsWidget
+    custom_widget_store_channels = StoreChannelsWidget
+
+    def validate(self, data):
+        super(CharmRecipeEditView, self).validate(data)
+        owner = data.get("owner", None)
+        project = data.get("project", None)
+        name = data.get("name", None)
+        if owner and project and name:
+            try:
+                recipe = getUtility(ICharmRecipeSet).getByName(
+                    owner, project, name)
+                if recipe != self.context:
+                    self.setFieldError(
+                        "name",
+                        "There is already a charm recipe owned by %s in %s "
+                        "with this name." %
+                        (owner.display_name, project.display_name))
+            except NoSuchCharmRecipe:
+                pass
+
+
+class CharmRecipeDeleteView(BaseCharmRecipeEditView):
+    """View for deleting charm recipes."""
+
+    @property
+    def label(self):
+        return "Delete %s charm recipe" % self.context.name
+
+    page_title = "Delete"
+
+    field_names = []
+
+    @action("Delete charm recipe", name="delete")
+    def delete_action(self, action, data):
+        owner = self.context.owner
+        self.context.destroySelf()
+        self.next_url = canonical_url(owner, view_name="+charm-recipes")
diff --git a/lib/lp/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml
index 3c54b6e..60e3dc4 100644
--- a/lib/lp/charms/browser/configure.zcml
+++ b/lib/lp/charms/browser/configure.zcml
@@ -11,6 +11,12 @@
         <browser:url
             for="lp.charms.interfaces.charmrecipe.ICharmRecipe"
             urldata="lp.charms.browser.charmrecipe.CharmRecipeURL" />
+        <browser:menus
+            module="lp.charms.browser.charmrecipe"
+            classes="CharmRecipeNavigationMenu" />
+        <browser:navigation
+            module="lp.charms.browser.charmrecipe"
+            classes="CharmRecipeNavigation" />
         <browser:defaultView
             for="lp.charms.interfaces.charmrecipe.ICharmRecipe"
             name="+index" />
@@ -20,9 +26,24 @@
             permission="launchpad.View"
             name="+index"
             template="../templates/charmrecipe-index.pt" />
-        <browser:navigation
-            module="lp.charms.browser.charmrecipe"
-            classes="CharmRecipeNavigation" />
+        <browser:page
+            for="lp.charms.interfaces.charmrecipe.ICharmRecipe"
+            class="lp.charms.browser.charmrecipe.CharmRecipeAdminView"
+            permission="launchpad.Admin"
+            name="+admin"
+            template="../../app/templates/generic-edit.pt" />
+        <browser:page
+            for="lp.charms.interfaces.charmrecipe.ICharmRecipe"
+            class="lp.charms.browser.charmrecipe.CharmRecipeEditView"
+            permission="launchpad.Edit"
+            name="+edit"
+            template="../templates/charmrecipe-edit.pt" />
+        <browser:page
+            for="lp.charms.interfaces.charmrecipe.ICharmRecipe"
+            class="lp.charms.browser.charmrecipe.CharmRecipeDeleteView"
+            permission="launchpad.Edit"
+            name="+delete"
+            template="../../app/templates/generic-edit.pt" />
         <adapter
             provides="lp.services.webapp.interfaces.IBreadcrumb"
             for="lp.charms.interfaces.charmrecipe.ICharmRecipe"
diff --git a/lib/lp/charms/browser/tests/test_charmrecipe.py b/lib/lp/charms/browser/tests/test_charmrecipe.py
index fbad88e..fcc7f7b 100644
--- a/lib/lp/charms/browser/tests/test_charmrecipe.py
+++ b/lib/lp/charms/browser/tests/test_charmrecipe.py
@@ -23,19 +23,33 @@ from testtools.matchers import (
     )
 import transaction
 from zope.component import getUtility
+from zope.publisher.interfaces import NotFound
+from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
+from zope.testbrowser.browser import LinkNotFoundError
 
+from lp.app.enums import InformationType
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.buildmaster.enums import BuildStatus
 from lp.buildmaster.interfaces.processor import IProcessorSet
-from lp.charms.browser.charmrecipe import CharmRecipeView
+from lp.charms.browser.charmrecipe import (
+    CharmRecipeAdminView,
+    CharmRecipeEditView,
+    CharmRecipeView,
+    )
 from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
+from lp.registry.enums import PersonVisibility
+from lp.services.database.constants import UTC_NOW
 from lp.services.features.testing import FeatureFixture
 from lp.services.job.interfaces.job import JobStatus
 from lp.services.propertycache import get_property_cache
 from lp.services.webapp import canonical_url
+from lp.services.webapp.servers import LaunchpadTestRequest
 from lp.testing import (
     BrowserTestCase,
+    login,
+    login_admin,
+    login_person,
     person_logged_in,
     TestCaseWithFactory,
     time_counter,
@@ -44,6 +58,15 @@ from lp.testing.layers import (
     DatabaseFunctionalLayer,
     LaunchpadFunctionalLayer,
     )
+from lp.testing.matchers import (
+    MatchesPickerText,
+    MatchesTagText,
+    )
+from lp.testing.pages import (
+    extract_text,
+    find_main_content,
+    find_tags_by_class,
+    )
 from lp.testing.publication import test_traverse
 from lp.testing.views import (
     create_initialized_view,
@@ -88,6 +111,206 @@ class BaseTestCharmRecipeView(BrowserTestCase):
             name="test-person", displayname="Test Person")
 
 
+class TestCharmRecipeAdminView(BaseTestCharmRecipeView):
+
+    def test_unauthorized(self):
+        # A non-admin user cannot administer a charm recipe.
+        login_person(self.person)
+        recipe = self.factory.makeCharmRecipe(registrant=self.person)
+        recipe_url = canonical_url(recipe)
+        browser = self.getViewBrowser(recipe, user=self.person)
+        self.assertRaises(
+            LinkNotFoundError, browser.getLink, "Administer charm recipe")
+        self.assertRaises(
+            Unauthorized, self.getUserBrowser, recipe_url + "/+admin",
+            user=self.person)
+
+    def test_admin_recipe(self):
+        # Admins can change require_virtualized.
+        login("admin@xxxxxxxxxxxxx")
+        admin = self.factory.makePerson(
+            member_of=[getUtility(ILaunchpadCelebrities).admin])
+        login_person(self.person)
+        recipe = self.factory.makeCharmRecipe(registrant=self.person)
+        self.assertTrue(recipe.require_virtualized)
+
+        browser = self.getViewBrowser(recipe, user=admin)
+        browser.getLink("Administer charm recipe").click()
+        browser.getControl("Require virtualized builders").selected = False
+        browser.getControl("Update charm recipe").click()
+
+        login_admin()
+        self.assertFalse(recipe.require_virtualized)
+
+    def test_admin_recipe_sets_date_last_modified(self):
+        # Administering a charm recipe sets the date_last_modified property.
+        login("admin@xxxxxxxxxxxxx")
+        ppa_admin = self.factory.makePerson(
+            member_of=[getUtility(ILaunchpadCelebrities).ppa_admin])
+        login_person(self.person)
+        date_created = datetime(2000, 1, 1, tzinfo=pytz.UTC)
+        recipe = self.factory.makeCharmRecipe(
+            registrant=self.person, date_created=date_created)
+        login_person(ppa_admin)
+        view = CharmRecipeAdminView(recipe, LaunchpadTestRequest())
+        view.initialize()
+        view.request_action.success({"require_virtualized": False})
+        self.assertSqlAttributeEqualsDate(
+            recipe, "date_last_modified", UTC_NOW)
+
+
+class TestCharmRecipeEditView(BaseTestCharmRecipeView):
+
+    def test_edit_recipe(self):
+        [old_git_ref] = self.factory.makeGitRefs()
+        recipe = self.factory.makeCharmRecipe(
+            registrant=self.person, owner=self.person, git_ref=old_git_ref)
+        self.factory.makeTeam(
+            name="new-team", displayname="New Team", members=[self.person])
+        [new_git_ref] = self.factory.makeGitRefs()
+
+        browser = self.getViewBrowser(recipe, user=self.person)
+        browser.getLink("Edit charm recipe").click()
+        browser.getControl("Owner").value = ["new-team"]
+        browser.getControl(name="field.name").value = "new-name"
+        browser.getControl(name="field.git_ref.repository").value = (
+            new_git_ref.repository.identity)
+        browser.getControl(name="field.git_ref.path").value = new_git_ref.path
+        browser.getControl(
+            "Automatically build when branch changes").selected = True
+        browser.getControl(
+            name="field.auto_build_channels.charmcraft").value = "edge"
+        browser.getControl("Update charm recipe").click()
+
+        content = find_main_content(browser.contents)
+        self.assertEqual("new-name", extract_text(content.h1))
+        self.assertThat("New Team", MatchesPickerText(content, "edit-owner"))
+        self.assertThat(
+            "Source:\n%s\nEdit charm recipe" % new_git_ref.display_name,
+            MatchesTagText(content, "source"))
+        self.assertThat(
+            "Build schedule:\n(?)\nBuilt automatically\nEdit charm recipe\n",
+            MatchesTagText(content, "auto_build"))
+        self.assertThat(
+            "Source snap channels for automatic builds:\nEdit charm recipe\n"
+            "charmcraft\nedge",
+            MatchesTagText(content, "auto_build_channels"))
+        self.assertThat(
+            "Builds of this charm recipe are not automatically uploaded to "
+            "the store.\nEdit charm recipe",
+            MatchesTagText(content, "store_upload"))
+
+    def test_edit_recipe_sets_date_last_modified(self):
+        # Editing a charm recipe sets the date_last_modified property.
+        date_created = datetime(2000, 1, 1, tzinfo=pytz.UTC)
+        recipe = self.factory.makeCharmRecipe(
+            registrant=self.person, date_created=date_created)
+        with person_logged_in(self.person):
+            view = CharmRecipeEditView(recipe, LaunchpadTestRequest())
+            view.initialize()
+            view.request_action.success({
+                "owner": recipe.owner,
+                "name": "changed",
+                })
+        self.assertSqlAttributeEqualsDate(
+            recipe, "date_last_modified", UTC_NOW)
+
+    def test_edit_recipe_already_exists(self):
+        project = self.factory.makeProduct(displayname="Test Project")
+        recipe = self.factory.makeCharmRecipe(
+            registrant=self.person, project=project, owner=self.person,
+            name="one")
+        self.factory.makeCharmRecipe(
+            registrant=self.person, project=project, owner=self.person,
+            name="two")
+        browser = self.getViewBrowser(recipe, user=self.person)
+        browser.getLink("Edit charm recipe").click()
+        browser.getControl(name="field.name").value = "two"
+        browser.getControl("Update charm recipe").click()
+        self.assertEqual(
+            "There is already a charm recipe owned by Test Person in "
+            "Test Project with this name.",
+            extract_text(find_tags_by_class(browser.contents, "message")[1]))
+
+    def test_edit_public_recipe_private_owner(self):
+        login_person(self.person)
+        recipe = self.factory.makeCharmRecipe(
+            registrant=self.person, owner=self.person)
+        private_team = self.factory.makeTeam(
+            owner=self.person, visibility=PersonVisibility.PRIVATE)
+        private_team_name = private_team.name
+        browser = self.getViewBrowser(recipe, user=self.person)
+        browser.getLink("Edit charm recipe").click()
+        browser.getControl("Owner").value = [private_team_name]
+        browser.getControl("Update charm recipe").click()
+        self.assertEqual(
+            "A public charm recipe cannot have a private owner.",
+            extract_text(find_tags_by_class(browser.contents, "message")[1]))
+
+    def test_edit_public_recipe_private_git_ref(self):
+        login_person(self.person)
+        recipe = self.factory.makeCharmRecipe(
+            registrant=self.person, owner=self.person,
+            git_ref=self.factory.makeGitRefs()[0])
+        login_person(self.person)
+        [private_ref] = self.factory.makeGitRefs(
+            owner=self.person,
+            information_type=InformationType.PRIVATESECURITY)
+        private_ref_identity = private_ref.repository.identity
+        private_ref_path = private_ref.path
+        browser = self.getViewBrowser(recipe, user=self.person)
+        browser.getLink("Edit charm recipe").click()
+        browser.getControl(name="field.git_ref.repository").value = (
+            private_ref_identity)
+        browser.getControl(name="field.git_ref.path").value = private_ref_path
+        browser.getControl("Update charm recipe").click()
+        self.assertEqual(
+            "A public charm recipe cannot have a private repository.",
+            extract_text(find_tags_by_class(browser.contents, "message")[1]))
+
+
+class TestCharmRecipeDeleteView(BaseTestCharmRecipeView):
+
+    def test_unauthorized(self):
+        # A user without edit access cannot delete a charm recipe.
+        recipe = self.factory.makeCharmRecipe(
+            registrant=self.person, owner=self.person)
+        recipe_url = canonical_url(recipe)
+        other_person = self.factory.makePerson()
+        browser = self.getViewBrowser(recipe, user=other_person)
+        self.assertRaises(
+            LinkNotFoundError, browser.getLink, "Delete charm recipe")
+        self.assertRaises(
+            Unauthorized, self.getUserBrowser, recipe_url + "/+delete",
+            user=other_person)
+
+    def test_delete_recipe_without_builds(self):
+        # A charm recipe without builds can be deleted.
+        recipe = self.factory.makeCharmRecipe(
+            registrant=self.person, owner=self.person)
+        recipe_url = canonical_url(recipe)
+        owner_url = canonical_url(self.person)
+        browser = self.getViewBrowser(recipe, user=self.person)
+        browser.getLink("Delete charm recipe").click()
+        browser.getControl("Delete charm recipe").click()
+        self.assertEqual(owner_url + "/+charm-recipes", browser.url)
+        self.assertRaises(NotFound, browser.open, recipe_url)
+
+    def test_delete_recipe_with_builds(self):
+        # A charm recipe with builds can be deleted.
+        recipe = self.factory.makeCharmRecipe(
+            registrant=self.person, owner=self.person)
+        build = self.factory.makeCharmRecipeBuild(recipe=recipe)
+        self.factory.makeCharmFile(build=build)
+        recipe_url = canonical_url(recipe)
+        owner_url = canonical_url(self.person)
+        browser = self.getViewBrowser(recipe, user=self.person)
+        browser.getLink("Delete charm recipe").click()
+        browser.getControl("Delete charm recipe").click()
+        self.assertEqual(owner_url + "/+charm-recipes", browser.url)
+        self.assertRaises(NotFound, browser.open, recipe_url)
+
+
 class TestCharmRecipeView(BaseTestCharmRecipeView):
 
     def setUp(self):
diff --git a/lib/lp/charms/browser/widgets/__init__.py b/lib/lp/charms/browser/widgets/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/charms/browser/widgets/__init__.py
diff --git a/lib/lp/charms/browser/widgets/charmrecipebuildchannels.py b/lib/lp/charms/browser/widgets/charmrecipebuildchannels.py
new file mode 100644
index 0000000..ef76294
--- /dev/null
+++ b/lib/lp/charms/browser/widgets/charmrecipebuildchannels.py
@@ -0,0 +1,108 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""A widget for selecting source snap channels for charm recipe builds."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    "CharmRecipeBuildChannelsWidget",
+    ]
+
+from zope.browserpage import ViewPageTemplateFile
+from zope.formlib.interfaces import IInputWidget
+from zope.formlib.utility import setUpWidget
+from zope.formlib.widget import (
+    BrowserWidget,
+    InputErrors,
+    InputWidget,
+    )
+from zope.interface import implementer
+from zope.schema import TextLine
+from zope.security.proxy import isinstance as zope_isinstance
+
+from lp.app.errors import UnexpectedFormData
+from lp.services.webapp.interfaces import (
+    IAlwaysSubmittedWidget,
+    ISingleLineWidgetLayout,
+    )
+
+
+@implementer(ISingleLineWidgetLayout, IAlwaysSubmittedWidget, IInputWidget)
+class CharmRecipeBuildChannelsWidget(BrowserWidget, InputWidget):
+
+    template = ViewPageTemplateFile("templates/charmrecipebuildchannels.pt")
+    hint = False
+    snap_names = ["core", "core18", "core20", "charmcraft"]
+    _widgets_set_up = False
+
+    def __init__(self, context, request):
+        super(CharmRecipeBuildChannelsWidget, self).__init__(context, request)
+        self.hint = (
+            "The channels to use for build tools when building the charm "
+            "recipe.")
+
+    def setUpSubWidgets(self):
+        if self._widgets_set_up:
+            return
+        fields = [
+            TextLine(
+                __name__=snap_name, title="%s channel" % snap_name,
+                required=False)
+            for snap_name in self.snap_names
+            ]
+        for field in fields:
+            setUpWidget(
+                self, field.__name__, field, IInputWidget, prefix=self.name)
+        self._widgets_set_up = True
+
+    def setRenderedValue(self, value):
+        """See `IWidget`."""
+        self.setUpSubWidgets()
+        if not zope_isinstance(value, dict):
+            value = {}
+        for snap_name in self.snap_names:
+            getattr(self, "%s_widget" % snap_name).setRenderedValue(
+                value.get(snap_name))
+
+    def hasInput(self):
+        """See `IInputWidget`."""
+        return any(
+            "%s.%s" % (self.name, snap_name) in self.request.form
+            for snap_name in self.snap_names)
+
+    def hasValidInput(self):
+        """See `IInputWidget`."""
+        try:
+            self.getInputValue()
+            return True
+        except InputErrors:
+            return False
+        except UnexpectedFormData:
+            return False
+
+    def getInputValue(self):
+        """See `IInputWidget`."""
+        self.setUpSubWidgets()
+        channels = {}
+        for snap_name in self.snap_names:
+            widget = getattr(self, snap_name + "_widget")
+            channel = widget.getInputValue()
+            if channel:
+                channels[snap_name] = channel
+        return channels
+
+    def error(self):
+        """See `IBrowserWidget`."""
+        try:
+            if self.hasInput():
+                self.getInputValue()
+        except InputErrors as error:
+            self._error = error
+        return super(CharmRecipeBuildChannelsWidget, self).error()
+
+    def __call__(self):
+        """See `IBrowserWidget`."""
+        self.setUpSubWidgets()
+        return self.template()
diff --git a/lib/lp/charms/browser/widgets/templates/charmrecipebuildchannels.pt b/lib/lp/charms/browser/widgets/templates/charmrecipebuildchannels.pt
new file mode 100644
index 0000000..cf0052a
--- /dev/null
+++ b/lib/lp/charms/browser/widgets/templates/charmrecipebuildchannels.pt
@@ -0,0 +1,24 @@
+<tal:root
+    xmlns:tal="http://xml.zope.org/namespaces/tal";
+    omit-tag="">
+
+<table class="subordinate">
+  <tr>
+    <td>core</td>
+    <td><div tal:content="structure view/core_widget" /></td>
+  </tr>
+  <tr>
+    <td>core18</td>
+    <td><div tal:content="structure view/core18_widget" /></td>
+  </tr>
+  <tr>
+    <td>core20</td>
+    <td><div tal:content="structure view/core20_widget" /></td>
+  </tr>
+  <tr>
+    <td>charmcraft</td>
+    <td><div tal:content="structure view/charmcraft_widget" /></td>
+  </tr>
+</table>
+
+</tal:root>
diff --git a/lib/lp/charms/browser/widgets/tests/__init__.py b/lib/lp/charms/browser/widgets/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/charms/browser/widgets/tests/__init__.py
diff --git a/lib/lp/charms/browser/widgets/tests/test_charmrecipebuildchannelswidget.py b/lib/lp/charms/browser/widgets/tests/test_charmrecipebuildchannelswidget.py
new file mode 100644
index 0000000..6ab1907
--- /dev/null
+++ b/lib/lp/charms/browser/widgets/tests/test_charmrecipebuildchannelswidget.py
@@ -0,0 +1,166 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+import re
+
+from zope.formlib.interfaces import (
+    IBrowserWidget,
+    IInputWidget,
+    )
+from zope.schema import Dict
+
+from lp.charms.browser.widgets.charmrecipebuildchannels import (
+    CharmRecipeBuildChannelsWidget,
+    )
+from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
+from lp.services.beautifulsoup import BeautifulSoup
+from lp.services.features.testing import FeatureFixture
+from lp.services.webapp.servers import LaunchpadTestRequest
+from lp.testing import (
+    TestCaseWithFactory,
+    verifyObject,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+
+
+class TestCharmRecipeBuildChannelsWidget(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestCharmRecipeBuildChannelsWidget, self).setUp()
+        self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+        field = Dict(
+            __name__="auto_build_channels",
+            title="Source snap channels for automatic builds")
+        self.context = self.factory.makeCharmRecipe()
+        self.field = field.bind(self.context)
+        self.request = LaunchpadTestRequest()
+        self.widget = CharmRecipeBuildChannelsWidget(self.field, self.request)
+
+    def test_implements(self):
+        self.assertTrue(verifyObject(IBrowserWidget, self.widget))
+        self.assertTrue(verifyObject(IInputWidget, self.widget))
+
+    def test_template(self):
+        self.assertTrue(
+            self.widget.template.filename.endswith(
+                "charmrecipebuildchannels.pt"),
+            "Template was not set up.")
+
+    def test_hint(self):
+        self.assertEqual(
+            "The channels to use for build tools when building the charm "
+            "recipe.",
+            self.widget.hint)
+
+    def test_setUpSubWidgets_first_call(self):
+        # The subwidgets are set up and a flag is set.
+        self.widget.setUpSubWidgets()
+        self.assertTrue(self.widget._widgets_set_up)
+        self.assertIsNotNone(getattr(self.widget, "core_widget", None))
+        self.assertIsNotNone(getattr(self.widget, "core18_widget", None))
+        self.assertIsNotNone(getattr(self.widget, "core20_widget", None))
+        self.assertIsNotNone(getattr(self.widget, "charmcraft_widget", None))
+
+    def test_setUpSubWidgets_second_call(self):
+        # The setUpSubWidgets method exits early if a flag is set to
+        # indicate that the widgets were set up.
+        self.widget._widgets_set_up = True
+        self.widget.setUpSubWidgets()
+        self.assertIsNone(getattr(self.widget, "core_widget", None))
+        self.assertIsNone(getattr(self.widget, "core18_widget", None))
+        self.assertIsNone(getattr(self.widget, "core20_widget", None))
+        self.assertIsNone(getattr(self.widget, "charmcraft_widget", None))
+
+    def test_setRenderedValue_None(self):
+        self.widget.setRenderedValue(None)
+        self.assertIsNone(self.widget.core_widget._getCurrentValue())
+        self.assertIsNone(self.widget.core18_widget._getCurrentValue())
+        self.assertIsNone(self.widget.core20_widget._getCurrentValue())
+        self.assertIsNone(self.widget.charmcraft_widget._getCurrentValue())
+
+    def test_setRenderedValue_empty(self):
+        self.widget.setRenderedValue({})
+        self.assertIsNone(self.widget.core_widget._getCurrentValue())
+        self.assertIsNone(self.widget.core18_widget._getCurrentValue())
+        self.assertIsNone(self.widget.core20_widget._getCurrentValue())
+        self.assertIsNone(self.widget.charmcraft_widget._getCurrentValue())
+
+    def test_setRenderedValue_one_channel(self):
+        self.widget.setRenderedValue({"charmcraft": "stable"})
+        self.assertIsNone(self.widget.core_widget._getCurrentValue())
+        self.assertIsNone(self.widget.core18_widget._getCurrentValue())
+        self.assertIsNone(self.widget.core20_widget._getCurrentValue())
+        self.assertEqual(
+            "stable", self.widget.charmcraft_widget._getCurrentValue())
+
+    def test_setRenderedValue_all_channels(self):
+        self.widget.setRenderedValue(
+            {"core": "candidate", "core18": "beta", "core20": "edge",
+             "charmcraft": "stable"})
+        self.assertEqual(
+            "candidate", self.widget.core_widget._getCurrentValue())
+        self.assertEqual("beta", self.widget.core18_widget._getCurrentValue())
+        self.assertEqual("edge", self.widget.core20_widget._getCurrentValue())
+        self.assertEqual(
+            "stable", self.widget.charmcraft_widget._getCurrentValue())
+
+    def test_hasInput_false(self):
+        # hasInput is false when there are no channels in the form data.
+        self.widget.request = LaunchpadTestRequest(form={})
+        self.assertFalse(self.widget.hasInput())
+
+    def test_hasInput_true(self):
+        # hasInput is true when there are channels in the form data.
+        self.widget.request = LaunchpadTestRequest(
+            form={"field.auto_build_channels.charmcraft": "stable"})
+        self.assertTrue(self.widget.hasInput())
+
+    def test_hasValidInput_true(self):
+        # The field input is valid when all submitted channels are valid.
+        # (At the moment, individual channel names are not validated, so
+        # there is no "false" counterpart to this test.)
+        form = {
+            "field.auto_build_channels.core": "",
+            "field.auto_build_channels.core18": "beta",
+            "field.auto_build_channels.core20": "edge",
+            "field.auto_build_channels.charmcraft": "stable",
+            }
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.assertTrue(self.widget.hasValidInput())
+
+    def test_getInputValue(self):
+        form = {
+            "field.auto_build_channels.core": "",
+            "field.auto_build_channels.core18": "beta",
+            "field.auto_build_channels.core20": "edge",
+            "field.auto_build_channels.charmcraft": "stable",
+            }
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.assertEqual(
+            {"core18": "beta", "core20": "edge",
+             "charmcraft": "stable"},
+            self.widget.getInputValue())
+
+    def test_call(self):
+        # The __call__ method sets up the widgets.
+        markup = self.widget()
+        self.assertIsNotNone(self.widget.core_widget)
+        self.assertIsNotNone(self.widget.core18_widget)
+        self.assertIsNotNone(self.widget.core20_widget)
+        self.assertIsNotNone(self.widget.charmcraft_widget)
+        soup = BeautifulSoup(markup)
+        fields = soup.find_all(["input"], {"id": re.compile(".*")})
+        expected_ids = [
+            "field.auto_build_channels.core",
+            "field.auto_build_channels.core18",
+            "field.auto_build_channels.core20",
+            "field.auto_build_channels.charmcraft",
+            ]
+        ids = [field["id"] for field in fields]
+        self.assertContentEqual(expected_ids, ids)
diff --git a/lib/lp/charms/templates/charmrecipe-edit.pt b/lib/lp/charms/templates/charmrecipe-edit.pt
new file mode 100644
index 0000000..81172b8
--- /dev/null
+++ b/lib/lp/charms/templates/charmrecipe-edit.pt
@@ -0,0 +1,71 @@
+<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>
+
+<metal:block fill-slot="head_epilogue">
+  <style type="text/css">
+    .subordinate {
+      margin: 0.5em 0 0.5em 4em;
+    }
+  </style>
+</metal:block>
+
+<div metal:fill-slot="main">
+  <div metal:use-macro="context/@@launchpad_form/form">
+    <metal:formbody fill-slot="widgets">
+      <table class="form">
+        <tal:widget define="widget nocall:view/widgets/owner">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+        <tal:widget define="widget nocall:view/widgets/project">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+        <tal:widget define="widget nocall:view/widgets/name">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+
+        <tal:widget define="widget nocall:view/widgets/git_ref">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+
+        <tal:widget define="widget nocall:view/widgets/auto_build">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+        <tr>
+          <td>
+            <table class="subordinate">
+              <tal:widget define="widget nocall:view/widgets/auto_build_channels">
+                <metal:block use-macro="context/@@launchpad_form/widget_row" />
+              </tal:widget>
+            </table>
+          </td>
+        </tr>
+
+        <tal:widget define="widget nocall:view/widgets/store_upload">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+        <tr>
+          <td>
+            <table class="subordinate">
+              <tal:widget define="widget nocall:view/widgets/store_name">
+                <metal:block use-macro="context/@@launchpad_form/widget_row" />
+              </tal:widget>
+              <tal:widget define="widget nocall:view/widgets/store_channels"
+                          condition="widget/has_risks_vocabulary">
+                <metal:block use-macro="context/@@launchpad_form/widget_row" />
+              </tal:widget>
+            </table>
+          </td>
+        </tr>
+      </table>
+    </metal:formbody>
+  </div>
+</div>
+
+</body>
+</html>
diff --git a/lib/lp/charms/templates/charmrecipe-index.pt b/lib/lp/charms/templates/charmrecipe-index.pt
index 90a849a..55b191d 100644
--- a/lib/lp/charms/templates/charmrecipe-index.pt
+++ b/lib/lp/charms/templates/charmrecipe-index.pt
@@ -30,13 +30,14 @@
     <div class="two-column-list">
       <dl id="owner">
         <dt>Owner:</dt>
-        <dd tal:content="structure context/owner/fmt:link"/>
+        <dd tal:content="structure view/person_picker"/>
       </dl>
       <dl id="project" tal:define="project context/project">
         <dt>Project:</dt>
         <dd>
           <a tal:attributes="href context/project/fmt:url"
              tal:content="context/project/display_name"/>
+          <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
         </dd>
       </dl>
       <dl id="source"
@@ -44,6 +45,7 @@
         <dt>Source:</dt>
         <dd tal:condition="view/user_can_see_source">
           <a tal:replace="structure source/fmt:link"/>
+          <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
         </dd>
         <dd tal:condition="not: view/user_can_see_source">
           <span class="sprite private">&lt;redacted&gt;</span>
@@ -57,11 +59,13 @@
         </dt>
         <dd>
           <span tal:replace="view/build_frequency"/>
+          <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
         </dd>
       </dl>
       <dl id="auto_build_channels" tal:condition="context/auto_build_channels">
         <dt>
           Source snap channels for automatic builds:
+          <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
         </dt>
         <dd>
           <table class="listing compressed">
@@ -81,12 +85,14 @@
         <dt>Registered store package name:</dt>
         <dd>
           <span tal:content="context/store_name"/>
+          <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
         </dd>
       </dl>
       <dl id="store_channels" tal:condition="view/store_channels">
         <dt>Store channels:</dt>
         <dd>
           <span tal:content="view/store_channels"/>
+          <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
         </dd>
       </dl>
       <p id="store_channels" tal:condition="not: view/store_channels">
@@ -95,6 +101,7 @@
     </div>
     <p id="store_upload" tal:condition="not: context/store_upload">
       Builds of this charm recipe are not automatically uploaded to the store.
+      <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
     </p>
 
     <h2>Latest builds</h2>