Merge ~cjwatson/launchpad:charm-recipe-create-views into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 3d9db4903f64d9bcbf13ca06def41eaba462a664
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:charm-recipe-create-views
Merge into: launchpad:master
Prerequisite: ~cjwatson/launchpad:charm-recipe-edit-views
Diff against target: 814 lines (+467/-34)
16 files modified
lib/lp/charms/browser/charmrecipe.py (+94/-0)
lib/lp/charms/browser/configure.zcml (+13/-0)
lib/lp/charms/browser/hascharmrecipes.py (+17/-1)
lib/lp/charms/browser/tests/test_charmrecipe.py (+150/-0)
lib/lp/charms/browser/tests/test_hascharmrecipes.py (+56/-0)
lib/lp/charms/browser/widgets/charmrecipebuildchannels.py (+1/-1)
lib/lp/charms/browser/widgets/templates/charmrecipebuildchannels.pt (+4/-4)
lib/lp/charms/browser/widgets/tests/test_charmrecipebuildchannelswidget.py (+15/-16)
lib/lp/charms/interfaces/charmrecipe.py (+2/-2)
lib/lp/charms/interfaces/charmrecipebuild.py (+2/-2)
lib/lp/charms/interfaces/charmrecipejob.py (+2/-2)
lib/lp/charms/templates/charmrecipe-macros.pt (+6/-0)
lib/lp/charms/templates/charmrecipe-new.pt (+87/-0)
lib/lp/code/browser/gitref.py (+1/-0)
lib/lp/registry/browser/product.py (+1/-0)
lib/lp/registry/templates/product-index.pt (+16/-6)
Reviewer Review Type Date Requested Status
Ioana Lasc (community) Approve
Review via email: mp+403968@code.launchpad.net

Commit message

Add views for creating charm recipes

