Merge ~cjwatson/launchpad:charm-recipe-create-views into launchpad:master
- Git
- lp:~cjwatson/launchpad
- charm-recipe-create-views
- Merge into 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) |
Related bugs: |
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
Description of the change
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
1 | diff --git a/lib/lp/charms/browser/charmrecipe.py b/lib/lp/charms/browser/charmrecipe.py |
2 | index 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 |
128 | diff --git a/lib/lp/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml |
129 | index 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}" |
152 | diff --git a/lib/lp/charms/browser/hascharmrecipes.py b/lib/lp/charms/browser/hascharmrecipes.py |
153 | index 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.""" |
189 | diff --git a/lib/lp/charms/browser/tests/test_charmrecipe.py b/lib/lp/charms/browser/tests/test_charmrecipe.py |
190 | index 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): |
357 | diff --git a/lib/lp/charms/browser/tests/test_hascharmrecipes.py b/lib/lp/charms/browser/tests/test_hascharmrecipes.py |
358 | index 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 |
436 | diff --git a/lib/lp/charms/browser/widgets/charmrecipebuildchannels.py b/lib/lp/charms/browser/widgets/charmrecipebuildchannels.py |
437 | index 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): |
449 | diff --git a/lib/lp/charms/browser/widgets/templates/charmrecipebuildchannels.pt b/lib/lp/charms/browser/widgets/templates/charmrecipebuildchannels.pt |
450 | index 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> |
475 | diff --git a/lib/lp/charms/browser/widgets/tests/test_charmrecipebuildchannelswidget.py b/lib/lp/charms/browser/widgets/tests/test_charmrecipebuildchannelswidget.py |
476 | index 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) |
590 | diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py |
591 | index 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."), |
605 | diff --git a/lib/lp/charms/interfaces/charmrecipebuild.py b/lib/lp/charms/interfaces/charmrecipebuild.py |
606 | index 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( |
620 | diff --git a/lib/lp/charms/interfaces/charmrecipejob.py b/lib/lp/charms/interfaces/charmrecipejob.py |
621 | index 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( |
635 | diff --git a/lib/lp/charms/templates/charmrecipe-macros.pt b/lib/lp/charms/templates/charmrecipe-macros.pt |
636 | index 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> |
652 | diff --git a/lib/lp/charms/templates/charmrecipe-new.pt b/lib/lp/charms/templates/charmrecipe-new.pt |
653 | new file mode 100644 |
654 | index 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> |
745 | diff --git a/lib/lp/code/browser/gitref.py b/lib/lp/code/browser/gitref.py |
746 | index 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', |
757 | diff --git a/lib/lp/registry/browser/product.py b/lib/lp/registry/browser/product.py |
758 | index 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 | |
769 | diff --git a/lib/lp/registry/templates/product-index.pt b/lib/lp/registry/templates/product-index.pt |
770 | index 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> |