launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #27199
[Merge] ~cjwatson/launchpad:charm-recipe-create-views into launchpad:master
Colin Watson has proposed merging ~cjwatson/launchpad:charm-recipe-create-views into launchpad:master with ~cjwatson/launchpad:charm-recipe-edit-views as a prerequisite.
Commit message:
Add views for creating charm recipes
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/403968
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charm-recipe-create-views into launchpad:master.
diff --git a/lib/lp/charms/browser/charmrecipe.py b/lib/lp/charms/browser/charmrecipe.py
index 6511424..2345ec1 100644
--- a/lib/lp/charms/browser/charmrecipe.py
+++ b/lib/lp/charms/browser/charmrecipe.py
@@ -7,6 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals
__metaclass__ = type
__all__ = [
+ "CharmRecipeAddView",
"CharmRecipeAdminView",
"CharmRecipeDeleteView",
"CharmRecipeEditView",
@@ -30,6 +31,7 @@ from zope.security.interfaces import Unauthorized
from lp.app.browser.launchpadform import (
action,
LaunchpadEditFormView,
+ LaunchpadFormView,
)
from lp.app.browser.lazrjs import InlinePersonEditPickerWidget
from lp.app.browser.tales import format_link
@@ -43,7 +45,9 @@ from lp.charms.interfaces.charmrecipe import (
)
from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuildSet
from lp.code.browser.widgets.gitref import GitRefWidget
+from lp.code.interfaces.gitref import IGitRef
from lp.registry.interfaces.personproduct import IPersonProductFactory
+from lp.registry.interfaces.product import IProduct
from lp.services.propertycache import cachedproperty
from lp.services.utils import seconds_since_epoch
from lp.services.webapp import (
@@ -240,6 +244,96 @@ class ICharmRecipeEditSchema(Interface):
store_channels = copy_field(ICharmRecipe["store_channels"], required=True)
+class CharmRecipeAddView(LaunchpadFormView):
+ """View for creating charm recipes."""
+
+ page_title = label = "Create a new charm recipe"
+
+ schema = ICharmRecipeEditSchema
+
+ custom_widget_git_ref = GitRefWidget
+ custom_widget_auto_build_channels = CharmRecipeBuildChannelsWidget
+ custom_widget_store_channels = StoreChannelsWidget
+
+ @property
+ def field_names(self):
+ fields = ["owner", "name"]
+ if self.is_project_context:
+ fields += ["git_ref"]
+ else:
+ fields += ["project"]
+ return fields + [
+ "auto_build",
+ "auto_build_channels",
+ "store_upload",
+ "store_name",
+ "store_channels",
+ ]
+
+ @property
+ def is_project_context(self):
+ return IProduct.providedBy(self.context)
+
+ @property
+ def cancel_url(self):
+ return canonical_url(self.context)
+
+ @property
+ def initial_values(self):
+ initial_values = {"owner": self.user}
+ if (IGitRef.providedBy(self.context) and
+ IProduct.providedBy(self.context.target)):
+ initial_values["project"] = self.context.target
+ return initial_values
+
+ 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(CharmRecipeAddView, 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(CharmRecipeAddView, self).validate_widgets(data, names=names)
+
+ @action("Create charm recipe", name="create")
+ def create_action(self, action, data):
+ if IGitRef.providedBy(self.context):
+ project = data["project"]
+ git_ref = self.context
+ elif self.is_project_context:
+ project = self.context
+ git_ref = data["git_ref"]
+ else:
+ raise NotImplementedError(
+ "Unknown context for charm recipe creation.")
+ recipe = getUtility(ICharmRecipeSet).new(
+ self.user, data["owner"], project, data["name"], git_ref=git_ref,
+ auto_build=data["auto_build"],
+ auto_build_channels=data["auto_build_channels"],
+ store_upload=data["store_upload"],
+ store_name=data["store_name"],
+ store_channels=data.get("store_channels"))
+ self.next_url = canonical_url(recipe)
+
+ def validate(self, data):
+ super(CharmRecipeAddView, self).validate(data)
+ owner = data.get("owner", None)
+ if self.is_project_context:
+ project = self.context
+ else:
+ project = data.get("project", None)
+ name = data.get("name", None)
+ if owner and project and name:
+ if getUtility(ICharmRecipeSet).exists(owner, project, name):
+ self.setFieldError(
+ "name",
+ "There is already a charm recipe owned by %s in %s with "
+ "this name." % (owner.display_name, project.display_name))
+
+
class BaseCharmRecipeEditView(LaunchpadEditFormView):
schema = ICharmRecipeEditSchema
diff --git a/lib/lp/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml
index 60e3dc4..72be851 100644
--- a/lib/lp/charms/browser/configure.zcml
+++ b/lib/lp/charms/browser/configure.zcml
@@ -50,6 +50,19 @@
factory="lp.charms.browser.charmrecipe.CharmRecipeBreadcrumb"
permission="zope.Public" />
+ <browser:page
+ for="lp.code.interfaces.gitref.IGitRef"
+ class="lp.charms.browser.charmrecipe.CharmRecipeAddView"
+ permission="launchpad.AnyPerson"
+ name="+new-charm-recipe"
+ template="../templates/charmrecipe-new.pt" />
+ <browser:page
+ for="lp.registry.interfaces.product.IProduct"
+ class="lp.charms.browser.charmrecipe.CharmRecipeAddView"
+ permission="launchpad.AnyPerson"
+ name="+new-charm-recipe"
+ template="../templates/charmrecipe-new.pt" />
+
<browser:url
for="lp.charms.interfaces.charmrecipe.ICharmRecipeBuildRequest"
path_expression="string:+build-request/${id}"
diff --git a/lib/lp/charms/browser/hascharmrecipes.py b/lib/lp/charms/browser/hascharmrecipes.py
index 5c395b5..16f6f4c 100644
--- a/lib/lp/charms/browser/hascharmrecipes.py
+++ b/lib/lp/charms/browser/hascharmrecipes.py
@@ -13,8 +13,13 @@ __all__ = [
from zope.component import getUtility
-from lp.charms.interfaces.charmrecipe import ICharmRecipeSet
+from lp.charms.interfaces.charmrecipe import (
+ CHARM_RECIPE_ALLOW_CREATE,
+ CHARM_RECIPE_PRIVATE_FEATURE_FLAG,
+ ICharmRecipeSet,
+ )
from lp.code.interfaces.gitrepository import IGitRepository
+from lp.services.features import getFeatureFlag
from lp.services.webapp import (
canonical_url,
Link,
@@ -31,6 +36,17 @@ class HasCharmRecipesMenuMixin:
self.context, visible_by_user=self.user).is_empty()
return Link("+charm-recipes", text, icon="info", enabled=enabled)
+ def create_charm_recipe(self):
+ # Only enabled for private contexts if the
+ # charm.recipe.allow_private flag is enabled.
+ enabled = (
+ bool(getFeatureFlag(CHARM_RECIPE_ALLOW_CREATE)) and (
+ not self.context.private or
+ bool(getFeatureFlag(CHARM_RECIPE_PRIVATE_FEATURE_FLAG))))
+
+ text = "Create charm recipe"
+ return Link("+new-charm-recipe", text, enabled=enabled, icon="add")
+
class HasCharmRecipesViewMixin:
"""A view mixin for objects that have charm recipes."""
diff --git a/lib/lp/charms/browser/tests/test_charmrecipe.py b/lib/lp/charms/browser/tests/test_charmrecipe.py
index fcc7f7b..c6130cb 100644
--- a/lib/lp/charms/browser/tests/test_charmrecipe.py
+++ b/lib/lp/charms/browser/tests/test_charmrecipe.py
@@ -66,6 +66,7 @@ from lp.testing.pages import (
extract_text,
find_main_content,
find_tags_by_class,
+ find_tag_by_id,
)
from lp.testing.publication import test_traverse
from lp.testing.views import (
@@ -111,6 +112,155 @@ class BaseTestCharmRecipeView(BrowserTestCase):
name="test-person", displayname="Test Person")
+class TestCharmRecipeAddView(BaseTestCharmRecipeView):
+
+ def test_create_new_recipe_not_logged_in(self):
+ [git_ref] = self.factory.makeGitRefs()
+ self.assertRaises(
+ Unauthorized, self.getViewBrowser, git_ref,
+ view_name="+new-charm-recipe", no_login=True)
+
+ def test_create_new_recipe_git(self):
+ self.factory.makeProduct(
+ name="test-project", displayname="Test Project")
+ [git_ref] = self.factory.makeGitRefs(
+ owner=self.person, target=self.person)
+ source_display = git_ref.display_name
+ browser = self.getViewBrowser(
+ git_ref, view_name="+new-charm-recipe", user=self.person)
+ browser.getControl(name="field.name").value = "charm-name"
+ self.assertEqual("", browser.getControl(name="field.project").value)
+ browser.getControl(name="field.project").value = "test-project"
+ browser.getControl("Create charm recipe").click()
+
+ content = find_main_content(browser.contents)
+ self.assertEqual("charm-name", extract_text(content.h1))
+ self.assertThat(
+ "Test Person", MatchesPickerText(content, "edit-owner"))
+ self.assertThat(
+ "Project:\nTest Project\nEdit charm recipe",
+ MatchesTagText(content, "project"))
+ self.assertThat(
+ "Source:\n%s\nEdit charm recipe" % source_display,
+ MatchesTagText(content, "source"))
+ self.assertThat(
+ "Build schedule:\n(?)\nBuilt on request\nEdit charm recipe\n",
+ MatchesTagText(content, "auto_build"))
+ self.assertIsNone(find_tag_by_id(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_create_new_recipe_git_project_namespace(self):
+ # If the Git repository is already in a project namespace, then that
+ # project is the default for the new recipe.
+ project = self.factory.makeProduct(
+ name="test-project", displayname="Test Project")
+ [git_ref] = self.factory.makeGitRefs(target=project)
+ source_display = git_ref.display_name
+ browser = self.getViewBrowser(
+ git_ref, view_name="+new-charm-recipe", user=self.person)
+ browser.getControl(name="field.name").value = "charm-name"
+ self.assertEqual(
+ "test-project", browser.getControl(name="field.project").value)
+ browser.getControl("Create charm recipe").click()
+
+ content = find_main_content(browser.contents)
+ self.assertEqual("charm-name", extract_text(content.h1))
+ self.assertThat(
+ "Test Person", MatchesPickerText(content, "edit-owner"))
+ self.assertThat(
+ "Project:\nTest Project\nEdit charm recipe",
+ MatchesTagText(content, "project"))
+ self.assertThat(
+ "Source:\n%s\nEdit charm recipe" % source_display,
+ MatchesTagText(content, "source"))
+ self.assertThat(
+ "Build schedule:\n(?)\nBuilt on request\nEdit charm recipe\n",
+ MatchesTagText(content, "auto_build"))
+ self.assertIsNone(find_tag_by_id(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_create_new_recipe_project(self):
+ project = self.factory.makeProduct(displayname="Test Project")
+ [git_ref] = self.factory.makeGitRefs()
+ source_display = git_ref.display_name
+ browser = self.getViewBrowser(
+ project, view_name="+new-charm-recipe", user=self.person)
+ browser.getControl(name="field.name").value = "charm-name"
+ browser.getControl(name="field.git_ref.repository").value = (
+ git_ref.repository.shortened_path)
+ browser.getControl(name="field.git_ref.path").value = git_ref.path
+ browser.getControl("Create charm recipe").click()
+
+ content = find_main_content(browser.contents)
+ self.assertEqual("charm-name", extract_text(content.h1))
+ self.assertThat(
+ "Test Person", MatchesPickerText(content, "edit-owner"))
+ self.assertThat(
+ "Project:\nTest Project\nEdit charm recipe",
+ MatchesTagText(content, "project"))
+ self.assertThat(
+ "Source:\n%s\nEdit charm recipe" % source_display,
+ MatchesTagText(content, "source"))
+ self.assertThat(
+ "Build schedule:\n(?)\nBuilt on request\nEdit charm recipe\n",
+ MatchesTagText(content, "auto_build"))
+ self.assertIsNone(find_tag_by_id(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_create_new_recipe_users_teams_as_owner_options(self):
+ # Teams that the user is in are options for the charm recipe owner.
+ self.factory.makeTeam(
+ name="test-team", displayname="Test Team", members=[self.person])
+ [git_ref] = self.factory.makeGitRefs()
+ browser = self.getViewBrowser(
+ git_ref, view_name="+new-charm-recipe", user=self.person)
+ options = browser.getControl("Owner").displayOptions
+ self.assertEqual(
+ ["Test Person (test-person)", "Test Team (test-team)"],
+ sorted(str(option) for option in options))
+
+ def test_create_new_recipe_auto_build(self):
+ # Creating a new recipe and asking for it to be automatically built
+ # sets all the appropriate fields.
+ self.factory.makeProduct(
+ name="test-project", displayname="Test Project")
+ [git_ref] = self.factory.makeGitRefs()
+ browser = self.getViewBrowser(
+ git_ref, view_name="+new-charm-recipe", user=self.person)
+ browser.getControl(name="field.name").value = "charm-name"
+ browser.getControl(name="field.project").value = "test-project"
+ browser.getControl(
+ "Automatically build when branch changes").selected = True
+ browser.getControl(
+ name="field.auto_build_channels.charmcraft").value = "edge"
+ browser.getControl(
+ name="field.auto_build_channels.core").value = "stable"
+ browser.getControl(
+ name="field.auto_build_channels.core18").value = "beta"
+ browser.getControl(
+ name="field.auto_build_channels.core20").value = "edge/feature"
+ browser.getControl("Create charm recipe").click()
+
+ content = find_main_content(browser.contents)
+ 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\ncore\nstable\ncore18\nbeta\n"
+ "core20\nedge/feature\n",
+ MatchesTagText(content, "auto_build_channels"))
+
+
class TestCharmRecipeAdminView(BaseTestCharmRecipeView):
def test_unauthorized(self):
diff --git a/lib/lp/charms/browser/tests/test_hascharmrecipes.py b/lib/lp/charms/browser/tests/test_hascharmrecipes.py
index 6374893..6cc7bf7 100644
--- a/lib/lp/charms/browser/tests/test_hascharmrecipes.py
+++ b/lib/lp/charms/browser/tests/test_hascharmrecipes.py
@@ -7,13 +7,16 @@ from __future__ import absolute_import, print_function, unicode_literals
__metaclass__ = type
+import soupmatchers
from testscenarios import (
load_tests_apply_scenarios,
WithScenarios,
)
+from testtools.matchers import Not
from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
from lp.code.interfaces.gitrepository import IGitRepository
+from lp.code.tests.helpers import GitHostingFixture
from lp.services.features.testing import FeatureFixture
from lp.services.webapp import canonical_url
from lp.testing import TestCaseWithFactory
@@ -83,4 +86,57 @@ class TestHasCharmRecipesView(WithScenarios, TestCaseWithFactory):
self.assertEqual(expected_link, view.charm_recipes_link)
+class TestHasCharmRecipesMenu(WithScenarios, TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ scenarios = [
+ ("GitRef", {"context_factory": make_git_ref}),
+ ]
+
+ def setUp(self):
+ super(TestHasCharmRecipesMenu, self).setUp()
+ self.useFixture(GitHostingFixture())
+
+ def makeCharmRecipe(self, context):
+ return self.factory.makeCharmRecipe(git_ref=context)
+
+ def test_feature_flag_disabled(self):
+ # If the feature flag to allow charm recipe creation is disabled, we
+ # don't show a creation link.
+ context = self.context_factory(self)
+ view = create_initialized_view(context, "+index")
+ new_charm_recipe_url = canonical_url(
+ context, view_name="+new-charm-recipe")
+ self.assertThat(view(), Not(soupmatchers.HTMLContains(
+ soupmatchers.Tag(
+ "creation link", "a", attrs={"href": new_charm_recipe_url},
+ text="Create charm recipe"))))
+
+ def test_creation_link_no_recipes(self):
+ # An object with no charm recipes shows a creation link.
+ self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+ context = self.context_factory(self)
+ view = create_initialized_view(context, "+index")
+ new_charm_recipe_url = canonical_url(
+ context, view_name="+new-charm-recipe")
+ self.assertThat(view(), soupmatchers.HTMLContains(
+ soupmatchers.Tag(
+ "creation link", "a", attrs={"href": new_charm_recipe_url},
+ text="Create charm recipe")))
+
+ def test_creation_link_recipes(self):
+ # An object with charm recipes shows a creation link.
+ self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
+ context = self.context_factory(self)
+ self.makeCharmRecipe(context)
+ view = create_initialized_view(context, "+index")
+ new_charm_recipe_url = canonical_url(
+ context, view_name="+new-charm-recipe")
+ self.assertThat(view(), soupmatchers.HTMLContains(
+ soupmatchers.Tag(
+ "creation link", "a", attrs={"href": new_charm_recipe_url},
+ text="Create charm recipe")))
+
+
load_tests = load_tests_apply_scenarios
diff --git a/lib/lp/charms/browser/widgets/charmrecipebuildchannels.py b/lib/lp/charms/browser/widgets/charmrecipebuildchannels.py
index ef76294..b1512ed 100644
--- a/lib/lp/charms/browser/widgets/charmrecipebuildchannels.py
+++ b/lib/lp/charms/browser/widgets/charmrecipebuildchannels.py
@@ -34,7 +34,7 @@ class CharmRecipeBuildChannelsWidget(BrowserWidget, InputWidget):
template = ViewPageTemplateFile("templates/charmrecipebuildchannels.pt")
hint = False
- snap_names = ["core", "core18", "core20", "charmcraft"]
+ snap_names = ["charmcraft", "core", "core18", "core20"]
_widgets_set_up = False
def __init__(self, context, request):
diff --git a/lib/lp/charms/browser/widgets/templates/charmrecipebuildchannels.pt b/lib/lp/charms/browser/widgets/templates/charmrecipebuildchannels.pt
index cf0052a..0430f4a 100644
--- a/lib/lp/charms/browser/widgets/templates/charmrecipebuildchannels.pt
+++ b/lib/lp/charms/browser/widgets/templates/charmrecipebuildchannels.pt
@@ -4,6 +4,10 @@
<table class="subordinate">
<tr>
+ <td>charmcraft</td>
+ <td><div tal:content="structure view/charmcraft_widget" /></td>
+ </tr>
+ <tr>
<td>core</td>
<td><div tal:content="structure view/core_widget" /></td>
</tr>
@@ -15,10 +19,6 @@
<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/test_charmrecipebuildchannelswidget.py b/lib/lp/charms/browser/widgets/tests/test_charmrecipebuildchannelswidget.py
index 6ab1907..67b2040 100644
--- a/lib/lp/charms/browser/widgets/tests/test_charmrecipebuildchannelswidget.py
+++ b/lib/lp/charms/browser/widgets/tests/test_charmrecipebuildchannelswidget.py
@@ -62,53 +62,53 @@ class TestCharmRecipeBuildChannelsWidget(TestCaseWithFactory):
# 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, "charmcraft_widget", None))
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, "charmcraft_widget", None))
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.charmcraft_widget._getCurrentValue())
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.charmcraft_widget._getCurrentValue())
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.assertEqual(
+ "stable", self.widget.charmcraft_widget._getCurrentValue())
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"})
+ {"charmcraft": "stable", "core": "candidate", "core18": "beta",
+ "core20": "edge"})
+ self.assertEqual(
+ "stable", self.widget.charmcraft_widget._getCurrentValue())
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.
@@ -126,41 +126,40 @@ class TestCharmRecipeBuildChannelsWidget(TestCaseWithFactory):
# (At the moment, individual channel names are not validated, so
# there is no "false" counterpart to this test.)
form = {
+ "field.auto_build_channels.charmcraft": "stable",
"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.charmcraft": "stable",
"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"},
+ {"charmcraft": "stable", "core18": "beta", "core20": "edge"},
self.widget.getInputValue())
def test_call(self):
# The __call__ method sets up the widgets.
markup = self.widget()
+ self.assertIsNotNone(self.widget.charmcraft_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.charmcraft",
"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/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
index 9c76cf4..80e4572 100644
--- a/lib/lp/charms/interfaces/charmrecipe.py
+++ b/lib/lp/charms/interfaces/charmrecipe.py
@@ -445,8 +445,8 @@ class ICharmRecipeEditableAttributes(Interface):
key_type=TextLine(), required=False, readonly=False,
description=_(
"A dictionary mapping snap names to channels to use when building "
- "this charm recipe. Currently only 'core', 'core18', 'core20', "
- "and 'charmcraft' keys are supported."))
+ "this charm recipe. Currently only 'charmcraft', 'core', "
+ "'core18', and 'core20' keys are supported."))
is_stale = Bool(
title=_("Charm recipe is stale and is due to be rebuilt."),
diff --git a/lib/lp/charms/interfaces/charmrecipebuild.py b/lib/lp/charms/interfaces/charmrecipebuild.py
index 284566f..077ccce 100644
--- a/lib/lp/charms/interfaces/charmrecipebuild.py
+++ b/lib/lp/charms/interfaces/charmrecipebuild.py
@@ -65,8 +65,8 @@ class ICharmRecipeBuildView(IPackageBuild):
title=_("Source snap channels to use for this build."),
description=_(
"A dictionary mapping snap names to channels to use for this "
- "build. Currently only 'core', 'core18', 'core20', "
- "and 'charmcraft' keys are supported."),
+ "build. Currently only 'charmcraft', 'core', 'core18', and "
+ "'core20' keys are supported."),
key_type=TextLine())
virtualized = Bool(
diff --git a/lib/lp/charms/interfaces/charmrecipejob.py b/lib/lp/charms/interfaces/charmrecipejob.py
index 9bdc9e5..1094e5a 100644
--- a/lib/lp/charms/interfaces/charmrecipejob.py
+++ b/lib/lp/charms/interfaces/charmrecipejob.py
@@ -64,8 +64,8 @@ class ICharmRecipeRequestBuildsJob(IRunnableJob):
title=_("Source snap channels to use for these builds."),
description=_(
"A dictionary mapping snap names to channels to use for these "
- "builds. Currently only 'core', 'core18', 'core20', and "
- "'charmcraft' keys are supported."),
+ "builds. Currently only 'charmcraft', 'core', 'core18', and "
+ "'core20' keys are supported."),
key_type=TextLine(), required=False, readonly=True)
architectures = Set(
diff --git a/lib/lp/charms/templates/charmrecipe-macros.pt b/lib/lp/charms/templates/charmrecipe-macros.pt
index 93b4d69..fe314e1 100644
--- a/lib/lp/charms/templates/charmrecipe-macros.pt
+++ b/lib/lp/charms/templates/charmrecipe-macros.pt
@@ -17,6 +17,12 @@
</div>
</div>
+ <span
+ tal:define="link context_menu/create_charm_recipe|nothing"
+ tal:condition="python: link and link.enabled"
+ tal:replace="structure link/render"
+ />
+
</div>
</tal:root>
diff --git a/lib/lp/charms/templates/charmrecipe-new.pt b/lib/lp/charms/templates/charmrecipe-new.pt
new file mode 100644
index 0000000..bde870d
--- /dev/null
+++ b/lib/lp/charms/templates/charmrecipe-new.pt
@@ -0,0 +1,87 @@
+<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_side"
+ i18n:domain="launchpad">
+<body>
+
+<div metal:fill-slot="main">
+ <div>
+ <p>
+ A <a href="https://juju.is/docs">charmed operator</a> (packaged as a
+ "charm") encapsulates a single application and all the code and
+ know-how it takes to operate it, such as how to combine and work with
+ other related applications or how to upgrade it. Launchpad can build
+ charms using <a href="https://juju.is/docs/sdk">charmcraft</a>, from
+ any Git branch on Launchpad that has a <tt>charmcraft.yaml</tt> file.
+ </p>
+ </div>
+
+ <div metal:use-macro="context/@@launchpad_form/form">
+ <metal:formbody fill-slot="widgets">
+ <table class="form">
+ <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/owner">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+
+ <tal:not-project-context condition="not: view/is_project_context">
+ <tal:widget define="widget nocall:view/widgets/project">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ </tal:not-project-context>
+
+ <tal:project-context condition="view/is_project_context">
+ <tal:widget define="widget nocall:view/widgets/git_ref">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ </tal:project-context>
+
+ <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>
+
+ <script type="text/javascript">
+ LPJS.use('lp.charms.charmrecipe.edit', function(Y) {
+ Y.on('domready', function(e) {
+ Y.lp.charms.charmrecipe.edit.setup();
+ }, window);
+ });
+ </script>
+</div>
+
+</body>
+</html>
diff --git a/lib/lp/code/browser/gitref.py b/lib/lp/code/browser/gitref.py
index c53f8f1..95abbd9 100644
--- a/lib/lp/code/browser/gitref.py
+++ b/lib/lp/code/browser/gitref.py
@@ -84,6 +84,7 @@ class GitRefContextMenu(
facet = 'branches'
links = [
'browse_commits',
+ 'create_charm_recipe',
'create_recipe',
'create_snap',
'register_merge',
diff --git a/lib/lp/registry/browser/product.py b/lib/lp/registry/browser/product.py
index 628fe0e..63724fd 100644
--- a/lib/lp/registry/browser/product.py
+++ b/lib/lp/registry/browser/product.py
@@ -588,6 +588,7 @@ class ProductOverviewMenu(ApplicationMenu, ProductEditLinksMixin,
'view_charm_recipes',
'view_recipes',
'view_snaps',
+ 'create_charm_recipe',
'create_snap',
]
diff --git a/lib/lp/registry/templates/product-index.pt b/lib/lp/registry/templates/product-index.pt
index 242483d..fc3c274 100644
--- a/lib/lp/registry/templates/product-index.pt
+++ b/lib/lp/registry/templates/product-index.pt
@@ -170,25 +170,35 @@
</p>
<ul class="horizontal" id="project-link-info">
- <li tal:condition="overview_menu/series_add/enabled">
+ <li class="nowrap"
+ tal:condition="overview_menu/series_add/enabled">
<a tal:replace="structure overview_menu/series_add/fmt:link" />
</li>
- <li>
+ <li class="nowrap">
<a tal:replace="structure overview_menu/milestones/fmt:link" />
</li>
- <li tal:define="link context/menu:overview/view_recipes"
+ <li class="nowrap"
+ tal:define="link context/menu:overview/view_recipes"
tal:condition="link/enabled">
<a tal:replace="structure link/fmt:link" />
</li>
- <li tal:define="link context/menu:overview/view_snaps"
+ <li class="nowrap"
+ tal:define="link context/menu:overview/view_snaps"
tal:condition="link/enabled">
<a tal:replace="structure link/fmt:link" />
</li>
- <li tal:define="link context/menu:overview/create_snap"
+ <li class="nowrap"
+ tal:define="link context/menu:overview/create_snap"
tal:condition="link/enabled">
<a tal:replace="structure link/fmt:link" />
</li>
- <li tal:define="link context/menu:overview/view_charm_recipes"
+ <li class="nowrap"
+ tal:define="link context/menu:overview/view_charm_recipes"
+ tal:condition="link/enabled">
+ <a tal:replace="structure link/fmt:link" />
+ </li>
+ <li class="nowrap"
+ tal:define="link context/menu:overview/create_charm_recipe"
tal:condition="link/enabled">
<a tal:replace="structure link/fmt:link" />
</li>