To post a comment you must log in.
Revision history for this message
Ioana Lasc (ilasc) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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
77
8__metaclass__ = type8__metaclass__ = type
9__all__ = [9__all__ = [
10 "CharmRecipeAddView",
10 "CharmRecipeAdminView",11 "CharmRecipeAdminView",
11 "CharmRecipeDeleteView",12 "CharmRecipeDeleteView",
12 "CharmRecipeEditView",13 "CharmRecipeEditView",
@@ -30,6 +31,7 @@ from zope.security.interfaces import Unauthorized
30from lp.app.browser.launchpadform import (31from lp.app.browser.launchpadform import (
31 action,32 action,
32 LaunchpadEditFormView,33 LaunchpadEditFormView,
34 LaunchpadFormView,
33 )35 )
34from lp.app.browser.lazrjs import InlinePersonEditPickerWidget36from lp.app.browser.lazrjs import InlinePersonEditPickerWidget
35from lp.app.browser.tales import format_link37from lp.app.browser.tales import format_link
@@ -43,7 +45,9 @@ from lp.charms.interfaces.charmrecipe import (
43 )45 )
44from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuildSet46from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuildSet
45from lp.code.browser.widgets.gitref import GitRefWidget47from lp.code.browser.widgets.gitref import GitRefWidget
48from lp.code.interfaces.gitref import IGitRef
46from lp.registry.interfaces.personproduct import IPersonProductFactory49from lp.registry.interfaces.personproduct import IPersonProductFactory
50from lp.registry.interfaces.product import IProduct
47from lp.services.propertycache import cachedproperty51from lp.services.propertycache import cachedproperty
48from lp.services.utils import seconds_since_epoch52from lp.services.utils import seconds_since_epoch
49from lp.services.webapp import (53from lp.services.webapp import (
@@ -240,6 +244,96 @@ class ICharmRecipeEditSchema(Interface):
240 store_channels = copy_field(ICharmRecipe["store_channels"], required=True)244 store_channels = copy_field(ICharmRecipe["store_channels"], required=True)
241245
242246
247class CharmRecipeAddView(LaunchpadFormView):
248 """View for creating charm recipes."""
249
250 page_title = label = "Create a new charm recipe"
251
252 schema = ICharmRecipeEditSchema
253
254 custom_widget_git_ref = GitRefWidget
255 custom_widget_auto_build_channels = CharmRecipeBuildChannelsWidget
256 custom_widget_store_channels = StoreChannelsWidget
257
258 @property
259 def field_names(self):
260 fields = ["owner", "name"]
261 if self.is_project_context:
262 fields += ["git_ref"]
263 else:
264 fields += ["project"]
265 return fields + [
266 "auto_build",
267 "auto_build_channels",
268 "store_upload",
269 "store_name",
270 "store_channels",
271 ]
272
273 @property
274 def is_project_context(self):
275 return IProduct.providedBy(self.context)
276
277 @property
278 def cancel_url(self):
279 return canonical_url(self.context)
280
281 @property
282 def initial_values(self):
283 initial_values = {"owner": self.user}
284 if (IGitRef.providedBy(self.context) and
285 IProduct.providedBy(self.context.target)):
286 initial_values["project"] = self.context.target
287 return initial_values
288
289 def validate_widgets(self, data, names=None):
290 """See `LaunchpadFormView`."""
291 if self.widgets.get("store_upload") is not None:
292 # Set widgets as required or optional depending on the
293 # store_upload field.
294 super(CharmRecipeAddView, self).validate_widgets(
295 data, ["store_upload"])
296 store_upload = data.get("store_upload", False)
297 self.widgets["store_name"].context.required = store_upload
298 self.widgets["store_channels"].context.required = store_upload
299 super(CharmRecipeAddView, self).validate_widgets(data, names=names)
300
301 @action("Create charm recipe", name="create")
302 def create_action(self, action, data):
303 if IGitRef.providedBy(self.context):
304 project = data["project"]
305 git_ref = self.context
306 elif self.is_project_context:
307 project = self.context
308 git_ref = data["git_ref"]
309 else:
310 raise NotImplementedError(
311 "Unknown context for charm recipe creation.")
312 recipe = getUtility(ICharmRecipeSet).new(
313 self.user, data["owner"], project, data["name"], git_ref=git_ref,
314 auto_build=data["auto_build"],
315 auto_build_channels=data["auto_build_channels"],
316 store_upload=data["store_upload"],
317 store_name=data["store_name"],
318 store_channels=data.get("store_channels"))
319 self.next_url = canonical_url(recipe)
320
321 def validate(self, data):
322 super(CharmRecipeAddView, self).validate(data)
323 owner = data.get("owner", None)
324 if self.is_project_context:
325 project = self.context
326 else:
327 project = data.get("project", None)
328 name = data.get("name", None)
329 if owner and project and name:
330 if getUtility(ICharmRecipeSet).exists(owner, project, name):
331 self.setFieldError(
332 "name",
333 "There is already a charm recipe owned by %s in %s with "
334 "this name." % (owner.display_name, project.display_name))
335
336
243class BaseCharmRecipeEditView(LaunchpadEditFormView):337class BaseCharmRecipeEditView(LaunchpadEditFormView):
244338
245 schema = ICharmRecipeEditSchema339 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 @@
50 factory="lp.charms.browser.charmrecipe.CharmRecipeBreadcrumb"50 factory="lp.charms.browser.charmrecipe.CharmRecipeBreadcrumb"
51 permission="zope.Public" />51 permission="zope.Public" />
5252
53 <browser:page
54 for="lp.code.interfaces.gitref.IGitRef"
55 class="lp.charms.browser.charmrecipe.CharmRecipeAddView"
56 permission="launchpad.AnyPerson"
57 name="+new-charm-recipe"
58 template="../templates/charmrecipe-new.pt" />
59 <browser:page
60 for="lp.registry.interfaces.product.IProduct"
61 class="lp.charms.browser.charmrecipe.CharmRecipeAddView"
62 permission="launchpad.AnyPerson"
63 name="+new-charm-recipe"
64 template="../templates/charmrecipe-new.pt" />
65
53 <browser:url66 <browser:url
54 for="lp.charms.interfaces.charmrecipe.ICharmRecipeBuildRequest"67 for="lp.charms.interfaces.charmrecipe.ICharmRecipeBuildRequest"
55 path_expression="string:+build-request/${id}"68 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__ = [
1313
14from zope.component import getUtility14from zope.component import getUtility
1515
16from lp.charms.interfaces.charmrecipe import ICharmRecipeSet16from lp.charms.interfaces.charmrecipe import (
17 CHARM_RECIPE_ALLOW_CREATE,
18 CHARM_RECIPE_PRIVATE_FEATURE_FLAG,
19 ICharmRecipeSet,
20 )
17from lp.code.interfaces.gitrepository import IGitRepository21from lp.code.interfaces.gitrepository import IGitRepository
22from lp.services.features import getFeatureFlag
18from lp.services.webapp import (23from lp.services.webapp import (
19 canonical_url,24 canonical_url,
20 Link,25 Link,
@@ -31,6 +36,17 @@ class HasCharmRecipesMenuMixin:
31 self.context, visible_by_user=self.user).is_empty()36 self.context, visible_by_user=self.user).is_empty()
32 return Link("+charm-recipes", text, icon="info", enabled=enabled)37 return Link("+charm-recipes", text, icon="info", enabled=enabled)
3338
39 def create_charm_recipe(self):
40 # Only enabled for private contexts if the
41 # charm.recipe.allow_private flag is enabled.
42 enabled = (
43 bool(getFeatureFlag(CHARM_RECIPE_ALLOW_CREATE)) and (
44 not self.context.private or
45 bool(getFeatureFlag(CHARM_RECIPE_PRIVATE_FEATURE_FLAG))))
46
47 text = "Create charm recipe"
48 return Link("+new-charm-recipe", text, enabled=enabled, icon="add")
49
3450
35class HasCharmRecipesViewMixin:51class HasCharmRecipesViewMixin:
36 """A view mixin for objects that have charm recipes."""52 """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 (
66 extract_text,66 extract_text,
67 find_main_content,67 find_main_content,
68 find_tags_by_class,68 find_tags_by_class,
69 find_tag_by_id,
69 )70 )
70from lp.testing.publication import test_traverse71from lp.testing.publication import test_traverse
71from lp.testing.views import (72from lp.testing.views import (
@@ -111,6 +112,155 @@ class BaseTestCharmRecipeView(BrowserTestCase):
111 name="test-person", displayname="Test Person")112 name="test-person", displayname="Test Person")
112113
113114
115class TestCharmRecipeAddView(BaseTestCharmRecipeView):
116
117 def test_create_new_recipe_not_logged_in(self):
118 [git_ref] = self.factory.makeGitRefs()
119 self.assertRaises(
120 Unauthorized, self.getViewBrowser, git_ref,
121 view_name="+new-charm-recipe", no_login=True)
122
123 def test_create_new_recipe_git(self):
124 self.factory.makeProduct(
125 name="test-project", displayname="Test Project")
126 [git_ref] = self.factory.makeGitRefs(
127 owner=self.person, target=self.person)
128 source_display = git_ref.display_name
129 browser = self.getViewBrowser(
130 git_ref, view_name="+new-charm-recipe", user=self.person)
131 browser.getControl(name="field.name").value = "charm-name"
132 self.assertEqual("", browser.getControl(name="field.project").value)
133 browser.getControl(name="field.project").value = "test-project"
134 browser.getControl("Create charm recipe").click()
135
136 content = find_main_content(browser.contents)
137 self.assertEqual("charm-name", extract_text(content.h1))
138 self.assertThat(
139 "Test Person", MatchesPickerText(content, "edit-owner"))
140 self.assertThat(
141 "Project:\nTest Project\nEdit charm recipe",
142 MatchesTagText(content, "project"))
143 self.assertThat(
144 "Source:\n%s\nEdit charm recipe" % source_display,
145 MatchesTagText(content, "source"))
146 self.assertThat(
147 "Build schedule:\n(?)\nBuilt on request\nEdit charm recipe\n",
148 MatchesTagText(content, "auto_build"))
149 self.assertIsNone(find_tag_by_id(content, "auto_build_channels"))
150 self.assertThat(
151 "Builds of this charm recipe are not automatically uploaded to "
152 "the store.\nEdit charm recipe",
153 MatchesTagText(content, "store_upload"))
154
155 def test_create_new_recipe_git_project_namespace(self):
156 # If the Git repository is already in a project namespace, then that
157 # project is the default for the new recipe.
158 project = self.factory.makeProduct(
159 name="test-project", displayname="Test Project")
160 [git_ref] = self.factory.makeGitRefs(target=project)
161 source_display = git_ref.display_name
162 browser = self.getViewBrowser(
163 git_ref, view_name="+new-charm-recipe", user=self.person)
164 browser.getControl(name="field.name").value = "charm-name"
165 self.assertEqual(
166 "test-project", browser.getControl(name="field.project").value)
167 browser.getControl("Create charm recipe").click()
168
169 content = find_main_content(browser.contents)
170 self.assertEqual("charm-name", extract_text(content.h1))
171 self.assertThat(
172 "Test Person", MatchesPickerText(content, "edit-owner"))
173 self.assertThat(
174 "Project:\nTest Project\nEdit charm recipe",
175 MatchesTagText(content, "project"))
176 self.assertThat(
177 "Source:\n%s\nEdit charm recipe" % source_display,
178 MatchesTagText(content, "source"))
179 self.assertThat(
180 "Build schedule:\n(?)\nBuilt on request\nEdit charm recipe\n",
181 MatchesTagText(content, "auto_build"))
182 self.assertIsNone(find_tag_by_id(content, "auto_build_channels"))
183 self.assertThat(
184 "Builds of this charm recipe are not automatically uploaded to "
185 "the store.\nEdit charm recipe",
186 MatchesTagText(content, "store_upload"))
187
188 def test_create_new_recipe_project(self):
189 project = self.factory.makeProduct(displayname="Test Project")
190 [git_ref] = self.factory.makeGitRefs()
191 source_display = git_ref.display_name
192 browser = self.getViewBrowser(
193 project, view_name="+new-charm-recipe", user=self.person)
194 browser.getControl(name="field.name").value = "charm-name"
195 browser.getControl(name="field.git_ref.repository").value = (
196 git_ref.repository.shortened_path)
197 browser.getControl(name="field.git_ref.path").value = git_ref.path
198 browser.getControl("Create charm recipe").click()
199
200 content = find_main_content(browser.contents)
201 self.assertEqual("charm-name", extract_text(content.h1))
202 self.assertThat(
203 "Test Person", MatchesPickerText(content, "edit-owner"))
204 self.assertThat(
205 "Project:\nTest Project\nEdit charm recipe",
206 MatchesTagText(content, "project"))
207 self.assertThat(
208 "Source:\n%s\nEdit charm recipe" % source_display,
209 MatchesTagText(content, "source"))
210 self.assertThat(
211 "Build schedule:\n(?)\nBuilt on request\nEdit charm recipe\n",
212 MatchesTagText(content, "auto_build"))
213 self.assertIsNone(find_tag_by_id(content, "auto_build_channels"))
214 self.assertThat(
215 "Builds of this charm recipe are not automatically uploaded to "
216 "the store.\nEdit charm recipe",
217 MatchesTagText(content, "store_upload"))
218
219 def test_create_new_recipe_users_teams_as_owner_options(self):
220 # Teams that the user is in are options for the charm recipe owner.
221 self.factory.makeTeam(
222 name="test-team", displayname="Test Team", members=[self.person])
223 [git_ref] = self.factory.makeGitRefs()
224 browser = self.getViewBrowser(
225 git_ref, view_name="+new-charm-recipe", user=self.person)
226 options = browser.getControl("Owner").displayOptions
227 self.assertEqual(
228 ["Test Person (test-person)", "Test Team (test-team)"],
229 sorted(str(option) for option in options))
230
231 def test_create_new_recipe_auto_build(self):
232 # Creating a new recipe and asking for it to be automatically built
233 # sets all the appropriate fields.
234 self.factory.makeProduct(
235 name="test-project", displayname="Test Project")
236 [git_ref] = self.factory.makeGitRefs()
237 browser = self.getViewBrowser(
238 git_ref, view_name="+new-charm-recipe", user=self.person)
239 browser.getControl(name="field.name").value = "charm-name"
240 browser.getControl(name="field.project").value = "test-project"
241 browser.getControl(
242 "Automatically build when branch changes").selected = True
243 browser.getControl(
244 name="field.auto_build_channels.charmcraft").value = "edge"
245 browser.getControl(
246 name="field.auto_build_channels.core").value = "stable"
247 browser.getControl(
248 name="field.auto_build_channels.core18").value = "beta"
249 browser.getControl(
250 name="field.auto_build_channels.core20").value = "edge/feature"
251 browser.getControl("Create charm recipe").click()
252
253 content = find_main_content(browser.contents)
254 self.assertThat(
255 "Build schedule:\n(?)\nBuilt automatically\nEdit charm recipe\n",
256 MatchesTagText(content, "auto_build"))
257 self.assertThat(
258 "Source snap channels for automatic builds:\nEdit charm recipe\n"
259 "charmcraft\nedge\ncore\nstable\ncore18\nbeta\n"
260 "core20\nedge/feature\n",
261 MatchesTagText(content, "auto_build_channels"))
262
263
114class TestCharmRecipeAdminView(BaseTestCharmRecipeView):264class TestCharmRecipeAdminView(BaseTestCharmRecipeView):
115265
116 def test_unauthorized(self):266 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
77
8__metaclass__ = type8__metaclass__ = type
99
10import soupmatchers
10from testscenarios import (11from testscenarios import (
11 load_tests_apply_scenarios,12 load_tests_apply_scenarios,
12 WithScenarios,13 WithScenarios,
13 )14 )
15from testtools.matchers import Not
1416
15from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE17from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
16from lp.code.interfaces.gitrepository import IGitRepository18from lp.code.interfaces.gitrepository import IGitRepository
19from lp.code.tests.helpers import GitHostingFixture
17from lp.services.features.testing import FeatureFixture20from lp.services.features.testing import FeatureFixture
18from lp.services.webapp import canonical_url21from lp.services.webapp import canonical_url
19from lp.testing import TestCaseWithFactory22from lp.testing import TestCaseWithFactory
@@ -83,4 +86,57 @@ class TestHasCharmRecipesView(WithScenarios, TestCaseWithFactory):
83 self.assertEqual(expected_link, view.charm_recipes_link)86 self.assertEqual(expected_link, view.charm_recipes_link)
8487
8588
89class TestHasCharmRecipesMenu(WithScenarios, TestCaseWithFactory):
90
91 layer = DatabaseFunctionalLayer
92
93 scenarios = [
94 ("GitRef", {"context_factory": make_git_ref}),
95 ]
96
97 def setUp(self):
98 super(TestHasCharmRecipesMenu, self).setUp()
99 self.useFixture(GitHostingFixture())
100
101 def makeCharmRecipe(self, context):
102 return self.factory.makeCharmRecipe(git_ref=context)
103
104 def test_feature_flag_disabled(self):
105 # If the feature flag to allow charm recipe creation is disabled, we
106 # don't show a creation link.
107 context = self.context_factory(self)
108 view = create_initialized_view(context, "+index")
109 new_charm_recipe_url = canonical_url(
110 context, view_name="+new-charm-recipe")
111 self.assertThat(view(), Not(soupmatchers.HTMLContains(
112 soupmatchers.Tag(
113 "creation link", "a", attrs={"href": new_charm_recipe_url},
114 text="Create charm recipe"))))
115
116 def test_creation_link_no_recipes(self):
117 # An object with no charm recipes shows a creation link.
118 self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
119 context = self.context_factory(self)
120 view = create_initialized_view(context, "+index")
121 new_charm_recipe_url = canonical_url(
122 context, view_name="+new-charm-recipe")
123 self.assertThat(view(), soupmatchers.HTMLContains(
124 soupmatchers.Tag(
125 "creation link", "a", attrs={"href": new_charm_recipe_url},
126 text="Create charm recipe")))
127
128 def test_creation_link_recipes(self):
129 # An object with charm recipes shows a creation link.
130 self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
131 context = self.context_factory(self)
132 self.makeCharmRecipe(context)
133 view = create_initialized_view(context, "+index")
134 new_charm_recipe_url = canonical_url(
135 context, view_name="+new-charm-recipe")
136 self.assertThat(view(), soupmatchers.HTMLContains(
137 soupmatchers.Tag(
138 "creation link", "a", attrs={"href": new_charm_recipe_url},
139 text="Create charm recipe")))
140
141
86load_tests = load_tests_apply_scenarios142load_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):
3434
35 template = ViewPageTemplateFile("templates/charmrecipebuildchannels.pt")35 template = ViewPageTemplateFile("templates/charmrecipebuildchannels.pt")
36 hint = False36 hint = False
37 snap_names = ["core", "core18", "core20", "charmcraft"]37 snap_names = ["charmcraft", "core", "core18", "core20"]
38 _widgets_set_up = False38 _widgets_set_up = False
3939
40 def __init__(self, context, request):40 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 @@
44
5<table class="subordinate">5<table class="subordinate">
6 <tr>6 <tr>
7 <td>charmcraft</td>
8 <td><div tal:content="structure view/charmcraft_widget" /></td>
9 </tr>
10 <tr>
7 <td>core</td>11 <td>core</td>
8 <td><div tal:content="structure view/core_widget" /></td>12 <td><div tal:content="structure view/core_widget" /></td>
9 </tr>13 </tr>
@@ -15,10 +19,6 @@
15 <td>core20</td>19 <td>core20</td>
16 <td><div tal:content="structure view/core20_widget" /></td>20 <td><div tal:content="structure view/core20_widget" /></td>
17 </tr>21 </tr>
18 <tr>
19 <td>charmcraft</td>
20 <td><div tal:content="structure view/charmcraft_widget" /></td>
21 </tr>
22</table>22</table>
2323
24</tal:root>24</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):
62 # The subwidgets are set up and a flag is set.62 # The subwidgets are set up and a flag is set.
63 self.widget.setUpSubWidgets()63 self.widget.setUpSubWidgets()
64 self.assertTrue(self.widget._widgets_set_up)64 self.assertTrue(self.widget._widgets_set_up)
65 self.assertIsNotNone(getattr(self.widget, "charmcraft_widget", None))
65 self.assertIsNotNone(getattr(self.widget, "core_widget", None))66 self.assertIsNotNone(getattr(self.widget, "core_widget", None))
66 self.assertIsNotNone(getattr(self.widget, "core18_widget", None))67 self.assertIsNotNone(getattr(self.widget, "core18_widget", None))
67 self.assertIsNotNone(getattr(self.widget, "core20_widget", None))68 self.assertIsNotNone(getattr(self.widget, "core20_widget", None))
68 self.assertIsNotNone(getattr(self.widget, "charmcraft_widget", None))
6969
70 def test_setUpSubWidgets_second_call(self):70 def test_setUpSubWidgets_second_call(self):
71 # The setUpSubWidgets method exits early if a flag is set to71 # The setUpSubWidgets method exits early if a flag is set to
72 # indicate that the widgets were set up.72 # indicate that the widgets were set up.
73 self.widget._widgets_set_up = True73 self.widget._widgets_set_up = True
74 self.widget.setUpSubWidgets()74 self.widget.setUpSubWidgets()
75 self.assertIsNone(getattr(self.widget, "charmcraft_widget", None))
75 self.assertIsNone(getattr(self.widget, "core_widget", None))76 self.assertIsNone(getattr(self.widget, "core_widget", None))
76 self.assertIsNone(getattr(self.widget, "core18_widget", None))77 self.assertIsNone(getattr(self.widget, "core18_widget", None))
77 self.assertIsNone(getattr(self.widget, "core20_widget", None))78 self.assertIsNone(getattr(self.widget, "core20_widget", None))
78 self.assertIsNone(getattr(self.widget, "charmcraft_widget", None))
7979
80 def test_setRenderedValue_None(self):80 def test_setRenderedValue_None(self):
81 self.widget.setRenderedValue(None)81 self.widget.setRenderedValue(None)
82 self.assertIsNone(self.widget.charmcraft_widget._getCurrentValue())
82 self.assertIsNone(self.widget.core_widget._getCurrentValue())83 self.assertIsNone(self.widget.core_widget._getCurrentValue())
83 self.assertIsNone(self.widget.core18_widget._getCurrentValue())84 self.assertIsNone(self.widget.core18_widget._getCurrentValue())
84 self.assertIsNone(self.widget.core20_widget._getCurrentValue())85 self.assertIsNone(self.widget.core20_widget._getCurrentValue())
85 self.assertIsNone(self.widget.charmcraft_widget._getCurrentValue())
8686
87 def test_setRenderedValue_empty(self):87 def test_setRenderedValue_empty(self):
88 self.widget.setRenderedValue({})88 self.widget.setRenderedValue({})
89 self.assertIsNone(self.widget.charmcraft_widget._getCurrentValue())
89 self.assertIsNone(self.widget.core_widget._getCurrentValue())90 self.assertIsNone(self.widget.core_widget._getCurrentValue())
90 self.assertIsNone(self.widget.core18_widget._getCurrentValue())91 self.assertIsNone(self.widget.core18_widget._getCurrentValue())
91 self.assertIsNone(self.widget.core20_widget._getCurrentValue())92 self.assertIsNone(self.widget.core20_widget._getCurrentValue())
92 self.assertIsNone(self.widget.charmcraft_widget._getCurrentValue())
9393
94 def test_setRenderedValue_one_channel(self):94 def test_setRenderedValue_one_channel(self):
95 self.widget.setRenderedValue({"charmcraft": "stable"})95 self.widget.setRenderedValue({"charmcraft": "stable"})
96 self.assertEqual(
97 "stable", self.widget.charmcraft_widget._getCurrentValue())
96 self.assertIsNone(self.widget.core_widget._getCurrentValue())98 self.assertIsNone(self.widget.core_widget._getCurrentValue())
97 self.assertIsNone(self.widget.core18_widget._getCurrentValue())99 self.assertIsNone(self.widget.core18_widget._getCurrentValue())
98 self.assertIsNone(self.widget.core20_widget._getCurrentValue())100 self.assertIsNone(self.widget.core20_widget._getCurrentValue())
99 self.assertEqual(
100 "stable", self.widget.charmcraft_widget._getCurrentValue())
101101
102 def test_setRenderedValue_all_channels(self):102 def test_setRenderedValue_all_channels(self):
103 self.widget.setRenderedValue(103 self.widget.setRenderedValue(
104 {"core": "candidate", "core18": "beta", "core20": "edge",104 {"charmcraft": "stable", "core": "candidate", "core18": "beta",
105 "charmcraft": "stable"})105 "core20": "edge"})
106 self.assertEqual(
107 "stable", self.widget.charmcraft_widget._getCurrentValue())
106 self.assertEqual(108 self.assertEqual(
107 "candidate", self.widget.core_widget._getCurrentValue())109 "candidate", self.widget.core_widget._getCurrentValue())
108 self.assertEqual("beta", self.widget.core18_widget._getCurrentValue())110 self.assertEqual("beta", self.widget.core18_widget._getCurrentValue())
109 self.assertEqual("edge", self.widget.core20_widget._getCurrentValue())111 self.assertEqual("edge", self.widget.core20_widget._getCurrentValue())
110 self.assertEqual(
111 "stable", self.widget.charmcraft_widget._getCurrentValue())
112112
113 def test_hasInput_false(self):113 def test_hasInput_false(self):
114 # hasInput is false when there are no channels in the form data.114 # hasInput is false when there are no channels in the form data.
@@ -126,41 +126,40 @@ class TestCharmRecipeBuildChannelsWidget(TestCaseWithFactory):
126 # (At the moment, individual channel names are not validated, so126 # (At the moment, individual channel names are not validated, so
127 # there is no "false" counterpart to this test.)127 # there is no "false" counterpart to this test.)
128 form = {128 form = {
129 "field.auto_build_channels.charmcraft": "stable",
129 "field.auto_build_channels.core": "",130 "field.auto_build_channels.core": "",
130 "field.auto_build_channels.core18": "beta",131 "field.auto_build_channels.core18": "beta",
131 "field.auto_build_channels.core20": "edge",132 "field.auto_build_channels.core20": "edge",
132 "field.auto_build_channels.charmcraft": "stable",
133 }133 }
134 self.widget.request = LaunchpadTestRequest(form=form)134 self.widget.request = LaunchpadTestRequest(form=form)
135 self.assertTrue(self.widget.hasValidInput())135 self.assertTrue(self.widget.hasValidInput())
136136
137 def test_getInputValue(self):137 def test_getInputValue(self):
138 form = {138 form = {
139 "field.auto_build_channels.charmcraft": "stable",
139 "field.auto_build_channels.core": "",140 "field.auto_build_channels.core": "",
140 "field.auto_build_channels.core18": "beta",141 "field.auto_build_channels.core18": "beta",
141 "field.auto_build_channels.core20": "edge",142 "field.auto_build_channels.core20": "edge",
142 "field.auto_build_channels.charmcraft": "stable",
143 }143 }
144 self.widget.request = LaunchpadTestRequest(form=form)144 self.widget.request = LaunchpadTestRequest(form=form)
145 self.assertEqual(145 self.assertEqual(
146 {"core18": "beta", "core20": "edge",146 {"charmcraft": "stable", "core18": "beta", "core20": "edge"},
147 "charmcraft": "stable"},
148 self.widget.getInputValue())147 self.widget.getInputValue())
149148
150 def test_call(self):149 def test_call(self):
151 # The __call__ method sets up the widgets.150 # The __call__ method sets up the widgets.
152 markup = self.widget()151 markup = self.widget()
152 self.assertIsNotNone(self.widget.charmcraft_widget)
153 self.assertIsNotNone(self.widget.core_widget)153 self.assertIsNotNone(self.widget.core_widget)
154 self.assertIsNotNone(self.widget.core18_widget)154 self.assertIsNotNone(self.widget.core18_widget)
155 self.assertIsNotNone(self.widget.core20_widget)155 self.assertIsNotNone(self.widget.core20_widget)
156 self.assertIsNotNone(self.widget.charmcraft_widget)
157 soup = BeautifulSoup(markup)156 soup = BeautifulSoup(markup)
158 fields = soup.find_all(["input"], {"id": re.compile(".*")})157 fields = soup.find_all(["input"], {"id": re.compile(".*")})
159 expected_ids = [158 expected_ids = [
159 "field.auto_build_channels.charmcraft",
160 "field.auto_build_channels.core",160 "field.auto_build_channels.core",
161 "field.auto_build_channels.core18",161 "field.auto_build_channels.core18",
162 "field.auto_build_channels.core20",162 "field.auto_build_channels.core20",
163 "field.auto_build_channels.charmcraft",
164 ]163 ]
165 ids = [field["id"] for field in fields]164 ids = [field["id"] for field in fields]
166 self.assertContentEqual(expected_ids, ids)165 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):
445 key_type=TextLine(), required=False, readonly=False,445 key_type=TextLine(), required=False, readonly=False,
446 description=_(446 description=_(
447 "A dictionary mapping snap names to channels to use when building "447 "A dictionary mapping snap names to channels to use when building "
448 "this charm recipe. Currently only 'core', 'core18', 'core20', "448 "this charm recipe. Currently only 'charmcraft', 'core', "
449 "and 'charmcraft' keys are supported."))449 "'core18', and 'core20' keys are supported."))
450450
451 is_stale = Bool(451 is_stale = Bool(
452 title=_("Charm recipe is stale and is due to be rebuilt."),452 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):
65 title=_("Source snap channels to use for this build."),65 title=_("Source snap channels to use for this build."),
66 description=_(66 description=_(
67 "A dictionary mapping snap names to channels to use for this "67 "A dictionary mapping snap names to channels to use for this "
68 "build. Currently only 'core', 'core18', 'core20', "68 "build. Currently only 'charmcraft', 'core', 'core18', and "
69 "and 'charmcraft' keys are supported."),69 "'core20' keys are supported."),
70 key_type=TextLine())70 key_type=TextLine())
7171
72 virtualized = Bool(72 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):
64 title=_("Source snap channels to use for these builds."),64 title=_("Source snap channels to use for these builds."),
65 description=_(65 description=_(
66 "A dictionary mapping snap names to channels to use for these "66 "A dictionary mapping snap names to channels to use for these "
67 "builds. Currently only 'core', 'core18', 'core20', and "67 "builds. Currently only 'charmcraft', 'core', 'core18', and "
68 "'charmcraft' keys are supported."),68 "'core20' keys are supported."),
69 key_type=TextLine(), required=False, readonly=True)69 key_type=TextLine(), required=False, readonly=True)
7070
71 architectures = Set(71 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 @@
17 </div>17 </div>
18 </div>18 </div>
1919
20 <span
21 tal:define="link context_menu/create_charm_recipe|nothing"
22 tal:condition="python: link and link.enabled"
23 tal:replace="structure link/render"
24 />
25
20</div>26</div>
2127
22</tal:root>28</tal:root>
diff --git a/lib/lp/charms/templates/charmrecipe-new.pt b/lib/lp/charms/templates/charmrecipe-new.pt
23new file mode 10064429new file mode 100644
index 0000000..bde870d
--- /dev/null
+++ b/lib/lp/charms/templates/charmrecipe-new.pt
@@ -0,0 +1,87 @@
1<html
2 xmlns="http://www.w3.org/1999/xhtml"
3 xmlns:tal="http://xml.zope.org/namespaces/tal"
4 xmlns:metal="http://xml.zope.org/namespaces/metal"
5 xmlns:i18n="http://xml.zope.org/namespaces/i18n"
6 metal:use-macro="view/macro:page/main_side"
7 i18n:domain="launchpad">
8<body>
9
10<div metal:fill-slot="main">
11 <div>
12 <p>
13 A <a href="https://juju.is/docs">charmed operator</a> (packaged as a
14 "charm") encapsulates a single application and all the code and
15 know-how it takes to operate it, such as how to combine and work with
16 other related applications or how to upgrade it. Launchpad can build
17 charms using <a href="https://juju.is/docs/sdk">charmcraft</a>, from
18 any Git branch on Launchpad that has a <tt>charmcraft.yaml</tt> file.
19 </p>
20 </div>
21
22 <div metal:use-macro="context/@@launchpad_form/form">
23 <metal:formbody fill-slot="widgets">
24 <table class="form">
25 <tal:widget define="widget nocall:view/widgets/name">
26 <metal:block use-macro="context/@@launchpad_form/widget_row" />
27 </tal:widget>
28 <tal:widget define="widget nocall:view/widgets/owner">
29 <metal:block use-macro="context/@@launchpad_form/widget_row" />
30 </tal:widget>
31
32 <tal:not-project-context condition="not: view/is_project_context">
33 <tal:widget define="widget nocall:view/widgets/project">
34 <metal:block use-macro="context/@@launchpad_form/widget_row" />
35 </tal:widget>
36 </tal:not-project-context>
37
38 <tal:project-context condition="view/is_project_context">
39 <tal:widget define="widget nocall:view/widgets/git_ref">
40 <metal:block use-macro="context/@@launchpad_form/widget_row" />
41 </tal:widget>
42 </tal:project-context>
43
44 <tal:widget define="widget nocall:view/widgets/auto_build">
45 <metal:block use-macro="context/@@launchpad_form/widget_row" />
46 </tal:widget>
47 <tr>
48 <td>
49 <table class="subordinate">
50 <tal:widget define="widget nocall:view/widgets/auto_build_channels">
51 <metal:block use-macro="context/@@launchpad_form/widget_row" />
52 </tal:widget>
53 </table>
54 </td>
55 </tr>
56
57 <tal:widget define="widget nocall:view/widgets/store_upload">
58 <metal:block use-macro="context/@@launchpad_form/widget_row" />
59 </tal:widget>
60 <tr>
61 <td>
62 <table class="subordinate">
63 <tal:widget define="widget nocall:view/widgets/store_name">
64 <metal:block use-macro="context/@@launchpad_form/widget_row" />
65 </tal:widget>
66 <tal:widget define="widget nocall:view/widgets/store_channels"
67 condition="widget/has_risks_vocabulary">
68 <metal:block use-macro="context/@@launchpad_form/widget_row" />
69 </tal:widget>
70 </table>
71 </td>
72 </tr>
73 </table>
74 </metal:formbody>
75 </div>
76
77 <script type="text/javascript">
78 LPJS.use('lp.charms.charmrecipe.edit', function(Y) {
79 Y.on('domready', function(e) {
80 Y.lp.charms.charmrecipe.edit.setup();
81 }, window);
82 });
83 </script>
84</div>
85
86</body>
87</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(
84 facet = 'branches'84 facet = 'branches'
85 links = [85 links = [
86 'browse_commits',86 'browse_commits',
87 'create_charm_recipe',
87 'create_recipe',88 'create_recipe',
88 'create_snap',89 'create_snap',
89 'register_merge',90 '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,
588 'view_charm_recipes',588 'view_charm_recipes',
589 'view_recipes',589 'view_recipes',
590 'view_snaps',590 'view_snaps',
591 'create_charm_recipe',
591 'create_snap',592 'create_snap',
592 ]593 ]
593594
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 @@
170 </p>170 </p>
171171
172 <ul class="horizontal" id="project-link-info">172 <ul class="horizontal" id="project-link-info">
173 <li tal:condition="overview_menu/series_add/enabled">173 <li class="nowrap"
174 tal:condition="overview_menu/series_add/enabled">
174 <a tal:replace="structure overview_menu/series_add/fmt:link" />175 <a tal:replace="structure overview_menu/series_add/fmt:link" />
175 </li>176 </li>
176 <li>177 <li class="nowrap">
177 <a tal:replace="structure overview_menu/milestones/fmt:link" />178 <a tal:replace="structure overview_menu/milestones/fmt:link" />
178 </li>179 </li>
179 <li tal:define="link context/menu:overview/view_recipes"180 <li class="nowrap"
181 tal:define="link context/menu:overview/view_recipes"
180 tal:condition="link/enabled">182 tal:condition="link/enabled">
181 <a tal:replace="structure link/fmt:link" />183 <a tal:replace="structure link/fmt:link" />
182 </li>184 </li>
183 <li tal:define="link context/menu:overview/view_snaps"185 <li class="nowrap"
186 tal:define="link context/menu:overview/view_snaps"
184 tal:condition="link/enabled">187 tal:condition="link/enabled">
185 <a tal:replace="structure link/fmt:link" />188 <a tal:replace="structure link/fmt:link" />
186 </li>189 </li>
187 <li tal:define="link context/menu:overview/create_snap"190 <li class="nowrap"
191 tal:define="link context/menu:overview/create_snap"
188 tal:condition="link/enabled">192 tal:condition="link/enabled">
189 <a tal:replace="structure link/fmt:link" />193 <a tal:replace="structure link/fmt:link" />
190 </li>194 </li>
191 <li tal:define="link context/menu:overview/view_charm_recipes"195 <li class="nowrap"
196 tal:define="link context/menu:overview/view_charm_recipes"
197 tal:condition="link/enabled">
198 <a tal:replace="structure link/fmt:link" />
199 </li>
200 <li class="nowrap"
201 tal:define="link context/menu:overview/create_charm_recipe"
192 tal:condition="link/enabled">202 tal:condition="link/enabled">
193 <a tal:replace="structure link/fmt:link" />203 <a tal:replace="structure link/fmt:link" />
194 </li>204 </li>

Subscribers

People subscribed via source and target branches

to status/vote changes: