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
1diff --git a/lib/lp/charms/browser/charmrecipe.py b/lib/lp/charms/browser/charmrecipe.py
2index 6511424..2345ec1 100644
3--- a/lib/lp/charms/browser/charmrecipe.py
4+++ b/lib/lp/charms/browser/charmrecipe.py
5@@ -7,6 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals
6
7 __metaclass__ = type
8 __all__ = [
9+ "CharmRecipeAddView",
10 "CharmRecipeAdminView",
11 "CharmRecipeDeleteView",
12 "CharmRecipeEditView",
13@@ -30,6 +31,7 @@ from zope.security.interfaces import Unauthorized
14 from lp.app.browser.launchpadform import (
15 action,
16 LaunchpadEditFormView,
17+ LaunchpadFormView,
18 )
19 from lp.app.browser.lazrjs import InlinePersonEditPickerWidget
20 from lp.app.browser.tales import format_link
21@@ -43,7 +45,9 @@ from lp.charms.interfaces.charmrecipe import (
22 )
23 from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuildSet
24 from lp.code.browser.widgets.gitref import GitRefWidget
25+from lp.code.interfaces.gitref import IGitRef
26 from lp.registry.interfaces.personproduct import IPersonProductFactory
27+from lp.registry.interfaces.product import IProduct
28 from lp.services.propertycache import cachedproperty
29 from lp.services.utils import seconds_since_epoch
30 from lp.services.webapp import (
31@@ -240,6 +244,96 @@ class ICharmRecipeEditSchema(Interface):
32 store_channels = copy_field(ICharmRecipe["store_channels"], required=True)
33
34
35+class CharmRecipeAddView(LaunchpadFormView):
36+ """View for creating charm recipes."""
37+
38+ page_title = label = "Create a new charm recipe"
39+
40+ schema = ICharmRecipeEditSchema
41+
42+ custom_widget_git_ref = GitRefWidget
43+ custom_widget_auto_build_channels = CharmRecipeBuildChannelsWidget
44+ custom_widget_store_channels = StoreChannelsWidget
45+
46+ @property
47+ def field_names(self):
48+ fields = ["owner", "name"]
49+ if self.is_project_context:
50+ fields += ["git_ref"]
51+ else:
52+ fields += ["project"]
53+ return fields + [
54+ "auto_build",
55+ "auto_build_channels",
56+ "store_upload",
57+ "store_name",
58+ "store_channels",
59+ ]
60+
61+ @property
62+ def is_project_context(self):
63+ return IProduct.providedBy(self.context)
64+
65+ @property
66+ def cancel_url(self):
67+ return canonical_url(self.context)
68+
69+ @property
70+ def initial_values(self):
71+ initial_values = {"owner": self.user}
72+ if (IGitRef.providedBy(self.context) and
73+ IProduct.providedBy(self.context.target)):
74+ initial_values["project"] = self.context.target
75+ return initial_values
76+
77+ def validate_widgets(self, data, names=None):
78+ """See `LaunchpadFormView`."""
79+ if self.widgets.get("store_upload") is not None:
80+ # Set widgets as required or optional depending on the
81+ # store_upload field.
82+ super(CharmRecipeAddView, self).validate_widgets(
83+ data, ["store_upload"])
84+ store_upload = data.get("store_upload", False)
85+ self.widgets["store_name"].context.required = store_upload
86+ self.widgets["store_channels"].context.required = store_upload
87+ super(CharmRecipeAddView, self).validate_widgets(data, names=names)
88+
89+ @action("Create charm recipe", name="create")
90+ def create_action(self, action, data):
91+ if IGitRef.providedBy(self.context):
92+ project = data["project"]
93+ git_ref = self.context
94+ elif self.is_project_context:
95+ project = self.context
96+ git_ref = data["git_ref"]
97+ else:
98+ raise NotImplementedError(
99+ "Unknown context for charm recipe creation.")
100+ recipe = getUtility(ICharmRecipeSet).new(
101+ self.user, data["owner"], project, data["name"], git_ref=git_ref,
102+ auto_build=data["auto_build"],
103+ auto_build_channels=data["auto_build_channels"],
104+ store_upload=data["store_upload"],
105+ store_name=data["store_name"],
106+ store_channels=data.get("store_channels"))
107+ self.next_url = canonical_url(recipe)
108+
109+ def validate(self, data):
110+ super(CharmRecipeAddView, self).validate(data)
111+ owner = data.get("owner", None)
112+ if self.is_project_context:
113+ project = self.context
114+ else:
115+ project = data.get("project", None)
116+ name = data.get("name", None)
117+ if owner and project and name:
118+ if getUtility(ICharmRecipeSet).exists(owner, project, name):
119+ self.setFieldError(
120+ "name",
121+ "There is already a charm recipe owned by %s in %s with "
122+ "this name." % (owner.display_name, project.display_name))
123+
124+
125 class BaseCharmRecipeEditView(LaunchpadEditFormView):
126
127 schema = ICharmRecipeEditSchema
128diff --git a/lib/lp/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml
129index 60e3dc4..72be851 100644
130--- a/lib/lp/charms/browser/configure.zcml
131+++ b/lib/lp/charms/browser/configure.zcml
132@@ -50,6 +50,19 @@
133 factory="lp.charms.browser.charmrecipe.CharmRecipeBreadcrumb"
134 permission="zope.Public" />
135
136+ <browser:page
137+ for="lp.code.interfaces.gitref.IGitRef"
138+ class="lp.charms.browser.charmrecipe.CharmRecipeAddView"
139+ permission="launchpad.AnyPerson"
140+ name="+new-charm-recipe"
141+ template="../templates/charmrecipe-new.pt" />
142+ <browser:page
143+ for="lp.registry.interfaces.product.IProduct"
144+ class="lp.charms.browser.charmrecipe.CharmRecipeAddView"
145+ permission="launchpad.AnyPerson"
146+ name="+new-charm-recipe"
147+ template="../templates/charmrecipe-new.pt" />
148+
149 <browser:url
150 for="lp.charms.interfaces.charmrecipe.ICharmRecipeBuildRequest"
151 path_expression="string:+build-request/${id}"
152diff --git a/lib/lp/charms/browser/hascharmrecipes.py b/lib/lp/charms/browser/hascharmrecipes.py
153index 5c395b5..16f6f4c 100644
154--- a/lib/lp/charms/browser/hascharmrecipes.py
155+++ b/lib/lp/charms/browser/hascharmrecipes.py
156@@ -13,8 +13,13 @@ __all__ = [
157
158 from zope.component import getUtility
159
160-from lp.charms.interfaces.charmrecipe import ICharmRecipeSet
161+from lp.charms.interfaces.charmrecipe import (
162+ CHARM_RECIPE_ALLOW_CREATE,
163+ CHARM_RECIPE_PRIVATE_FEATURE_FLAG,
164+ ICharmRecipeSet,
165+ )
166 from lp.code.interfaces.gitrepository import IGitRepository
167+from lp.services.features import getFeatureFlag
168 from lp.services.webapp import (
169 canonical_url,
170 Link,
171@@ -31,6 +36,17 @@ class HasCharmRecipesMenuMixin:
172 self.context, visible_by_user=self.user).is_empty()
173 return Link("+charm-recipes", text, icon="info", enabled=enabled)
174
175+ def create_charm_recipe(self):
176+ # Only enabled for private contexts if the
177+ # charm.recipe.allow_private flag is enabled.
178+ enabled = (
179+ bool(getFeatureFlag(CHARM_RECIPE_ALLOW_CREATE)) and (
180+ not self.context.private or
181+ bool(getFeatureFlag(CHARM_RECIPE_PRIVATE_FEATURE_FLAG))))
182+
183+ text = "Create charm recipe"
184+ return Link("+new-charm-recipe", text, enabled=enabled, icon="add")
185+
186
187 class HasCharmRecipesViewMixin:
188 """A view mixin for objects that have charm recipes."""
189diff --git a/lib/lp/charms/browser/tests/test_charmrecipe.py b/lib/lp/charms/browser/tests/test_charmrecipe.py
190index fcc7f7b..c6130cb 100644
191--- a/lib/lp/charms/browser/tests/test_charmrecipe.py
192+++ b/lib/lp/charms/browser/tests/test_charmrecipe.py
193@@ -66,6 +66,7 @@ from lp.testing.pages import (
194 extract_text,
195 find_main_content,
196 find_tags_by_class,
197+ find_tag_by_id,
198 )
199 from lp.testing.publication import test_traverse
200 from lp.testing.views import (
201@@ -111,6 +112,155 @@ class BaseTestCharmRecipeView(BrowserTestCase):
202 name="test-person", displayname="Test Person")
203
204
205+class TestCharmRecipeAddView(BaseTestCharmRecipeView):
206+
207+ def test_create_new_recipe_not_logged_in(self):
208+ [git_ref] = self.factory.makeGitRefs()
209+ self.assertRaises(
210+ Unauthorized, self.getViewBrowser, git_ref,
211+ view_name="+new-charm-recipe", no_login=True)
212+
213+ def test_create_new_recipe_git(self):
214+ self.factory.makeProduct(
215+ name="test-project", displayname="Test Project")
216+ [git_ref] = self.factory.makeGitRefs(
217+ owner=self.person, target=self.person)
218+ source_display = git_ref.display_name
219+ browser = self.getViewBrowser(
220+ git_ref, view_name="+new-charm-recipe", user=self.person)
221+ browser.getControl(name="field.name").value = "charm-name"
222+ self.assertEqual("", browser.getControl(name="field.project").value)
223+ browser.getControl(name="field.project").value = "test-project"
224+ browser.getControl("Create charm recipe").click()
225+
226+ content = find_main_content(browser.contents)
227+ self.assertEqual("charm-name", extract_text(content.h1))
228+ self.assertThat(
229+ "Test Person", MatchesPickerText(content, "edit-owner"))
230+ self.assertThat(
231+ "Project:\nTest Project\nEdit charm recipe",
232+ MatchesTagText(content, "project"))
233+ self.assertThat(
234+ "Source:\n%s\nEdit charm recipe" % source_display,
235+ MatchesTagText(content, "source"))
236+ self.assertThat(
237+ "Build schedule:\n(?)\nBuilt on request\nEdit charm recipe\n",
238+ MatchesTagText(content, "auto_build"))
239+ self.assertIsNone(find_tag_by_id(content, "auto_build_channels"))
240+ self.assertThat(
241+ "Builds of this charm recipe are not automatically uploaded to "
242+ "the store.\nEdit charm recipe",
243+ MatchesTagText(content, "store_upload"))
244+
245+ def test_create_new_recipe_git_project_namespace(self):
246+ # If the Git repository is already in a project namespace, then that
247+ # project is the default for the new recipe.
248+ project = self.factory.makeProduct(
249+ name="test-project", displayname="Test Project")
250+ [git_ref] = self.factory.makeGitRefs(target=project)
251+ source_display = git_ref.display_name
252+ browser = self.getViewBrowser(
253+ git_ref, view_name="+new-charm-recipe", user=self.person)
254+ browser.getControl(name="field.name").value = "charm-name"
255+ self.assertEqual(
256+ "test-project", browser.getControl(name="field.project").value)
257+ browser.getControl("Create charm recipe").click()
258+
259+ content = find_main_content(browser.contents)
260+ self.assertEqual("charm-name", extract_text(content.h1))
261+ self.assertThat(
262+ "Test Person", MatchesPickerText(content, "edit-owner"))
263+ self.assertThat(
264+ "Project:\nTest Project\nEdit charm recipe",
265+ MatchesTagText(content, "project"))
266+ self.assertThat(
267+ "Source:\n%s\nEdit charm recipe" % source_display,
268+ MatchesTagText(content, "source"))
269+ self.assertThat(
270+ "Build schedule:\n(?)\nBuilt on request\nEdit charm recipe\n",
271+ MatchesTagText(content, "auto_build"))
272+ self.assertIsNone(find_tag_by_id(content, "auto_build_channels"))
273+ self.assertThat(
274+ "Builds of this charm recipe are not automatically uploaded to "
275+ "the store.\nEdit charm recipe",
276+ MatchesTagText(content, "store_upload"))
277+
278+ def test_create_new_recipe_project(self):
279+ project = self.factory.makeProduct(displayname="Test Project")
280+ [git_ref] = self.factory.makeGitRefs()
281+ source_display = git_ref.display_name
282+ browser = self.getViewBrowser(
283+ project, view_name="+new-charm-recipe", user=self.person)
284+ browser.getControl(name="field.name").value = "charm-name"
285+ browser.getControl(name="field.git_ref.repository").value = (
286+ git_ref.repository.shortened_path)
287+ browser.getControl(name="field.git_ref.path").value = git_ref.path
288+ browser.getControl("Create charm recipe").click()
289+
290+ content = find_main_content(browser.contents)
291+ self.assertEqual("charm-name", extract_text(content.h1))
292+ self.assertThat(
293+ "Test Person", MatchesPickerText(content, "edit-owner"))
294+ self.assertThat(
295+ "Project:\nTest Project\nEdit charm recipe",
296+ MatchesTagText(content, "project"))
297+ self.assertThat(
298+ "Source:\n%s\nEdit charm recipe" % source_display,
299+ MatchesTagText(content, "source"))
300+ self.assertThat(
301+ "Build schedule:\n(?)\nBuilt on request\nEdit charm recipe\n",
302+ MatchesTagText(content, "auto_build"))
303+ self.assertIsNone(find_tag_by_id(content, "auto_build_channels"))
304+ self.assertThat(
305+ "Builds of this charm recipe are not automatically uploaded to "
306+ "the store.\nEdit charm recipe",
307+ MatchesTagText(content, "store_upload"))
308+
309+ def test_create_new_recipe_users_teams_as_owner_options(self):
310+ # Teams that the user is in are options for the charm recipe owner.
311+ self.factory.makeTeam(
312+ name="test-team", displayname="Test Team", members=[self.person])
313+ [git_ref] = self.factory.makeGitRefs()
314+ browser = self.getViewBrowser(
315+ git_ref, view_name="+new-charm-recipe", user=self.person)
316+ options = browser.getControl("Owner").displayOptions
317+ self.assertEqual(
318+ ["Test Person (test-person)", "Test Team (test-team)"],
319+ sorted(str(option) for option in options))
320+
321+ def test_create_new_recipe_auto_build(self):
322+ # Creating a new recipe and asking for it to be automatically built
323+ # sets all the appropriate fields.
324+ self.factory.makeProduct(
325+ name="test-project", displayname="Test Project")
326+ [git_ref] = self.factory.makeGitRefs()
327+ browser = self.getViewBrowser(
328+ git_ref, view_name="+new-charm-recipe", user=self.person)
329+ browser.getControl(name="field.name").value = "charm-name"
330+ browser.getControl(name="field.project").value = "test-project"
331+ browser.getControl(
332+ "Automatically build when branch changes").selected = True
333+ browser.getControl(
334+ name="field.auto_build_channels.charmcraft").value = "edge"
335+ browser.getControl(
336+ name="field.auto_build_channels.core").value = "stable"
337+ browser.getControl(
338+ name="field.auto_build_channels.core18").value = "beta"
339+ browser.getControl(
340+ name="field.auto_build_channels.core20").value = "edge/feature"
341+ browser.getControl("Create charm recipe").click()
342+
343+ content = find_main_content(browser.contents)
344+ self.assertThat(
345+ "Build schedule:\n(?)\nBuilt automatically\nEdit charm recipe\n",
346+ MatchesTagText(content, "auto_build"))
347+ self.assertThat(
348+ "Source snap channels for automatic builds:\nEdit charm recipe\n"
349+ "charmcraft\nedge\ncore\nstable\ncore18\nbeta\n"
350+ "core20\nedge/feature\n",
351+ MatchesTagText(content, "auto_build_channels"))
352+
353+
354 class TestCharmRecipeAdminView(BaseTestCharmRecipeView):
355
356 def test_unauthorized(self):
357diff --git a/lib/lp/charms/browser/tests/test_hascharmrecipes.py b/lib/lp/charms/browser/tests/test_hascharmrecipes.py
358index 6374893..6cc7bf7 100644
359--- a/lib/lp/charms/browser/tests/test_hascharmrecipes.py
360+++ b/lib/lp/charms/browser/tests/test_hascharmrecipes.py
361@@ -7,13 +7,16 @@ from __future__ import absolute_import, print_function, unicode_literals
362
363 __metaclass__ = type
364
365+import soupmatchers
366 from testscenarios import (
367 load_tests_apply_scenarios,
368 WithScenarios,
369 )
370+from testtools.matchers import Not
371
372 from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
373 from lp.code.interfaces.gitrepository import IGitRepository
374+from lp.code.tests.helpers import GitHostingFixture
375 from lp.services.features.testing import FeatureFixture
376 from lp.services.webapp import canonical_url
377 from lp.testing import TestCaseWithFactory
378@@ -83,4 +86,57 @@ class TestHasCharmRecipesView(WithScenarios, TestCaseWithFactory):
379 self.assertEqual(expected_link, view.charm_recipes_link)
380
381
382+class TestHasCharmRecipesMenu(WithScenarios, TestCaseWithFactory):
383+
384+ layer = DatabaseFunctionalLayer
385+
386+ scenarios = [
387+ ("GitRef", {"context_factory": make_git_ref}),
388+ ]
389+
390+ def setUp(self):
391+ super(TestHasCharmRecipesMenu, self).setUp()
392+ self.useFixture(GitHostingFixture())
393+
394+ def makeCharmRecipe(self, context):
395+ return self.factory.makeCharmRecipe(git_ref=context)
396+
397+ def test_feature_flag_disabled(self):
398+ # If the feature flag to allow charm recipe creation is disabled, we
399+ # don't show a creation link.
400+ context = self.context_factory(self)
401+ view = create_initialized_view(context, "+index")
402+ new_charm_recipe_url = canonical_url(
403+ context, view_name="+new-charm-recipe")
404+ self.assertThat(view(), Not(soupmatchers.HTMLContains(
405+ soupmatchers.Tag(
406+ "creation link", "a", attrs={"href": new_charm_recipe_url},
407+ text="Create charm recipe"))))
408+
409+ def test_creation_link_no_recipes(self):
410+ # An object with no charm recipes shows a creation link.
411+ self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
412+ context = self.context_factory(self)
413+ view = create_initialized_view(context, "+index")
414+ new_charm_recipe_url = canonical_url(
415+ context, view_name="+new-charm-recipe")
416+ self.assertThat(view(), soupmatchers.HTMLContains(
417+ soupmatchers.Tag(
418+ "creation link", "a", attrs={"href": new_charm_recipe_url},
419+ text="Create charm recipe")))
420+
421+ def test_creation_link_recipes(self):
422+ # An object with charm recipes shows a creation link.
423+ self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
424+ context = self.context_factory(self)
425+ self.makeCharmRecipe(context)
426+ view = create_initialized_view(context, "+index")
427+ new_charm_recipe_url = canonical_url(
428+ context, view_name="+new-charm-recipe")
429+ self.assertThat(view(), soupmatchers.HTMLContains(
430+ soupmatchers.Tag(
431+ "creation link", "a", attrs={"href": new_charm_recipe_url},
432+ text="Create charm recipe")))
433+
434+
435 load_tests = load_tests_apply_scenarios
436diff --git a/lib/lp/charms/browser/widgets/charmrecipebuildchannels.py b/lib/lp/charms/browser/widgets/charmrecipebuildchannels.py
437index ef76294..b1512ed 100644
438--- a/lib/lp/charms/browser/widgets/charmrecipebuildchannels.py
439+++ b/lib/lp/charms/browser/widgets/charmrecipebuildchannels.py
440@@ -34,7 +34,7 @@ class CharmRecipeBuildChannelsWidget(BrowserWidget, InputWidget):
441
442 template = ViewPageTemplateFile("templates/charmrecipebuildchannels.pt")
443 hint = False
444- snap_names = ["core", "core18", "core20", "charmcraft"]
445+ snap_names = ["charmcraft", "core", "core18", "core20"]
446 _widgets_set_up = False
447
448 def __init__(self, context, request):
449diff --git a/lib/lp/charms/browser/widgets/templates/charmrecipebuildchannels.pt b/lib/lp/charms/browser/widgets/templates/charmrecipebuildchannels.pt
450index cf0052a..0430f4a 100644
451--- a/lib/lp/charms/browser/widgets/templates/charmrecipebuildchannels.pt
452+++ b/lib/lp/charms/browser/widgets/templates/charmrecipebuildchannels.pt
453@@ -4,6 +4,10 @@
454
455 <table class="subordinate">
456 <tr>
457+ <td>charmcraft</td>
458+ <td><div tal:content="structure view/charmcraft_widget" /></td>
459+ </tr>
460+ <tr>
461 <td>core</td>
462 <td><div tal:content="structure view/core_widget" /></td>
463 </tr>
464@@ -15,10 +19,6 @@
465 <td>core20</td>
466 <td><div tal:content="structure view/core20_widget" /></td>
467 </tr>
468- <tr>
469- <td>charmcraft</td>
470- <td><div tal:content="structure view/charmcraft_widget" /></td>
471- </tr>
472 </table>
473
474 </tal:root>
475diff --git a/lib/lp/charms/browser/widgets/tests/test_charmrecipebuildchannelswidget.py b/lib/lp/charms/browser/widgets/tests/test_charmrecipebuildchannelswidget.py
476index 6ab1907..67b2040 100644
477--- a/lib/lp/charms/browser/widgets/tests/test_charmrecipebuildchannelswidget.py
478+++ b/lib/lp/charms/browser/widgets/tests/test_charmrecipebuildchannelswidget.py
479@@ -62,53 +62,53 @@ class TestCharmRecipeBuildChannelsWidget(TestCaseWithFactory):
480 # The subwidgets are set up and a flag is set.
481 self.widget.setUpSubWidgets()
482 self.assertTrue(self.widget._widgets_set_up)
483+ self.assertIsNotNone(getattr(self.widget, "charmcraft_widget", None))
484 self.assertIsNotNone(getattr(self.widget, "core_widget", None))
485 self.assertIsNotNone(getattr(self.widget, "core18_widget", None))
486 self.assertIsNotNone(getattr(self.widget, "core20_widget", None))
487- self.assertIsNotNone(getattr(self.widget, "charmcraft_widget", None))
488
489 def test_setUpSubWidgets_second_call(self):
490 # The setUpSubWidgets method exits early if a flag is set to
491 # indicate that the widgets were set up.
492 self.widget._widgets_set_up = True
493 self.widget.setUpSubWidgets()
494+ self.assertIsNone(getattr(self.widget, "charmcraft_widget", None))
495 self.assertIsNone(getattr(self.widget, "core_widget", None))
496 self.assertIsNone(getattr(self.widget, "core18_widget", None))
497 self.assertIsNone(getattr(self.widget, "core20_widget", None))
498- self.assertIsNone(getattr(self.widget, "charmcraft_widget", None))
499
500 def test_setRenderedValue_None(self):
501 self.widget.setRenderedValue(None)
502+ self.assertIsNone(self.widget.charmcraft_widget._getCurrentValue())
503 self.assertIsNone(self.widget.core_widget._getCurrentValue())
504 self.assertIsNone(self.widget.core18_widget._getCurrentValue())
505 self.assertIsNone(self.widget.core20_widget._getCurrentValue())
506- self.assertIsNone(self.widget.charmcraft_widget._getCurrentValue())
507
508 def test_setRenderedValue_empty(self):
509 self.widget.setRenderedValue({})
510+ self.assertIsNone(self.widget.charmcraft_widget._getCurrentValue())
511 self.assertIsNone(self.widget.core_widget._getCurrentValue())
512 self.assertIsNone(self.widget.core18_widget._getCurrentValue())
513 self.assertIsNone(self.widget.core20_widget._getCurrentValue())
514- self.assertIsNone(self.widget.charmcraft_widget._getCurrentValue())
515
516 def test_setRenderedValue_one_channel(self):
517 self.widget.setRenderedValue({"charmcraft": "stable"})
518+ self.assertEqual(
519+ "stable", self.widget.charmcraft_widget._getCurrentValue())
520 self.assertIsNone(self.widget.core_widget._getCurrentValue())
521 self.assertIsNone(self.widget.core18_widget._getCurrentValue())
522 self.assertIsNone(self.widget.core20_widget._getCurrentValue())
523- self.assertEqual(
524- "stable", self.widget.charmcraft_widget._getCurrentValue())
525
526 def test_setRenderedValue_all_channels(self):
527 self.widget.setRenderedValue(
528- {"core": "candidate", "core18": "beta", "core20": "edge",
529- "charmcraft": "stable"})
530+ {"charmcraft": "stable", "core": "candidate", "core18": "beta",
531+ "core20": "edge"})
532+ self.assertEqual(
533+ "stable", self.widget.charmcraft_widget._getCurrentValue())
534 self.assertEqual(
535 "candidate", self.widget.core_widget._getCurrentValue())
536 self.assertEqual("beta", self.widget.core18_widget._getCurrentValue())
537 self.assertEqual("edge", self.widget.core20_widget._getCurrentValue())
538- self.assertEqual(
539- "stable", self.widget.charmcraft_widget._getCurrentValue())
540
541 def test_hasInput_false(self):
542 # hasInput is false when there are no channels in the form data.
543@@ -126,41 +126,40 @@ class TestCharmRecipeBuildChannelsWidget(TestCaseWithFactory):
544 # (At the moment, individual channel names are not validated, so
545 # there is no "false" counterpart to this test.)
546 form = {
547+ "field.auto_build_channels.charmcraft": "stable",
548 "field.auto_build_channels.core": "",
549 "field.auto_build_channels.core18": "beta",
550 "field.auto_build_channels.core20": "edge",
551- "field.auto_build_channels.charmcraft": "stable",
552 }
553 self.widget.request = LaunchpadTestRequest(form=form)
554 self.assertTrue(self.widget.hasValidInput())
555
556 def test_getInputValue(self):
557 form = {
558+ "field.auto_build_channels.charmcraft": "stable",
559 "field.auto_build_channels.core": "",
560 "field.auto_build_channels.core18": "beta",
561 "field.auto_build_channels.core20": "edge",
562- "field.auto_build_channels.charmcraft": "stable",
563 }
564 self.widget.request = LaunchpadTestRequest(form=form)
565 self.assertEqual(
566- {"core18": "beta", "core20": "edge",
567- "charmcraft": "stable"},
568+ {"charmcraft": "stable", "core18": "beta", "core20": "edge"},
569 self.widget.getInputValue())
570
571 def test_call(self):
572 # The __call__ method sets up the widgets.
573 markup = self.widget()
574+ self.assertIsNotNone(self.widget.charmcraft_widget)
575 self.assertIsNotNone(self.widget.core_widget)
576 self.assertIsNotNone(self.widget.core18_widget)
577 self.assertIsNotNone(self.widget.core20_widget)
578- self.assertIsNotNone(self.widget.charmcraft_widget)
579 soup = BeautifulSoup(markup)
580 fields = soup.find_all(["input"], {"id": re.compile(".*")})
581 expected_ids = [
582+ "field.auto_build_channels.charmcraft",
583 "field.auto_build_channels.core",
584 "field.auto_build_channels.core18",
585 "field.auto_build_channels.core20",
586- "field.auto_build_channels.charmcraft",
587 ]
588 ids = [field["id"] for field in fields]
589 self.assertContentEqual(expected_ids, ids)
590diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
591index 9c76cf4..80e4572 100644
592--- a/lib/lp/charms/interfaces/charmrecipe.py
593+++ b/lib/lp/charms/interfaces/charmrecipe.py
594@@ -445,8 +445,8 @@ class ICharmRecipeEditableAttributes(Interface):
595 key_type=TextLine(), required=False, readonly=False,
596 description=_(
597 "A dictionary mapping snap names to channels to use when building "
598- "this charm recipe. Currently only 'core', 'core18', 'core20', "
599- "and 'charmcraft' keys are supported."))
600+ "this charm recipe. Currently only 'charmcraft', 'core', "
601+ "'core18', and 'core20' keys are supported."))
602
603 is_stale = Bool(
604 title=_("Charm recipe is stale and is due to be rebuilt."),
605diff --git a/lib/lp/charms/interfaces/charmrecipebuild.py b/lib/lp/charms/interfaces/charmrecipebuild.py
606index 284566f..077ccce 100644
607--- a/lib/lp/charms/interfaces/charmrecipebuild.py
608+++ b/lib/lp/charms/interfaces/charmrecipebuild.py
609@@ -65,8 +65,8 @@ class ICharmRecipeBuildView(IPackageBuild):
610 title=_("Source snap channels to use for this build."),
611 description=_(
612 "A dictionary mapping snap names to channels to use for this "
613- "build. Currently only 'core', 'core18', 'core20', "
614- "and 'charmcraft' keys are supported."),
615+ "build. Currently only 'charmcraft', 'core', 'core18', and "
616+ "'core20' keys are supported."),
617 key_type=TextLine())
618
619 virtualized = Bool(
620diff --git a/lib/lp/charms/interfaces/charmrecipejob.py b/lib/lp/charms/interfaces/charmrecipejob.py
621index 9bdc9e5..1094e5a 100644
622--- a/lib/lp/charms/interfaces/charmrecipejob.py
623+++ b/lib/lp/charms/interfaces/charmrecipejob.py
624@@ -64,8 +64,8 @@ class ICharmRecipeRequestBuildsJob(IRunnableJob):
625 title=_("Source snap channels to use for these builds."),
626 description=_(
627 "A dictionary mapping snap names to channels to use for these "
628- "builds. Currently only 'core', 'core18', 'core20', and "
629- "'charmcraft' keys are supported."),
630+ "builds. Currently only 'charmcraft', 'core', 'core18', and "
631+ "'core20' keys are supported."),
632 key_type=TextLine(), required=False, readonly=True)
633
634 architectures = Set(
635diff --git a/lib/lp/charms/templates/charmrecipe-macros.pt b/lib/lp/charms/templates/charmrecipe-macros.pt
636index 93b4d69..fe314e1 100644
637--- a/lib/lp/charms/templates/charmrecipe-macros.pt
638+++ b/lib/lp/charms/templates/charmrecipe-macros.pt
639@@ -17,6 +17,12 @@
640 </div>
641 </div>
642
643+ <span
644+ tal:define="link context_menu/create_charm_recipe|nothing"
645+ tal:condition="python: link and link.enabled"
646+ tal:replace="structure link/render"
647+ />
648+
649 </div>
650
651 </tal:root>
652diff --git a/lib/lp/charms/templates/charmrecipe-new.pt b/lib/lp/charms/templates/charmrecipe-new.pt
653new file mode 100644
654index 0000000..bde870d
655--- /dev/null
656+++ b/lib/lp/charms/templates/charmrecipe-new.pt
657@@ -0,0 +1,87 @@
658+<html
659+ xmlns="http://www.w3.org/1999/xhtml"
660+ xmlns:tal="http://xml.zope.org/namespaces/tal"
661+ xmlns:metal="http://xml.zope.org/namespaces/metal"
662+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
663+ metal:use-macro="view/macro:page/main_side"
664+ i18n:domain="launchpad">
665+<body>
666+
667+<div metal:fill-slot="main">
668+ <div>
669+ <p>
670+ A <a href="https://juju.is/docs">charmed operator</a> (packaged as a
671+ "charm") encapsulates a single application and all the code and
672+ know-how it takes to operate it, such as how to combine and work with
673+ other related applications or how to upgrade it. Launchpad can build
674+ charms using <a href="https://juju.is/docs/sdk">charmcraft</a>, from
675+ any Git branch on Launchpad that has a <tt>charmcraft.yaml</tt> file.
676+ </p>
677+ </div>
678+
679+ <div metal:use-macro="context/@@launchpad_form/form">
680+ <metal:formbody fill-slot="widgets">
681+ <table class="form">
682+ <tal:widget define="widget nocall:view/widgets/name">
683+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
684+ </tal:widget>
685+ <tal:widget define="widget nocall:view/widgets/owner">
686+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
687+ </tal:widget>
688+
689+ <tal:not-project-context condition="not: view/is_project_context">
690+ <tal:widget define="widget nocall:view/widgets/project">
691+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
692+ </tal:widget>
693+ </tal:not-project-context>
694+
695+ <tal:project-context condition="view/is_project_context">
696+ <tal:widget define="widget nocall:view/widgets/git_ref">
697+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
698+ </tal:widget>
699+ </tal:project-context>
700+
701+ <tal:widget define="widget nocall:view/widgets/auto_build">
702+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
703+ </tal:widget>
704+ <tr>
705+ <td>
706+ <table class="subordinate">
707+ <tal:widget define="widget nocall:view/widgets/auto_build_channels">
708+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
709+ </tal:widget>
710+ </table>
711+ </td>
712+ </tr>
713+
714+ <tal:widget define="widget nocall:view/widgets/store_upload">
715+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
716+ </tal:widget>
717+ <tr>
718+ <td>
719+ <table class="subordinate">
720+ <tal:widget define="widget nocall:view/widgets/store_name">
721+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
722+ </tal:widget>
723+ <tal:widget define="widget nocall:view/widgets/store_channels"
724+ condition="widget/has_risks_vocabulary">
725+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
726+ </tal:widget>
727+ </table>
728+ </td>
729+ </tr>
730+ </table>
731+ </metal:formbody>
732+ </div>
733+
734+ <script type="text/javascript">
735+ LPJS.use('lp.charms.charmrecipe.edit', function(Y) {
736+ Y.on('domready', function(e) {
737+ Y.lp.charms.charmrecipe.edit.setup();
738+ }, window);
739+ });
740+ </script>
741+</div>
742+
743+</body>
744+</html>
745diff --git a/lib/lp/code/browser/gitref.py b/lib/lp/code/browser/gitref.py
746index c53f8f1..95abbd9 100644
747--- a/lib/lp/code/browser/gitref.py
748+++ b/lib/lp/code/browser/gitref.py
749@@ -84,6 +84,7 @@ class GitRefContextMenu(
750 facet = 'branches'
751 links = [
752 'browse_commits',
753+ 'create_charm_recipe',
754 'create_recipe',
755 'create_snap',
756 'register_merge',
757diff --git a/lib/lp/registry/browser/product.py b/lib/lp/registry/browser/product.py
758index 628fe0e..63724fd 100644
759--- a/lib/lp/registry/browser/product.py
760+++ b/lib/lp/registry/browser/product.py
761@@ -588,6 +588,7 @@ class ProductOverviewMenu(ApplicationMenu, ProductEditLinksMixin,
762 'view_charm_recipes',
763 'view_recipes',
764 'view_snaps',
765+ 'create_charm_recipe',
766 'create_snap',
767 ]
768
769diff --git a/lib/lp/registry/templates/product-index.pt b/lib/lp/registry/templates/product-index.pt
770index 242483d..fc3c274 100644
771--- a/lib/lp/registry/templates/product-index.pt
772+++ b/lib/lp/registry/templates/product-index.pt
773@@ -170,25 +170,35 @@
774 </p>
775
776 <ul class="horizontal" id="project-link-info">
777- <li tal:condition="overview_menu/series_add/enabled">
778+ <li class="nowrap"
779+ tal:condition="overview_menu/series_add/enabled">
780 <a tal:replace="structure overview_menu/series_add/fmt:link" />
781 </li>
782- <li>
783+ <li class="nowrap">
784 <a tal:replace="structure overview_menu/milestones/fmt:link" />
785 </li>
786- <li tal:define="link context/menu:overview/view_recipes"
787+ <li class="nowrap"
788+ tal:define="link context/menu:overview/view_recipes"
789 tal:condition="link/enabled">
790 <a tal:replace="structure link/fmt:link" />
791 </li>
792- <li tal:define="link context/menu:overview/view_snaps"
793+ <li class="nowrap"
794+ tal:define="link context/menu:overview/view_snaps"
795 tal:condition="link/enabled">
796 <a tal:replace="structure link/fmt:link" />
797 </li>
798- <li tal:define="link context/menu:overview/create_snap"
799+ <li class="nowrap"
800+ tal:define="link context/menu:overview/create_snap"
801 tal:condition="link/enabled">
802 <a tal:replace="structure link/fmt:link" />
803 </li>
804- <li tal:define="link context/menu:overview/view_charm_recipes"
805+ <li class="nowrap"
806+ tal:define="link context/menu:overview/view_charm_recipes"
807+ tal:condition="link/enabled">
808+ <a tal:replace="structure link/fmt:link" />
809+ </li>
810+ <li class="nowrap"
811+ tal:define="link context/menu:overview/create_charm_recipe"
812 tal:condition="link/enabled">
813 <a tal:replace="structure link/fmt:link" />
814 </li>

Subscribers

People subscribed via source and target branches

to status/vote changes: