launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #27197
[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"><redacted></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>