Merge ~cjwatson/launchpad:charm-recipe-build-basic-browser into launchpad:master
- Git
- lp:~cjwatson/launchpad
- charm-recipe-build-basic-browser
- Merge into master
Proposed by
Colin Watson
Status: | Merged |
---|---|
Approved by: | Colin Watson |
Approved revision: | 4a5561d2fefbcacc3b3137ff7a44ea2ef5ed6404 |
Merge reported by: | Otto Co-Pilot |
Merged at revision: | not available |
Proposed branch: | ~cjwatson/launchpad:charm-recipe-build-basic-browser |
Merge into: | launchpad:master |
Prerequisite: | ~cjwatson/launchpad:charm-recipe-basic-browser |
Diff against target: |
786 lines (+722/-0) 7 files modified
lib/lp/app/browser/configure.zcml (+6/-0) lib/lp/app/browser/tales.py (+12/-0) lib/lp/charms/browser/charmrecipebuild.py (+171/-0) lib/lp/charms/browser/configure.zcml (+40/-0) lib/lp/charms/browser/tests/test_charmrecipebuild.py (+264/-0) lib/lp/charms/templates/charmrecipebuild-index.pt (+201/-0) lib/lp/charms/templates/charmrecipebuild-retry.pt (+28/-0) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Ioana Lasc (community) | Approve | ||
Review via email: mp+403791@code.launchpad.net |
Commit message
Add basic charm recipe build views
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/app/browser/configure.zcml b/lib/lp/app/browser/configure.zcml | |||
2 | index b08d380..005968c 100644 | |||
3 | --- a/lib/lp/app/browser/configure.zcml | |||
4 | +++ b/lib/lp/app/browser/configure.zcml | |||
5 | @@ -877,6 +877,12 @@ | |||
6 | 877 | name="fmt" | 877 | name="fmt" |
7 | 878 | /> | 878 | /> |
8 | 879 | <adapter | 879 | <adapter |
9 | 880 | for="lp.charms.interfaces.charmrecipe.ICharmRecipe" | ||
10 | 881 | provides="zope.traversing.interfaces.IPathAdapter" | ||
11 | 882 | factory="lp.app.browser.tales.CharmRecipeFormatterAPI" | ||
12 | 883 | name="fmt" | ||
13 | 884 | /> | ||
14 | 885 | <adapter | ||
15 | 880 | for="lp.blueprints.interfaces.specification.ISpecification" | 886 | for="lp.blueprints.interfaces.specification.ISpecification" |
16 | 881 | provides="zope.traversing.interfaces.IPathAdapter" | 887 | provides="zope.traversing.interfaces.IPathAdapter" |
17 | 882 | factory="lp.app.browser.tales.SpecificationFormatterAPI" | 888 | factory="lp.app.browser.tales.SpecificationFormatterAPI" |
18 | diff --git a/lib/lp/app/browser/tales.py b/lib/lp/app/browser/tales.py | |||
19 | index 17fd33f..1bf84aa 100644 | |||
20 | --- a/lib/lp/app/browser/tales.py | |||
21 | +++ b/lib/lp/app/browser/tales.py | |||
22 | @@ -1951,6 +1951,18 @@ class SnappySeriesFormatterAPI(CustomizableFormatter): | |||
23 | 1951 | return {'title': self._context.title} | 1951 | return {'title': self._context.title} |
24 | 1952 | 1952 | ||
25 | 1953 | 1953 | ||
26 | 1954 | class CharmRecipeFormatterAPI(CustomizableFormatter): | ||
27 | 1955 | """Adapter providing fmt support for ICharmRecipe objects.""" | ||
28 | 1956 | |||
29 | 1957 | _link_summary_template = _( | ||
30 | 1958 | 'Charm recipe %(name)s for %(owner)s in %(project)s') | ||
31 | 1959 | |||
32 | 1960 | def _link_summary_values(self): | ||
33 | 1961 | return {'name': self._context.name, | ||
34 | 1962 | 'owner': self._context.owner.displayname, | ||
35 | 1963 | 'project': self._context.project.displayname} | ||
36 | 1964 | |||
37 | 1965 | |||
38 | 1954 | class SpecificationFormatterAPI(CustomizableFormatter): | 1966 | class SpecificationFormatterAPI(CustomizableFormatter): |
39 | 1955 | """Adapter providing fmt support for Specification objects""" | 1967 | """Adapter providing fmt support for Specification objects""" |
40 | 1956 | 1968 | ||
41 | diff --git a/lib/lp/charms/browser/charmrecipebuild.py b/lib/lp/charms/browser/charmrecipebuild.py | |||
42 | 1957 | new file mode 100644 | 1969 | new file mode 100644 |
43 | index 0000000..f695084 | |||
44 | --- /dev/null | |||
45 | +++ b/lib/lp/charms/browser/charmrecipebuild.py | |||
46 | @@ -0,0 +1,171 @@ | |||
47 | 1 | # Copyright 2021 Canonical Ltd. This software is licensed under the | ||
48 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
49 | 3 | |||
50 | 4 | """Charm recipe build views.""" | ||
51 | 5 | |||
52 | 6 | from __future__ import absolute_import, print_function, unicode_literals | ||
53 | 7 | |||
54 | 8 | __metaclass__ = type | ||
55 | 9 | __all__ = [ | ||
56 | 10 | "CharmRecipeBuildContextMenu", | ||
57 | 11 | "CharmRecipeBuildNavigation", | ||
58 | 12 | "CharmRecipeBuildView", | ||
59 | 13 | ] | ||
60 | 14 | |||
61 | 15 | from zope.interface import Interface | ||
62 | 16 | |||
63 | 17 | from lp.app.browser.launchpadform import ( | ||
64 | 18 | action, | ||
65 | 19 | LaunchpadFormView, | ||
66 | 20 | ) | ||
67 | 21 | from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuild | ||
68 | 22 | from lp.services.librarian.browser import ( | ||
69 | 23 | FileNavigationMixin, | ||
70 | 24 | ProxiedLibraryFileAlias, | ||
71 | 25 | ) | ||
72 | 26 | from lp.services.propertycache import cachedproperty | ||
73 | 27 | from lp.services.webapp import ( | ||
74 | 28 | canonical_url, | ||
75 | 29 | ContextMenu, | ||
76 | 30 | enabled_with_permission, | ||
77 | 31 | LaunchpadView, | ||
78 | 32 | Link, | ||
79 | 33 | Navigation, | ||
80 | 34 | ) | ||
81 | 35 | from lp.soyuz.interfaces.binarypackagebuild import IBuildRescoreForm | ||
82 | 36 | |||
83 | 37 | |||
84 | 38 | class CharmRecipeBuildNavigation(Navigation, FileNavigationMixin): | ||
85 | 39 | usedfor = ICharmRecipeBuild | ||
86 | 40 | |||
87 | 41 | |||
88 | 42 | class CharmRecipeBuildContextMenu(ContextMenu): | ||
89 | 43 | """Context menu for charm recipe builds.""" | ||
90 | 44 | |||
91 | 45 | usedfor = ICharmRecipeBuild | ||
92 | 46 | |||
93 | 47 | facet = "overview" | ||
94 | 48 | |||
95 | 49 | links = ("retry", "cancel", "rescore") | ||
96 | 50 | |||
97 | 51 | @enabled_with_permission("launchpad.Edit") | ||
98 | 52 | def retry(self): | ||
99 | 53 | return Link( | ||
100 | 54 | "+retry", "Retry this build", icon="retry", | ||
101 | 55 | enabled=self.context.can_be_retried) | ||
102 | 56 | |||
103 | 57 | @enabled_with_permission("launchpad.Edit") | ||
104 | 58 | def cancel(self): | ||
105 | 59 | return Link( | ||
106 | 60 | "+cancel", "Cancel build", icon="remove", | ||
107 | 61 | enabled=self.context.can_be_cancelled) | ||
108 | 62 | |||
109 | 63 | @enabled_with_permission("launchpad.Admin") | ||
110 | 64 | def rescore(self): | ||
111 | 65 | return Link( | ||
112 | 66 | "+rescore", "Rescore build", icon="edit", | ||
113 | 67 | enabled=self.context.can_be_rescored) | ||
114 | 68 | |||
115 | 69 | |||
116 | 70 | class CharmRecipeBuildView(LaunchpadView): | ||
117 | 71 | """Default view of a charm recipe build.""" | ||
118 | 72 | |||
119 | 73 | @property | ||
120 | 74 | def label(self): | ||
121 | 75 | return self.context.title | ||
122 | 76 | |||
123 | 77 | page_title = label | ||
124 | 78 | |||
125 | 79 | @cachedproperty | ||
126 | 80 | def files(self): | ||
127 | 81 | """Return `LibraryFileAlias`es for files produced by this build.""" | ||
128 | 82 | if not self.context.was_built: | ||
129 | 83 | return None | ||
130 | 84 | |||
131 | 85 | return [ | ||
132 | 86 | ProxiedLibraryFileAlias(alias, self.context) | ||
133 | 87 | for _, alias, _ in self.context.getFiles() if not alias.deleted] | ||
134 | 88 | |||
135 | 89 | @cachedproperty | ||
136 | 90 | def has_files(self): | ||
137 | 91 | return bool(self.files) | ||
138 | 92 | |||
139 | 93 | @property | ||
140 | 94 | def next_url(self): | ||
141 | 95 | return canonical_url(self.context) | ||
142 | 96 | |||
143 | 97 | |||
144 | 98 | class CharmRecipeBuildRetryView(LaunchpadFormView): | ||
145 | 99 | """View for retrying a charm recipe build.""" | ||
146 | 100 | |||
147 | 101 | class schema(Interface): | ||
148 | 102 | """Schema for retrying a build.""" | ||
149 | 103 | |||
150 | 104 | page_title = label = "Retry build" | ||
151 | 105 | |||
152 | 106 | @property | ||
153 | 107 | def cancel_url(self): | ||
154 | 108 | return canonical_url(self.context) | ||
155 | 109 | next_url = cancel_url | ||
156 | 110 | |||
157 | 111 | @action("Retry build", name="retry") | ||
158 | 112 | def request_action(self, action, data): | ||
159 | 113 | """Retry the build.""" | ||
160 | 114 | if not self.context.can_be_retried: | ||
161 | 115 | self.request.response.addErrorNotification( | ||
162 | 116 | "Build cannot be retried") | ||
163 | 117 | else: | ||
164 | 118 | self.context.retry() | ||
165 | 119 | self.request.response.addInfoNotification("Build has been queued") | ||
166 | 120 | |||
167 | 121 | self.request.response.redirect(self.next_url) | ||
168 | 122 | |||
169 | 123 | |||
170 | 124 | class CharmRecipeBuildCancelView(LaunchpadFormView): | ||
171 | 125 | """View for cancelling a charm recipe build.""" | ||
172 | 126 | |||
173 | 127 | class schema(Interface): | ||
174 | 128 | """Schema for cancelling a build.""" | ||
175 | 129 | |||
176 | 130 | page_title = label = "Cancel build" | ||
177 | 131 | |||
178 | 132 | @property | ||
179 | 133 | def cancel_url(self): | ||
180 | 134 | return canonical_url(self.context) | ||
181 | 135 | next_url = cancel_url | ||
182 | 136 | |||
183 | 137 | @action("Cancel build", name="cancel") | ||
184 | 138 | def request_action(self, action, data): | ||
185 | 139 | """Cancel the build.""" | ||
186 | 140 | self.context.cancel() | ||
187 | 141 | |||
188 | 142 | |||
189 | 143 | class CharmRecipeBuildRescoreView(LaunchpadFormView): | ||
190 | 144 | """View for rescoring a charm recipe build.""" | ||
191 | 145 | |||
192 | 146 | schema = IBuildRescoreForm | ||
193 | 147 | |||
194 | 148 | page_title = label = "Rescore build" | ||
195 | 149 | |||
196 | 150 | def __call__(self): | ||
197 | 151 | if self.context.can_be_rescored: | ||
198 | 152 | return super(CharmRecipeBuildRescoreView, self).__call__() | ||
199 | 153 | self.request.response.addWarningNotification( | ||
200 | 154 | "Cannot rescore this build because it is not queued.") | ||
201 | 155 | self.request.response.redirect(canonical_url(self.context)) | ||
202 | 156 | |||
203 | 157 | @property | ||
204 | 158 | def cancel_url(self): | ||
205 | 159 | return canonical_url(self.context) | ||
206 | 160 | next_url = cancel_url | ||
207 | 161 | |||
208 | 162 | @action("Rescore build", name="rescore") | ||
209 | 163 | def request_action(self, action, data): | ||
210 | 164 | """Rescore the build.""" | ||
211 | 165 | score = data.get("priority") | ||
212 | 166 | self.context.rescore(score) | ||
213 | 167 | self.request.response.addNotification("Build rescored to %s." % score) | ||
214 | 168 | |||
215 | 169 | @property | ||
216 | 170 | def initial_values(self): | ||
217 | 171 | return {"score": str(self.context.buildqueue_record.lastscore)} | ||
218 | diff --git a/lib/lp/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml | |||
219 | index 92d78bf..0475288 100644 | |||
220 | --- a/lib/lp/charms/browser/configure.zcml | |||
221 | +++ b/lib/lp/charms/browser/configure.zcml | |||
222 | @@ -28,13 +28,53 @@ | |||
223 | 28 | for="lp.charms.interfaces.charmrecipe.ICharmRecipe" | 28 | for="lp.charms.interfaces.charmrecipe.ICharmRecipe" |
224 | 29 | factory="lp.charms.browser.charmrecipe.CharmRecipeBreadcrumb" | 29 | factory="lp.charms.browser.charmrecipe.CharmRecipeBreadcrumb" |
225 | 30 | permission="zope.Public" /> | 30 | permission="zope.Public" /> |
226 | 31 | |||
227 | 31 | <browser:url | 32 | <browser:url |
228 | 32 | for="lp.charms.interfaces.charmrecipe.ICharmRecipeBuildRequest" | 33 | for="lp.charms.interfaces.charmrecipe.ICharmRecipeBuildRequest" |
229 | 33 | path_expression="string:+build-request/${id}" | 34 | path_expression="string:+build-request/${id}" |
230 | 34 | attribute_to_parent="recipe" /> | 35 | attribute_to_parent="recipe" /> |
231 | 36 | |||
232 | 35 | <browser:url | 37 | <browser:url |
233 | 36 | for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild" | 38 | for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild" |
234 | 37 | path_expression="string:+build/${id}" | 39 | path_expression="string:+build/${id}" |
235 | 38 | attribute_to_parent="recipe" /> | 40 | attribute_to_parent="recipe" /> |
236 | 41 | <browser:menus | ||
237 | 42 | module="lp.charms.browser.charmrecipebuild" | ||
238 | 43 | classes="CharmRecipeBuildContextMenu" /> | ||
239 | 44 | <browser:navigation | ||
240 | 45 | module="lp.charms.browser.charmrecipebuild" | ||
241 | 46 | classes="CharmRecipeBuildNavigation" /> | ||
242 | 47 | <browser:defaultView | ||
243 | 48 | for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild" | ||
244 | 49 | name="+index" /> | ||
245 | 50 | <browser:page | ||
246 | 51 | for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild" | ||
247 | 52 | class="lp.charms.browser.charmrecipebuild.CharmRecipeBuildView" | ||
248 | 53 | permission="launchpad.View" | ||
249 | 54 | name="+index" | ||
250 | 55 | template="../templates/charmrecipebuild-index.pt" /> | ||
251 | 56 | <browser:page | ||
252 | 57 | for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild" | ||
253 | 58 | class="lp.charms.browser.charmrecipebuild.CharmRecipeBuildRetryView" | ||
254 | 59 | permission="launchpad.Edit" | ||
255 | 60 | name="+retry" | ||
256 | 61 | template="../templates/charmrecipebuild-retry.pt" /> | ||
257 | 62 | <browser:page | ||
258 | 63 | for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild" | ||
259 | 64 | class="lp.charms.browser.charmrecipebuild.CharmRecipeBuildCancelView" | ||
260 | 65 | permission="launchpad.Edit" | ||
261 | 66 | name="+cancel" | ||
262 | 67 | template="../../app/templates/generic-edit.pt" /> | ||
263 | 68 | <browser:page | ||
264 | 69 | for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild" | ||
265 | 70 | class="lp.charms.browser.charmrecipebuild.CharmRecipeBuildRescoreView" | ||
266 | 71 | permission="launchpad.Admin" | ||
267 | 72 | name="+rescore" | ||
268 | 73 | template="../../app/templates/generic-edit.pt" /> | ||
269 | 74 | <adapter | ||
270 | 75 | provides="lp.services.webapp.interfaces.IBreadcrumb" | ||
271 | 76 | for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild" | ||
272 | 77 | factory="lp.services.webapp.breadcrumb.TitleBreadcrumb" | ||
273 | 78 | permission="zope.Public" /> | ||
274 | 39 | </facet> | 79 | </facet> |
275 | 40 | </configure> | 80 | </configure> |
276 | diff --git a/lib/lp/charms/browser/tests/test_charmrecipebuild.py b/lib/lp/charms/browser/tests/test_charmrecipebuild.py | |||
277 | 41 | new file mode 100644 | 81 | new file mode 100644 |
278 | index 0000000..0978a0a | |||
279 | --- /dev/null | |||
280 | +++ b/lib/lp/charms/browser/tests/test_charmrecipebuild.py | |||
281 | @@ -0,0 +1,264 @@ | |||
282 | 1 | # Copyright 2021 Canonical Ltd. This software is licensed under the | ||
283 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
284 | 3 | |||
285 | 4 | """Test charm recipe build views.""" | ||
286 | 5 | |||
287 | 6 | from __future__ import absolute_import, print_function, unicode_literals | ||
288 | 7 | |||
289 | 8 | __metaclass__ = type | ||
290 | 9 | |||
291 | 10 | import re | ||
292 | 11 | |||
293 | 12 | from fixtures import FakeLogger | ||
294 | 13 | import soupmatchers | ||
295 | 14 | from storm.locals import Store | ||
296 | 15 | from testtools.matchers import StartsWith | ||
297 | 16 | import transaction | ||
298 | 17 | from zope.component import getUtility | ||
299 | 18 | from zope.security.interfaces import Unauthorized | ||
300 | 19 | from zope.security.proxy import removeSecurityProxy | ||
301 | 20 | from zope.testbrowser.browser import LinkNotFoundError | ||
302 | 21 | |||
303 | 22 | from lp.app.enums import InformationType | ||
304 | 23 | from lp.app.interfaces.launchpad import ILaunchpadCelebrities | ||
305 | 24 | from lp.buildmaster.enums import BuildStatus | ||
306 | 25 | from lp.charms.interfaces.charmrecipe import ( | ||
307 | 26 | CHARM_RECIPE_ALLOW_CREATE, | ||
308 | 27 | CHARM_RECIPE_PRIVATE_FEATURE_FLAG, | ||
309 | 28 | ) | ||
310 | 29 | from lp.services.features.testing import FeatureFixture | ||
311 | 30 | from lp.services.webapp import canonical_url | ||
312 | 31 | from lp.testing import ( | ||
313 | 32 | ANONYMOUS, | ||
314 | 33 | BrowserTestCase, | ||
315 | 34 | login, | ||
316 | 35 | person_logged_in, | ||
317 | 36 | TestCaseWithFactory, | ||
318 | 37 | ) | ||
319 | 38 | from lp.testing.layers import ( | ||
320 | 39 | DatabaseFunctionalLayer, | ||
321 | 40 | LaunchpadFunctionalLayer, | ||
322 | 41 | ) | ||
323 | 42 | from lp.testing.pages import ( | ||
324 | 43 | extract_text, | ||
325 | 44 | find_main_content, | ||
326 | 45 | find_tags_by_class, | ||
327 | 46 | ) | ||
328 | 47 | from lp.testing.views import create_initialized_view | ||
329 | 48 | |||
330 | 49 | |||
331 | 50 | class TestCanonicalUrlForCharmRecipeBuild(TestCaseWithFactory): | ||
332 | 51 | |||
333 | 52 | layer = DatabaseFunctionalLayer | ||
334 | 53 | |||
335 | 54 | def setUp(self): | ||
336 | 55 | super(TestCanonicalUrlForCharmRecipeBuild, self).setUp() | ||
337 | 56 | self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"})) | ||
338 | 57 | |||
339 | 58 | def test_canonical_url(self): | ||
340 | 59 | owner = self.factory.makePerson(name="person") | ||
341 | 60 | project = self.factory.makeProduct(name="charm-project") | ||
342 | 61 | recipe = self.factory.makeCharmRecipe( | ||
343 | 62 | registrant=owner, owner=owner, project=project, name="charm") | ||
344 | 63 | build = self.factory.makeCharmRecipeBuild( | ||
345 | 64 | requester=owner, recipe=recipe) | ||
346 | 65 | self.assertThat( | ||
347 | 66 | canonical_url(build), | ||
348 | 67 | StartsWith( | ||
349 | 68 | "http://launchpad.test/~person/charm-project/+charm/charm/" | ||
350 | 69 | "+build/")) | ||
351 | 70 | |||
352 | 71 | |||
353 | 72 | class TestCharmRecipeBuildView(TestCaseWithFactory): | ||
354 | 73 | |||
355 | 74 | layer = LaunchpadFunctionalLayer | ||
356 | 75 | |||
357 | 76 | def setUp(self): | ||
358 | 77 | super(TestCharmRecipeBuildView, self).setUp() | ||
359 | 78 | self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"})) | ||
360 | 79 | |||
361 | 80 | def test_files(self): | ||
362 | 81 | # CharmRecipeBuildView.files returns all the associated files. | ||
363 | 82 | build = self.factory.makeCharmRecipeBuild( | ||
364 | 83 | status=BuildStatus.FULLYBUILT) | ||
365 | 84 | charm_file = self.factory.makeCharmFile(build=build) | ||
366 | 85 | build_view = create_initialized_view(build, "+index") | ||
367 | 86 | self.assertEqual( | ||
368 | 87 | [charm_file.library_file.filename], | ||
369 | 88 | [lfa.filename for lfa in build_view.files]) | ||
370 | 89 | # Deleted files won't be included. | ||
371 | 90 | self.assertFalse(charm_file.library_file.deleted) | ||
372 | 91 | removeSecurityProxy(charm_file.library_file).content = None | ||
373 | 92 | self.assertTrue(charm_file.library_file.deleted) | ||
374 | 93 | build_view = create_initialized_view(build, "+index") | ||
375 | 94 | self.assertEqual([], build_view.files) | ||
376 | 95 | |||
377 | 96 | def test_revision_id(self): | ||
378 | 97 | build = self.factory.makeCharmRecipeBuild() | ||
379 | 98 | build.updateStatus( | ||
380 | 99 | BuildStatus.FULLYBUILT, slave_status={"revision_id": "dummy"}) | ||
381 | 100 | build_view = create_initialized_view(build, "+index") | ||
382 | 101 | self.assertThat(build_view(), soupmatchers.HTMLContains( | ||
383 | 102 | soupmatchers.Tag( | ||
384 | 103 | "revision ID", "li", attrs={"id": "revision-id"}, | ||
385 | 104 | text=re.compile(r"^\s*Revision: dummy\s*$")))) | ||
386 | 105 | |||
387 | 106 | |||
388 | 107 | class TestCharmRecipeBuildOperations(BrowserTestCase): | ||
389 | 108 | |||
390 | 109 | layer = DatabaseFunctionalLayer | ||
391 | 110 | |||
392 | 111 | def setUp(self): | ||
393 | 112 | super(TestCharmRecipeBuildOperations, self).setUp() | ||
394 | 113 | self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"})) | ||
395 | 114 | self.useFixture(FakeLogger()) | ||
396 | 115 | self.build = self.factory.makeCharmRecipeBuild() | ||
397 | 116 | self.build_url = canonical_url(self.build) | ||
398 | 117 | self.requester = self.build.requester | ||
399 | 118 | self.buildd_admin = self.factory.makePerson( | ||
400 | 119 | member_of=[getUtility(ILaunchpadCelebrities).buildd_admin]) | ||
401 | 120 | |||
402 | 121 | def test_retry_build(self): | ||
403 | 122 | # The requester of a build can retry it. | ||
404 | 123 | self.build.updateStatus(BuildStatus.FAILEDTOBUILD) | ||
405 | 124 | transaction.commit() | ||
406 | 125 | browser = self.getViewBrowser(self.build, user=self.requester) | ||
407 | 126 | browser.getLink("Retry this build").click() | ||
408 | 127 | self.assertEqual(self.build_url, browser.getLink("Cancel").url) | ||
409 | 128 | browser.getControl("Retry build").click() | ||
410 | 129 | self.assertEqual(self.build_url, browser.url) | ||
411 | 130 | login(ANONYMOUS) | ||
412 | 131 | self.assertEqual(BuildStatus.NEEDSBUILD, self.build.status) | ||
413 | 132 | |||
414 | 133 | def test_retry_build_random_user(self): | ||
415 | 134 | # An unrelated non-admin user cannot retry a build. | ||
416 | 135 | self.build.updateStatus(BuildStatus.FAILEDTOBUILD) | ||
417 | 136 | transaction.commit() | ||
418 | 137 | user = self.factory.makePerson() | ||
419 | 138 | browser = self.getViewBrowser(self.build, user=user) | ||
420 | 139 | self.assertRaises( | ||
421 | 140 | LinkNotFoundError, browser.getLink, "Retry this build") | ||
422 | 141 | self.assertRaises( | ||
423 | 142 | Unauthorized, self.getUserBrowser, self.build_url + "/+retry", | ||
424 | 143 | user=user) | ||
425 | 144 | |||
426 | 145 | def test_retry_build_wrong_state(self): | ||
427 | 146 | # If the build isn't in an unsuccessful terminal state, you can't | ||
428 | 147 | # retry it. | ||
429 | 148 | self.build.updateStatus(BuildStatus.FULLYBUILT) | ||
430 | 149 | browser = self.getViewBrowser(self.build, user=self.requester) | ||
431 | 150 | self.assertRaises( | ||
432 | 151 | LinkNotFoundError, browser.getLink, "Retry this build") | ||
433 | 152 | |||
434 | 153 | def test_cancel_build(self): | ||
435 | 154 | # The requester of a build can cancel it. | ||
436 | 155 | self.build.queueBuild() | ||
437 | 156 | transaction.commit() | ||
438 | 157 | browser = self.getViewBrowser(self.build, user=self.requester) | ||
439 | 158 | browser.getLink("Cancel build").click() | ||
440 | 159 | self.assertEqual(self.build_url, browser.getLink("Cancel").url) | ||
441 | 160 | browser.getControl("Cancel build").click() | ||
442 | 161 | self.assertEqual(self.build_url, browser.url) | ||
443 | 162 | login(ANONYMOUS) | ||
444 | 163 | self.assertEqual(BuildStatus.CANCELLED, self.build.status) | ||
445 | 164 | |||
446 | 165 | def test_cancel_build_random_user(self): | ||
447 | 166 | # An unrelated non-admin user cannot cancel a build. | ||
448 | 167 | self.build.queueBuild() | ||
449 | 168 | transaction.commit() | ||
450 | 169 | user = self.factory.makePerson() | ||
451 | 170 | browser = self.getViewBrowser(self.build, user=user) | ||
452 | 171 | self.assertRaises(LinkNotFoundError, browser.getLink, "Cancel build") | ||
453 | 172 | self.assertRaises( | ||
454 | 173 | Unauthorized, self.getUserBrowser, self.build_url + "/+cancel", | ||
455 | 174 | user=user) | ||
456 | 175 | |||
457 | 176 | def test_cancel_build_wrong_state(self): | ||
458 | 177 | # If the build isn't queued, you can't cancel it. | ||
459 | 178 | browser = self.getViewBrowser(self.build, user=self.requester) | ||
460 | 179 | self.assertRaises(LinkNotFoundError, browser.getLink, "Cancel build") | ||
461 | 180 | |||
462 | 181 | def test_rescore_build(self): | ||
463 | 182 | # A buildd admin can rescore a build. | ||
464 | 183 | self.build.queueBuild() | ||
465 | 184 | transaction.commit() | ||
466 | 185 | browser = self.getViewBrowser(self.build, user=self.buildd_admin) | ||
467 | 186 | browser.getLink("Rescore build").click() | ||
468 | 187 | self.assertEqual(self.build_url, browser.getLink("Cancel").url) | ||
469 | 188 | browser.getControl("Priority").value = "1024" | ||
470 | 189 | browser.getControl("Rescore build").click() | ||
471 | 190 | self.assertEqual(self.build_url, browser.url) | ||
472 | 191 | login(ANONYMOUS) | ||
473 | 192 | self.assertEqual(1024, self.build.buildqueue_record.lastscore) | ||
474 | 193 | |||
475 | 194 | def test_rescore_build_invalid_score(self): | ||
476 | 195 | # Build scores can only take numbers. | ||
477 | 196 | self.build.queueBuild() | ||
478 | 197 | transaction.commit() | ||
479 | 198 | browser = self.getViewBrowser(self.build, user=self.buildd_admin) | ||
480 | 199 | browser.getLink("Rescore build").click() | ||
481 | 200 | self.assertEqual(self.build_url, browser.getLink("Cancel").url) | ||
482 | 201 | browser.getControl("Priority").value = "tentwentyfour" | ||
483 | 202 | browser.getControl("Rescore build").click() | ||
484 | 203 | self.assertEqual( | ||
485 | 204 | "Invalid integer data", | ||
486 | 205 | extract_text(find_tags_by_class(browser.contents, "message")[1])) | ||
487 | 206 | |||
488 | 207 | def test_rescore_build_not_admin(self): | ||
489 | 208 | # A non-admin user cannot cancel a build. | ||
490 | 209 | self.build.queueBuild() | ||
491 | 210 | transaction.commit() | ||
492 | 211 | user = self.factory.makePerson() | ||
493 | 212 | browser = self.getViewBrowser(self.build, user=user) | ||
494 | 213 | self.assertRaises(LinkNotFoundError, browser.getLink, "Rescore build") | ||
495 | 214 | self.assertRaises( | ||
496 | 215 | Unauthorized, self.getUserBrowser, self.build_url + "/+rescore", | ||
497 | 216 | user=user) | ||
498 | 217 | |||
499 | 218 | def test_rescore_build_wrong_state(self): | ||
500 | 219 | # If the build isn't NEEDSBUILD, you can't rescore it. | ||
501 | 220 | self.build.queueBuild() | ||
502 | 221 | with person_logged_in(self.requester): | ||
503 | 222 | self.build.cancel() | ||
504 | 223 | browser = self.getViewBrowser(self.build, user=self.buildd_admin) | ||
505 | 224 | self.assertRaises(LinkNotFoundError, browser.getLink, "Rescore build") | ||
506 | 225 | |||
507 | 226 | def test_rescore_build_wrong_state_stale_link(self): | ||
508 | 227 | # An attempt to rescore a non-queued build from a stale link shows a | ||
509 | 228 | # sensible error message. | ||
510 | 229 | self.build.queueBuild() | ||
511 | 230 | with person_logged_in(self.requester): | ||
512 | 231 | self.build.cancel() | ||
513 | 232 | browser = self.getViewBrowser( | ||
514 | 233 | self.build, "+rescore", user=self.buildd_admin) | ||
515 | 234 | self.assertEqual(self.build_url, browser.url) | ||
516 | 235 | self.assertThat(browser.contents, soupmatchers.HTMLContains( | ||
517 | 236 | soupmatchers.Tag( | ||
518 | 237 | "notification", "div", attrs={"class": "warning message"}, | ||
519 | 238 | text="Cannot rescore this build because it is not queued."))) | ||
520 | 239 | |||
521 | 240 | def test_builder_history(self): | ||
522 | 241 | Store.of(self.build).flush() | ||
523 | 242 | self.build.updateStatus( | ||
524 | 243 | BuildStatus.FULLYBUILT, builder=self.factory.makeBuilder()) | ||
525 | 244 | title = self.build.title | ||
526 | 245 | browser = self.getViewBrowser(self.build.builder, "+history") | ||
527 | 246 | self.assertTextMatchesExpressionIgnoreWhitespace( | ||
528 | 247 | r"Build history.*%s" % re.escape(title), | ||
529 | 248 | extract_text(find_main_content(browser.contents))) | ||
530 | 249 | self.assertEqual(self.build_url, browser.getLink(title).url) | ||
531 | 250 | |||
532 | 251 | def makeBuildingRecipe(self, information_type=InformationType.PUBLIC): | ||
533 | 252 | builder = self.factory.makeBuilder() | ||
534 | 253 | build = self.factory.makeCharmRecipeBuild( | ||
535 | 254 | information_type=information_type) | ||
536 | 255 | build.updateStatus(BuildStatus.BUILDING, builder=builder) | ||
537 | 256 | build.queueBuild() | ||
538 | 257 | build.buildqueue_record.builder = builder | ||
539 | 258 | build.buildqueue_record.logtail = "tail of the log" | ||
540 | 259 | return build | ||
541 | 260 | |||
542 | 261 | def test_builder_index_public(self): | ||
543 | 262 | build = self.makeBuildingRecipe() | ||
544 | 263 | browser = self.getViewBrowser(build.builder, no_login=True) | ||
545 | 264 | self.assertIn("tail of the log", browser.contents) | ||
546 | diff --git a/lib/lp/charms/templates/charmrecipebuild-index.pt b/lib/lp/charms/templates/charmrecipebuild-index.pt | |||
547 | 0 | new file mode 100644 | 265 | new file mode 100644 |
548 | index 0000000..1d0e4f0 | |||
549 | --- /dev/null | |||
550 | +++ b/lib/lp/charms/templates/charmrecipebuild-index.pt | |||
551 | @@ -0,0 +1,201 @@ | |||
552 | 1 | <html | ||
553 | 2 | xmlns="http://www.w3.org/1999/xhtml" | ||
554 | 3 | xmlns:tal="http://xml.zope.org/namespaces/tal" | ||
555 | 4 | xmlns:metal="http://xml.zope.org/namespaces/metal" | ||
556 | 5 | xmlns:i18n="http://xml.zope.org/namespaces/i18n" | ||
557 | 6 | metal:use-macro="view/macro:page/main_only" | ||
558 | 7 | i18n:domain="launchpad" | ||
559 | 8 | > | ||
560 | 9 | |||
561 | 10 | <body> | ||
562 | 11 | |||
563 | 12 | <tal:registering metal:fill-slot="registering"> | ||
564 | 13 | created | ||
565 | 14 | <span tal:content="context/date_created/fmt:displaydate" | ||
566 | 15 | tal:attributes="title context/date_created/fmt:datetime"/> | ||
567 | 16 | </tal:registering> | ||
568 | 17 | |||
569 | 18 | <div metal:fill-slot="main"> | ||
570 | 19 | |||
571 | 20 | <div class="yui-g"> | ||
572 | 21 | |||
573 | 22 | <div id="status" class="yui-u first"> | ||
574 | 23 | <div class="portlet"> | ||
575 | 24 | <div metal:use-macro="template/macros/status"/> | ||
576 | 25 | </div> | ||
577 | 26 | </div> | ||
578 | 27 | |||
579 | 28 | <div id="details" class="yui-u"> | ||
580 | 29 | <div class="portlet"> | ||
581 | 30 | <div metal:use-macro="template/macros/details"/> | ||
582 | 31 | </div> | ||
583 | 32 | </div> | ||
584 | 33 | |||
585 | 34 | </div> <!-- yui-g --> | ||
586 | 35 | |||
587 | 36 | <div id="files" class="portlet" tal:condition="view/has_files"> | ||
588 | 37 | <div metal:use-macro="template/macros/files"/> | ||
589 | 38 | </div> | ||
590 | 39 | |||
591 | 40 | <div id="buildlog" class="portlet" | ||
592 | 41 | tal:condition="context/status/enumvalue:BUILDING"> | ||
593 | 42 | <div metal:use-macro="template/macros/buildlog"/> | ||
594 | 43 | </div> | ||
595 | 44 | |||
596 | 45 | </div> <!-- main --> | ||
597 | 46 | |||
598 | 47 | |||
599 | 48 | <metal:macros fill-slot="bogus"> | ||
600 | 49 | |||
601 | 50 | <metal:macro define-macro="details"> | ||
602 | 51 | <tal:comment replace="nothing"> | ||
603 | 52 | Details section. | ||
604 | 53 | </tal:comment> | ||
605 | 54 | <h2>Build details</h2> | ||
606 | 55 | <div class="two-column-list"> | ||
607 | 56 | <dl> | ||
608 | 57 | <dt>Recipe:</dt> | ||
609 | 58 | <dd> | ||
610 | 59 | <tal:recipe replace="structure context/recipe/fmt:link"/> | ||
611 | 60 | </dd> | ||
612 | 61 | </dl> | ||
613 | 62 | <dl> | ||
614 | 63 | <dt>Series:</dt> | ||
615 | 64 | <dd><a class="sprite distribution" | ||
616 | 65 | tal:define="series context/distro_series" | ||
617 | 66 | tal:attributes="href series/fmt:url" | ||
618 | 67 | tal:content="series/displayname"/> | ||
619 | 68 | </dd> | ||
620 | 69 | </dl> | ||
621 | 70 | <dl> | ||
622 | 71 | <dt>Architecture:</dt> | ||
623 | 72 | <dd><a class="sprite distribution" | ||
624 | 73 | tal:define="archseries context/distro_arch_series" | ||
625 | 74 | tal:attributes="href archseries/fmt:url" | ||
626 | 75 | tal:content="archseries/architecturetag"/> | ||
627 | 76 | </dd> | ||
628 | 77 | </dl> | ||
629 | 78 | </div> | ||
630 | 79 | </metal:macro> | ||
631 | 80 | |||
632 | 81 | <metal:macro define-macro="status"> | ||
633 | 82 | <tal:comment replace="nothing"> | ||
634 | 83 | Status section. | ||
635 | 84 | </tal:comment> | ||
636 | 85 | <h2>Build status</h2> | ||
637 | 86 | <p> | ||
638 | 87 | <span tal:replace="structure context/image:icon" /> | ||
639 | 88 | <span tal:attributes=" | ||
640 | 89 | class string:buildstatus${context/status/name};" | ||
641 | 90 | tal:content="context/status/title"/> | ||
642 | 91 | <tal:building condition="context/status/enumvalue:BUILDING"> | ||
643 | 92 | on <a tal:content="context/buildqueue_record/builder/title" | ||
644 | 93 | tal:attributes="href context/buildqueue_record/builder/fmt:url"/> | ||
645 | 94 | </tal:building> | ||
646 | 95 | <tal:built condition="context/builder"> | ||
647 | 96 | on <a tal:content="context/builder/title" | ||
648 | 97 | tal:attributes="href context/builder/fmt:url"/> | ||
649 | 98 | </tal:built> | ||
650 | 99 | <tal:retry define="link context/menu:context/retry" | ||
651 | 100 | condition="link/enabled" | ||
652 | 101 | replace="structure link/fmt:link" /> | ||
653 | 102 | <tal:cancel define="link context/menu:context/cancel" | ||
654 | 103 | condition="link/enabled" | ||
655 | 104 | replace="structure link/fmt:link" /> | ||
656 | 105 | </p> | ||
657 | 106 | |||
658 | 107 | <ul> | ||
659 | 108 | <li id="revision-id" tal:condition="context/revision_id"> | ||
660 | 109 | Revision: <span tal:replace="context/revision_id" /> | ||
661 | 110 | </li> | ||
662 | 111 | <li tal:condition="context/dependencies"> | ||
663 | 112 | Missing build dependencies: <em tal:content="context/dependencies"/> | ||
664 | 113 | </li> | ||
665 | 114 | <tal:reallypending condition="context/buildqueue_record"> | ||
666 | 115 | <tal:pending condition="context/buildqueue_record/status/enumvalue:WAITING"> | ||
667 | 116 | <li tal:define="eta context/buildqueue_record/getEstimatedJobStartTime"> | ||
668 | 117 | Start <tal:eta replace="eta/fmt:approximatedate"/> | ||
669 | 118 | (<span tal:replace="context/buildqueue_record/lastscore"/>) | ||
670 | 119 | <a href="https://help.launchpad.net/Packaging/BuildScores" | ||
671 | 120 | target="_blank">What's this?</a> | ||
672 | 121 | </li> | ||
673 | 122 | </tal:pending> | ||
674 | 123 | </tal:reallypending> | ||
675 | 124 | <tal:started condition="context/date_started"> | ||
676 | 125 | <li tal:condition="context/date_started"> | ||
677 | 126 | Started <span | ||
678 | 127 | tal:define="start context/date_started" | ||
679 | 128 | tal:attributes="title start/fmt:datetime" | ||
680 | 129 | tal:content="start/fmt:displaydate"/> | ||
681 | 130 | </li> | ||
682 | 131 | </tal:started> | ||
683 | 132 | <tal:finish condition="not: context/date_finished"> | ||
684 | 133 | <li tal:define="eta context/eta" tal:condition="context/eta"> | ||
685 | 134 | Estimated finish <tal:eta replace="eta/fmt:approximatedate"/> | ||
686 | 135 | </li> | ||
687 | 136 | </tal:finish> | ||
688 | 137 | |||
689 | 138 | <li tal:condition="context/date_finished"> | ||
690 | 139 | Finished <span | ||
691 | 140 | tal:attributes="title context/date_finished/fmt:datetime" | ||
692 | 141 | tal:content="context/date_finished/fmt:displaydate"/> | ||
693 | 142 | <tal:duration condition="context/duration"> | ||
694 | 143 | (took <span tal:replace="context/duration/fmt:exactduration"/>) | ||
695 | 144 | </tal:duration> | ||
696 | 145 | </li> | ||
697 | 146 | <li tal:define="file context/log" | ||
698 | 147 | tal:condition="file"> | ||
699 | 148 | <a class="sprite download" | ||
700 | 149 | tal:attributes="href context/log_url">buildlog</a> | ||
701 | 150 | (<span tal:replace="file/content/filesize/fmt:bytes" />) | ||
702 | 151 | </li> | ||
703 | 152 | <li tal:define="file context/upload_log" | ||
704 | 153 | tal:condition="file"> | ||
705 | 154 | <a class="sprite download" | ||
706 | 155 | tal:attributes="href context/upload_log_url">uploadlog</a> | ||
707 | 156 | (<span tal:replace="file/content/filesize/fmt:bytes" />) | ||
708 | 157 | </li> | ||
709 | 158 | </ul> | ||
710 | 159 | |||
711 | 160 | <div | ||
712 | 161 | style="margin-top: 1.5em" | ||
713 | 162 | tal:define="link context/menu:context/rescore" | ||
714 | 163 | tal:condition="link/enabled" | ||
715 | 164 | > | ||
716 | 165 | <a tal:replace="structure link/fmt:link"/> | ||
717 | 166 | </div> | ||
718 | 167 | </metal:macro> | ||
719 | 168 | |||
720 | 169 | <metal:macro define-macro="files"> | ||
721 | 170 | <tal:comment replace="nothing"> | ||
722 | 171 | Files section. | ||
723 | 172 | </tal:comment> | ||
724 | 173 | <h2>Built files</h2> | ||
725 | 174 | <p>Files resulting from this build:</p> | ||
726 | 175 | <ul> | ||
727 | 176 | <li tal:repeat="file view/files"> | ||
728 | 177 | <a class="sprite download" | ||
729 | 178 | tal:content="file/filename" | ||
730 | 179 | tal:attributes="href file/http_url"/> | ||
731 | 180 | (<span tal:replace="file/content/filesize/fmt:bytes"/>) | ||
732 | 181 | </li> | ||
733 | 182 | </ul> | ||
734 | 183 | </metal:macro> | ||
735 | 184 | |||
736 | 185 | <metal:macro define-macro="buildlog"> | ||
737 | 186 | <tal:comment replace="nothing"> | ||
738 | 187 | Buildlog section. | ||
739 | 188 | </tal:comment> | ||
740 | 189 | <h2>Buildlog</h2> | ||
741 | 190 | <div id="buildlog-tail" class="logtail" | ||
742 | 191 | tal:define="logtail context/buildqueue_record/logtail" | ||
743 | 192 | tal:content="structure logtail/fmt:text-to-html"/> | ||
744 | 193 | <p class="lesser" tal:condition="view/user"> | ||
745 | 194 | Updated on <span tal:replace="structure view/user/fmt:local-time"/> | ||
746 | 195 | </p> | ||
747 | 196 | </metal:macro> | ||
748 | 197 | |||
749 | 198 | </metal:macros> | ||
750 | 199 | |||
751 | 200 | </body> | ||
752 | 201 | </html> | ||
753 | diff --git a/lib/lp/charms/templates/charmrecipebuild-retry.pt b/lib/lp/charms/templates/charmrecipebuild-retry.pt | |||
754 | 0 | new file mode 100644 | 202 | new file mode 100644 |
755 | index 0000000..ba08004 | |||
756 | --- /dev/null | |||
757 | +++ b/lib/lp/charms/templates/charmrecipebuild-retry.pt | |||
758 | @@ -0,0 +1,28 @@ | |||
759 | 1 | <html | ||
760 | 2 | xmlns="http://www.w3.org/1999/xhtml" | ||
761 | 3 | xmlns:tal="http://xml.zope.org/namespaces/tal" | ||
762 | 4 | xmlns:metal="http://xml.zope.org/namespaces/metal" | ||
763 | 5 | xmlns:i18n="http://xml.zope.org/namespaces/i18n" | ||
764 | 6 | metal:use-macro="view/macro:page/main_only" | ||
765 | 7 | i18n:domain="launchpad"> | ||
766 | 8 | <body> | ||
767 | 9 | |||
768 | 10 | <div metal:fill-slot="main"> | ||
769 | 11 | <div metal:use-macro="context/@@launchpad_form/form"> | ||
770 | 12 | <div metal:fill-slot="extra_info"> | ||
771 | 13 | <p> | ||
772 | 14 | The status of <dfn tal:content="context/title" /> is | ||
773 | 15 | <span tal:replace="context/status/title" />. | ||
774 | 16 | </p> | ||
775 | 17 | <p>Retrying this build will destroy its history and logs.</p> | ||
776 | 18 | <p> | ||
777 | 19 | By default, this build will be retried only after other pending | ||
778 | 20 | builds; please contact a build daemon administrator if you need | ||
779 | 21 | special treatment. | ||
780 | 22 | </p> | ||
781 | 23 | </div> | ||
782 | 24 | </div> | ||
783 | 25 | </div> | ||
784 | 26 | |||
785 | 27 | </body> | ||
786 | 28 | </html> |