Merge ~cjwatson/launchpad:charm-recipe-basic-browser into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 715606474db859cfcefc19c64b7324d3a5042551
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:charm-recipe-basic-browser
Merge into: launchpad:master
Prerequisite: ~cjwatson/launchpad:charm-recipe-build-behaviour
Diff against target: 894 lines (+719/-5)
9 files modified
lib/lp/app/browser/configure.zcml (+7/-1)
lib/lp/charms/browser/charmrecipe.py (+96/-1)
lib/lp/charms/browser/configure.zcml (+14/-0)
lib/lp/charms/browser/tests/test_charmrecipe.py (+265/-2)
lib/lp/charms/configure.zcml (+2/-0)
lib/lp/charms/help/charm-recipe-build-frequency.html (+38/-0)
lib/lp/charms/interfaces/charmrecipe.py (+41/-1)
lib/lp/charms/model/charmrecipe.py (+90/-0)
lib/lp/charms/templates/charmrecipe-index.pt (+166/-0)
Reviewer Review Type Date Requested Status
Cristian Gonzalez (community) Approve
Review via email: mp+403745@code.launchpad.net

Commit message

Add a basic CharmRecipe:+index view

To post a comment you must log in.
Revision history for this message
Cristian Gonzalez (cristiangsp) wrote :

Looks good!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/app/browser/configure.zcml b/lib/lp/app/browser/configure.zcml
2index be9d898..b08d380 100644
3--- a/lib/lp/app/browser/configure.zcml
4+++ b/lib/lp/app/browser/configure.zcml
5@@ -1,4 +1,4 @@
6-<!-- Copyright 2009-2020 Canonical Ltd. This software is licensed under the
7+<!-- Copyright 2009-2021 Canonical Ltd. This software is licensed under the
8 GNU Affero General Public License version 3 (see the file LICENSE).
9 -->
10
11@@ -582,6 +582,12 @@
12 factory="lp.app.browser.tales.BuildImageDisplayAPI"
13 name="image"
14 />
15+ <adapter
16+ for="lp.charms.interfaces.charmrecipebuild.ICharmRecipeBuild"
17+ provides="zope.traversing.interfaces.IPathAdapter"
18+ factory="lp.app.browser.tales.BuildImageDisplayAPI"
19+ name="image"
20+ />
21
22 <adapter
23 for="lp.soyuz.interfaces.archive.IArchive"
24diff --git a/lib/lp/charms/browser/charmrecipe.py b/lib/lp/charms/browser/charmrecipe.py
25index a111fca..e247bf0 100644
26--- a/lib/lp/charms/browser/charmrecipe.py
27+++ b/lib/lp/charms/browser/charmrecipe.py
28@@ -9,18 +9,27 @@ __metaclass__ = type
29 __all__ = [
30 "CharmRecipeNavigation",
31 "CharmRecipeURL",
32+ "CharmRecipeView",
33 ]
34
35 from zope.component import getUtility
36 from zope.interface import implementer
37+from zope.security.interfaces import Unauthorized
38
39 from lp.charms.interfaces.charmrecipe import ICharmRecipe
40 from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuildSet
41 from lp.registry.interfaces.personproduct import IPersonProductFactory
42+from lp.services.propertycache import cachedproperty
43+from lp.services.utils import seconds_since_epoch
44 from lp.services.webapp import (
45+ LaunchpadView,
46 Navigation,
47 stepthrough,
48 )
49+from lp.services.webapp.breadcrumb import (
50+ Breadcrumb,
51+ NameBreadcrumb,
52+ )
53 from lp.services.webapp.interfaces import ICanonicalUrlData
54 from lp.soyuz.browser.build import get_build_by_id_str
55
56@@ -28,7 +37,7 @@ from lp.soyuz.browser.build import get_build_by_id_str
57 @implementer(ICanonicalUrlData)
58 class CharmRecipeURL:
59 """Charm recipe URL creation rules."""
60- rootsite = 'mainsite'
61+ rootsite = "mainsite"
62
63 def __init__(self, recipe):
64 self.recipe = recipe
65@@ -61,3 +70,89 @@ class CharmRecipeNavigation(Navigation):
66 if build is None or build.recipe != self.context:
67 return None
68 return build
69+
70+
71+class CharmRecipeBreadcrumb(NameBreadcrumb):
72+
73+ @property
74+ def inside(self):
75+ # XXX cjwatson 2021-06-04: This should probably link to an
76+ # appropriate listing view, but we don't have one of those yet.
77+ return Breadcrumb(
78+ self.context.project, text=self.context.project.display_name,
79+ inside=self.context.project)
80+
81+
82+class CharmRecipeView(LaunchpadView):
83+ """Default view of a charm recipe."""
84+
85+ @cachedproperty
86+ def builds_and_requests(self):
87+ return builds_and_requests_for_recipe(self.context)
88+
89+ @property
90+ def build_frequency(self):
91+ if self.context.auto_build:
92+ return "Built automatically"
93+ else:
94+ return "Built on request"
95+
96+ @property
97+ def sorted_auto_build_channels_items(self):
98+ if self.context.auto_build_channels is None:
99+ return []
100+ return sorted(self.context.auto_build_channels.items())
101+
102+ @property
103+ def store_channels(self):
104+ return ", ".join(self.context.store_channels)
105+
106+ @property
107+ def user_can_see_source(self):
108+ try:
109+ return self.context.source.visibleByUser(self.user)
110+ except Unauthorized:
111+ return False
112+
113+
114+def builds_and_requests_for_recipe(recipe):
115+ """A list of interesting builds and build requests.
116+
117+ All pending builds and pending build requests are shown, as well as up
118+ to 10 recent builds and recent failed build requests. Pending items are
119+ ordered by the date they were created; recent items are ordered by the
120+ date they finished (if available) or the date they started (if the date
121+ they finished is not set due to an error). This allows started but
122+ unfinished builds to show up in the view but be discarded as more recent
123+ builds become available.
124+
125+ Builds that the user does not have permission to see are excluded (by
126+ the model code).
127+ """
128+ # We need to interleave items of different types, so SQL can't do all
129+ # the sorting for us.
130+ def make_sort_key(*date_attrs):
131+ def _sort_key(item):
132+ for date_attr in date_attrs:
133+ if getattr(item, date_attr, None) is not None:
134+ return -seconds_since_epoch(getattr(item, date_attr))
135+ return 0
136+
137+ return _sort_key
138+
139+ items = sorted(
140+ list(recipe.pending_builds) + list(recipe.pending_build_requests),
141+ key=make_sort_key("date_created", "date_requested"))
142+ if len(items) < 10:
143+ # We need to interleave two unbounded result sets, but we only need
144+ # enough items from them to make the total count up to 10. It's
145+ # simplest to just fetch the upper bound from each set and do our
146+ # own sorting.
147+ recent_items = sorted(
148+ list(recipe.completed_builds[:10 - len(items)]) +
149+ list(recipe.failed_build_requests[:10 - len(items)]),
150+ key=make_sort_key(
151+ "date_finished", "date_started",
152+ "date_created", "date_requested"))
153+ items.extend(recent_items[:10 - len(items)])
154+ return items
155diff --git a/lib/lp/charms/browser/configure.zcml b/lib/lp/charms/browser/configure.zcml
156index 1f5a8af..92d78bf 100644
157--- a/lib/lp/charms/browser/configure.zcml
158+++ b/lib/lp/charms/browser/configure.zcml
159@@ -11,9 +11,23 @@
160 <browser:url
161 for="lp.charms.interfaces.charmrecipe.ICharmRecipe"
162 urldata="lp.charms.browser.charmrecipe.CharmRecipeURL" />
163+ <browser:defaultView
164+ for="lp.charms.interfaces.charmrecipe.ICharmRecipe"
165+ name="+index" />
166+ <browser:page
167+ for="lp.charms.interfaces.charmrecipe.ICharmRecipe"
168+ class="lp.charms.browser.charmrecipe.CharmRecipeView"
169+ permission="launchpad.View"
170+ name="+index"
171+ template="../templates/charmrecipe-index.pt" />
172 <browser:navigation
173 module="lp.charms.browser.charmrecipe"
174 classes="CharmRecipeNavigation" />
175+ <adapter
176+ provides="lp.services.webapp.interfaces.IBreadcrumb"
177+ for="lp.charms.interfaces.charmrecipe.ICharmRecipe"
178+ factory="lp.charms.browser.charmrecipe.CharmRecipeBreadcrumb"
179+ permission="zope.Public" />
180 <browser:url
181 for="lp.charms.interfaces.charmrecipe.ICharmRecipeBuildRequest"
182 path_expression="string:+build-request/${id}"
183diff --git a/lib/lp/charms/browser/tests/test_charmrecipe.py b/lib/lp/charms/browser/tests/test_charmrecipe.py
184index 89f2945..fbad88e 100644
185--- a/lib/lp/charms/browser/tests/test_charmrecipe.py
186+++ b/lib/lp/charms/browser/tests/test_charmrecipe.py
187@@ -7,11 +7,48 @@ from __future__ import absolute_import, print_function, unicode_literals
188
189 __metaclass__ = type
190
191+from datetime import (
192+ datetime,
193+ timedelta,
194+ )
195+import re
196+
197+from fixtures import FakeLogger
198+import pytz
199+import soupmatchers
200+from testtools.matchers import (
201+ Equals,
202+ MatchesListwise,
203+ MatchesStructure,
204+ )
205+import transaction
206+from zope.component import getUtility
207+from zope.security.proxy import removeSecurityProxy
208+
209+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
210+from lp.buildmaster.enums import BuildStatus
211+from lp.buildmaster.interfaces.processor import IProcessorSet
212+from lp.charms.browser.charmrecipe import CharmRecipeView
213 from lp.charms.interfaces.charmrecipe import CHARM_RECIPE_ALLOW_CREATE
214 from lp.services.features.testing import FeatureFixture
215+from lp.services.job.interfaces.job import JobStatus
216+from lp.services.propertycache import get_property_cache
217 from lp.services.webapp import canonical_url
218-from lp.testing import TestCaseWithFactory
219-from lp.testing.layers import DatabaseFunctionalLayer
220+from lp.testing import (
221+ BrowserTestCase,
222+ person_logged_in,
223+ TestCaseWithFactory,
224+ time_counter,
225+ )
226+from lp.testing.layers import (
227+ DatabaseFunctionalLayer,
228+ LaunchpadFunctionalLayer,
229+ )
230+from lp.testing.publication import test_traverse
231+from lp.testing.views import (
232+ create_initialized_view,
233+ create_view,
234+ )
235
236
237 class TestCharmRecipeNavigation(TestCaseWithFactory):
238@@ -30,3 +67,229 @@ class TestCharmRecipeNavigation(TestCaseWithFactory):
239 self.assertEqual(
240 "http://launchpad.test/~person/project/+charm/charm",
241 canonical_url(recipe))
242+
243+ def test_charm_recipe(self):
244+ recipe = self.factory.makeCharmRecipe()
245+ obj, _, _ = test_traverse(
246+ "http://launchpad.test/~%s/%s/+charm/%s" % (
247+ recipe.owner.name, recipe.project.name, recipe.name))
248+ self.assertEqual(recipe, obj)
249+
250+
251+class BaseTestCharmRecipeView(BrowserTestCase):
252+
253+ layer = LaunchpadFunctionalLayer
254+
255+ def setUp(self):
256+ super(BaseTestCharmRecipeView, self).setUp()
257+ self.useFixture(FeatureFixture({CHARM_RECIPE_ALLOW_CREATE: "on"}))
258+ self.useFixture(FakeLogger())
259+ self.person = self.factory.makePerson(
260+ name="test-person", displayname="Test Person")
261+
262+
263+class TestCharmRecipeView(BaseTestCharmRecipeView):
264+
265+ def setUp(self):
266+ super(TestCharmRecipeView, self).setUp()
267+ self.project = self.factory.makeProduct(
268+ name="test-project", displayname="Test Project")
269+ self.ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
270+ self.distroseries = self.factory.makeDistroSeries(
271+ distribution=self.ubuntu)
272+ processor = getUtility(IProcessorSet).getByName("386")
273+ self.distroarchseries = self.factory.makeDistroArchSeries(
274+ distroseries=self.distroseries, architecturetag="i386",
275+ processor=processor)
276+ self.factory.makeBuilder(virtualized=True)
277+
278+ def makeCharmRecipe(self, **kwargs):
279+ if "project" not in kwargs:
280+ kwargs["project"] = self.project
281+ if "git_ref" not in kwargs:
282+ kwargs["git_ref"] = self.factory.makeGitRefs()[0]
283+ return self.factory.makeCharmRecipe(
284+ registrant=self.person, owner=self.person, name="charm-name",
285+ **kwargs)
286+
287+ def makeBuild(self, recipe=None, date_created=None, **kwargs):
288+ if recipe is None:
289+ recipe = self.makeCharmRecipe()
290+ if date_created is None:
291+ date_created = datetime.now(pytz.UTC) - timedelta(hours=1)
292+ build = self.factory.makeCharmRecipeBuild(
293+ requester=self.person, recipe=recipe,
294+ distro_arch_series=self.distroarchseries,
295+ date_created=date_created, **kwargs)
296+ job = removeSecurityProxy(
297+ removeSecurityProxy(build.build_request)._job)
298+ job.job._status = JobStatus.COMPLETED
299+ return build
300+
301+ def test_breadcrumb(self):
302+ recipe = self.makeCharmRecipe()
303+ view = create_view(recipe, "+index")
304+ # To test the breadcrumbs we need a correct traversal stack.
305+ view.request.traversed_objects = [
306+ recipe.owner, recipe.project, recipe, view]
307+ view.initialize()
308+ breadcrumbs_tag = soupmatchers.Tag(
309+ "breadcrumbs", "ol", attrs={"class": "breadcrumbs"})
310+ self.assertThat(
311+ view(),
312+ soupmatchers.HTMLContains(
313+ soupmatchers.Within(
314+ breadcrumbs_tag,
315+ soupmatchers.Tag(
316+ "project breadcrumb", "a",
317+ text="Test Project",
318+ attrs={"href": re.compile(r"/test-project$")})),
319+ soupmatchers.Within(
320+ breadcrumbs_tag,
321+ soupmatchers.Tag(
322+ "charm breadcrumb", "li",
323+ text=re.compile(r"\scharm-name\s")))))
324+
325+ def test_index_git(self):
326+ [ref] = self.factory.makeGitRefs(
327+ owner=self.person, target=self.project, name="charm-repository",
328+ paths=["refs/heads/master"])
329+ recipe = self.makeCharmRecipe(git_ref=ref)
330+ build = self.makeBuild(
331+ recipe=recipe, status=BuildStatus.FULLYBUILT,
332+ duration=timedelta(minutes=30))
333+ self.assertTextMatchesExpressionIgnoreWhitespace(r"""\
334+ Test Project
335+ charm-name
336+ .*
337+ Charm recipe information
338+ Owner: Test Person
339+ Project: Test Project
340+ Source: ~test-person/test-project/\+git/charm-repository:master
341+ Build schedule: \(\?\)
342+ Built on request
343+ Builds of this charm recipe are not automatically uploaded to
344+ the store.
345+ Latest builds
346+ Status When complete Architecture
347+ Successfully built 30 minutes ago i386
348+ """, self.getMainText(build.recipe))
349+
350+ def test_index_success_with_buildlog(self):
351+ # The build log is shown if it is there.
352+ build = self.makeBuild(
353+ status=BuildStatus.FULLYBUILT, duration=timedelta(minutes=30))
354+ build.setLog(self.factory.makeLibraryFileAlias())
355+ self.assertTextMatchesExpressionIgnoreWhitespace(r"""\
356+ Latest builds
357+ Status When complete Architecture
358+ Successfully built 30 minutes ago buildlog \(.*\) i386
359+ """, self.getMainText(build.recipe))
360+
361+ def test_index_no_builds(self):
362+ # A message is shown when there are no builds.
363+ recipe = self.makeCharmRecipe()
364+ self.assertIn(
365+ "This charm recipe has not been built yet.",
366+ self.getMainText(recipe))
367+
368+ def test_index_pending_build(self):
369+ # A pending build is listed as such.
370+ build = self.makeBuild()
371+ build.queueBuild()
372+ self.assertTextMatchesExpressionIgnoreWhitespace(r"""\
373+ Latest builds
374+ Status When complete Architecture
375+ Needs building in .* \(estimated\) i386
376+ """, self.getMainText(build.recipe))
377+
378+ def test_index_pending_build_request(self):
379+ # A pending build request is listed as such.
380+ recipe = self.makeCharmRecipe()
381+ with person_logged_in(recipe.owner):
382+ recipe.requestBuilds(recipe.owner)
383+ self.assertTextMatchesExpressionIgnoreWhitespace("""\
384+ Latest builds
385+ Status When complete Architecture
386+ Pending build request
387+ """, self.getMainText(recipe))
388+
389+ def test_index_failed_build_request(self):
390+ # A failed build request is listed as such, with its error message.
391+ recipe = self.makeCharmRecipe()
392+ with person_logged_in(recipe.owner):
393+ request = recipe.requestBuilds(recipe.owner)
394+ job = removeSecurityProxy(removeSecurityProxy(request)._job)
395+ job.job._status = JobStatus.FAILED
396+ job.job.date_finished = datetime.now(pytz.UTC) - timedelta(hours=1)
397+ job.error_message = "Boom"
398+ self.assertTextMatchesExpressionIgnoreWhitespace(r"""\
399+ Latest builds
400+ Status When complete Architecture
401+ Failed build request 1 hour ago \(Boom\)
402+ """, self.getMainText(recipe))
403+
404+ def setStatus(self, build, status):
405+ build.updateStatus(
406+ BuildStatus.BUILDING, date_started=build.date_created)
407+ build.updateStatus(
408+ status, date_finished=build.date_started + timedelta(minutes=30))
409+
410+ def test_builds_and_requests(self):
411+ # CharmRecipeView.builds_and_requests produces reasonable results,
412+ # interleaving build requests with builds.
413+ recipe = self.makeCharmRecipe()
414+ # Create oldest builds first so that they sort properly by id.
415+ date_gen = time_counter(
416+ datetime(2000, 1, 1, tzinfo=pytz.UTC), timedelta(days=1))
417+ builds = [
418+ self.makeBuild(recipe=recipe, date_created=next(date_gen))
419+ for i in range(3)]
420+ self.setStatus(builds[2], BuildStatus.FULLYBUILT)
421+ with person_logged_in(recipe.owner):
422+ request = recipe.requestBuilds(recipe.owner)
423+ job = removeSecurityProxy(removeSecurityProxy(request)._job)
424+ job.job.date_created = next(date_gen)
425+ view = CharmRecipeView(recipe, None)
426+ # The pending build request is interleaved in date order with
427+ # pending builds, and these are followed by completed builds.
428+ self.assertThat(view.builds_and_requests, MatchesListwise([
429+ MatchesStructure.byEquality(id=request.id),
430+ Equals(builds[1]),
431+ Equals(builds[0]),
432+ Equals(builds[2]),
433+ ]))
434+ transaction.commit()
435+ builds.append(self.makeBuild(recipe=recipe))
436+ del get_property_cache(view).builds_and_requests
437+ self.assertThat(view.builds_and_requests, MatchesListwise([
438+ Equals(builds[3]),
439+ MatchesStructure.byEquality(id=request.id),
440+ Equals(builds[1]),
441+ Equals(builds[0]),
442+ Equals(builds[2]),
443+ ]))
444+ # If we pretend that the job failed, it is still listed, but after
445+ # any pending builds.
446+ job.job._status = JobStatus.FAILED
447+ job.job.date_finished = job.date_created + timedelta(minutes=30)
448+ del get_property_cache(view).builds_and_requests
449+ self.assertThat(view.builds_and_requests, MatchesListwise([
450+ Equals(builds[3]),
451+ Equals(builds[1]),
452+ Equals(builds[0]),
453+ MatchesStructure.byEquality(id=request.id),
454+ Equals(builds[2]),
455+ ]))
456+
457+ def test_store_channels_empty(self):
458+ recipe = self.factory.makeCharmRecipe()
459+ view = create_initialized_view(recipe, "+index")
460+ self.assertEqual("", view.store_channels)
461+
462+ def test_store_channels_display(self):
463+ recipe = self.factory.makeCharmRecipe(
464+ store_channels=["track/stable/fix-123", "track/edge/fix-123"])
465+ view = create_initialized_view(recipe, "+index")
466+ self.assertEqual(
467+ "track/stable/fix-123, track/edge/fix-123", view.store_channels)
468diff --git a/lib/lp/charms/configure.zcml b/lib/lp/charms/configure.zcml
469index 68acb5e..9cda4c5 100644
470--- a/lib/lp/charms/configure.zcml
471+++ b/lib/lp/charms/configure.zcml
472@@ -12,6 +12,8 @@
473
474 <include package=".browser" />
475
476+ <lp:help-folder folder="help" name="+help-charms" />
477+
478 <!-- CharmRecipe -->
479 <class class="lp.charms.model.charmrecipe.CharmRecipe">
480 <require
481diff --git a/lib/lp/charms/help/charm-recipe-build-frequency.html b/lib/lp/charms/help/charm-recipe-build-frequency.html
482new file mode 100644
483index 0000000..b15cf50
484--- /dev/null
485+++ b/lib/lp/charms/help/charm-recipe-build-frequency.html
486@@ -0,0 +1,38 @@
487+<html>
488+ <head>
489+ <title>Charm recipe build schedule</title>
490+ <link rel="stylesheet" type="text/css"
491+ href="/+icing/yui/cssreset/reset.css" />
492+ <link rel="stylesheet" type="text/css"
493+ href="/+icing/yui/cssfonts/fonts.css" />
494+ <link rel="stylesheet" type="text/css"
495+ href="/+icing/yui/cssbase/base.css" />
496+ <style type="text/css">
497+ dt { font-weight: bold }
498+ dd p { margin-bottom: 0.5em }
499+ </style>
500+ </head>
501+ <body>
502+ <h1>Charm recipe build schedule</h1>
503+
504+ <p>There are two options for when charm recipes get built:</p>
505+ <dl>
506+ <dt>Built automatically</dt>
507+ <dd>
508+ <p>A build will be scheduled automatically once a change to the
509+ top-level source branch for the charm recipe is detected.</p>
510+ <p>If there has been a build of the charm recipe within the previous
511+ hour, the build will not be scheduled until an hour since the last
512+ build.</p>
513+ <p>If you really want the build to happen before the one-hour period
514+ is up, you can use the "Request builds" action.</p>
515+ </dd>
516+ <dt>Built on request</dt>
517+ <dd>
518+ <p>Builds of the charm recipe have to be manually requested using
519+ the "Request builds" action.</p>
520+ </dd>
521+ </dl>
522+
523+ </body>
524+</html>
525diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
526index 82a2e6a..6b70d1b 100644
527--- a/lib/lp/charms/interfaces/charmrecipe.py
528+++ b/lib/lp/charms/interfaces/charmrecipe.py
529@@ -41,7 +41,10 @@ from lazr.restful.fields import (
530 ReferenceChoice,
531 )
532 from six.moves import http_client
533-from zope.interface import Interface
534+from zope.interface import (
535+ Attribute,
536+ Interface,
537+ )
538 from zope.schema import (
539 Bool,
540 Choice,
541@@ -260,6 +263,9 @@ class ICharmRecipeView(Interface):
542 vocabulary="ValidPersonOrTeam",
543 description=_("The person who registered this charm recipe."))
544
545+ source = Attribute(
546+ "The source branch for this charm recipe (VCS-agnostic).")
547+
548 private = Bool(
549 title=_("Private"), required=False, readonly=False,
550 description=_("Whether this charm recipe is private."))
551@@ -330,6 +336,40 @@ class ICharmRecipeView(Interface):
552 :return: `ICharmRecipeBuildRequest`.
553 """
554
555+ pending_build_requests = CollectionField(
556+ title=_("Pending build requests for this charm recipe."),
557+ value_type=Reference(ICharmRecipeBuildRequest),
558+ required=True, readonly=True)
559+
560+ failed_build_requests = CollectionField(
561+ title=_("Failed build requests for this charm recipe."),
562+ value_type=Reference(ICharmRecipeBuildRequest),
563+ required=True, readonly=True)
564+
565+ builds = CollectionField(
566+ title=_("All builds of this charm recipe."),
567+ description=_(
568+ "All builds of this charm recipe, sorted in descending order "
569+ "of finishing (or starting if not completed successfully)."),
570+ # Really ICharmRecipeBuild.
571+ value_type=Reference(schema=Interface), readonly=True)
572+
573+ completed_builds = CollectionField(
574+ title=_("Completed builds of this charm recipe."),
575+ description=_(
576+ "Completed builds of this charm recipe, sorted in descending "
577+ "order of finishing."),
578+ # Really ICharmRecipeBuild.
579+ value_type=Reference(schema=Interface), readonly=True)
580+
581+ pending_builds = CollectionField(
582+ title=_("Pending builds of this charm recipe."),
583+ description=_(
584+ "Pending builds of this charm recipe, sorted in descending "
585+ "order of creation."),
586+ # Really ICharmRecipeBuild.
587+ value_type=Reference(schema=Interface), readonly=True)
588+
589
590 class ICharmRecipeEdit(Interface):
591 """`ICharmRecipe` methods that require launchpad.Edit permission."""
592diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
593index 28458ca..da24bf6 100644
594--- a/lib/lp/charms/model/charmrecipe.py
595+++ b/lib/lp/charms/model/charmrecipe.py
596@@ -21,8 +21,10 @@ from storm.databases.postgres import JSON
597 from storm.locals import (
598 Bool,
599 DateTime,
600+ Desc,
601 Int,
602 Join,
603+ Not,
604 Or,
605 Reference,
606 Store,
607@@ -41,6 +43,8 @@ from lp.app.enums import (
608 )
609 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
610 from lp.buildmaster.enums import BuildStatus
611+from lp.buildmaster.interfaces.buildqueue import IBuildQueueSet
612+from lp.buildmaster.model.builder import Builder
613 from lp.charms.adapters.buildarch import determine_instances_to_build
614 from lp.charms.interfaces.charmrecipe import (
615 CannotFetchCharmcraftYaml,
616@@ -94,6 +98,10 @@ from lp.services.database.interfaces import (
617 IStore,
618 )
619 from lp.services.database.stormbase import StormBase
620+from lp.services.database.stormexpr import (
621+ Greatest,
622+ NullsLast,
623+ )
624 from lp.services.features import getFeatureFlag
625 from lp.services.job.interfaces.job import JobStatus
626 from lp.services.librarian.model import LibraryFileAlias
627@@ -325,6 +333,11 @@ class CharmRecipe(StormBase):
628 self.git_path = None
629
630 @property
631+ def source(self):
632+ """See `ICharmRecipe`."""
633+ return self.git_ref
634+
635+ @property
636 def store_channels(self):
637 """See `ICharmRecipe`."""
638 return self._store_channels or []
639@@ -527,6 +540,83 @@ class CharmRecipe(StormBase):
640 """See `ICharmRecipe`."""
641 return CharmRecipeBuildRequest(self, job_id)
642
643+ @property
644+ def pending_build_requests(self):
645+ """See `ICharmRecipe`."""
646+ job_source = getUtility(ICharmRecipeRequestBuildsJobSource)
647+ # The returned jobs are ordered by descending ID.
648+ jobs = job_source.findByRecipe(
649+ self, statuses=(JobStatus.WAITING, JobStatus.RUNNING))
650+ return DecoratedResultSet(
651+ jobs, result_decorator=CharmRecipeBuildRequest.fromJob)
652+
653+ @property
654+ def failed_build_requests(self):
655+ """See `ICharmRecipe`."""
656+ job_source = getUtility(ICharmRecipeRequestBuildsJobSource)
657+ # The returned jobs are ordered by descending ID.
658+ jobs = job_source.findByRecipe(self, statuses=(JobStatus.FAILED,))
659+ return DecoratedResultSet(
660+ jobs, result_decorator=CharmRecipeBuildRequest.fromJob)
661+
662+ def _getBuilds(self, filter_term, order_by):
663+ """The actual query to get the builds."""
664+ query_args = [
665+ CharmRecipeBuild.recipe == self,
666+ ]
667+ if filter_term is not None:
668+ query_args.append(filter_term)
669+ result = Store.of(self).find(CharmRecipeBuild, *query_args)
670+ result.order_by(order_by)
671+
672+ def eager_load(rows):
673+ getUtility(ICharmRecipeBuildSet).preloadBuildsData(rows)
674+ getUtility(IBuildQueueSet).preloadForBuildFarmJobs(rows)
675+ load_related(Builder, rows, ["builder_id"])
676+
677+ return DecoratedResultSet(result, pre_iter_hook=eager_load)
678+
679+ @property
680+ def builds(self):
681+ """See `ICharmRecipe`."""
682+ order_by = (
683+ NullsLast(Desc(Greatest(
684+ CharmRecipeBuild.date_started,
685+ CharmRecipeBuild.date_finished))),
686+ Desc(CharmRecipeBuild.date_created),
687+ Desc(CharmRecipeBuild.id))
688+ return self._getBuilds(None, order_by)
689+
690+ @property
691+ def _pending_states(self):
692+ """All the build states we consider pending (non-final)."""
693+ return [
694+ BuildStatus.NEEDSBUILD,
695+ BuildStatus.BUILDING,
696+ BuildStatus.UPLOADING,
697+ BuildStatus.CANCELLING,
698+ ]
699+
700+ @property
701+ def completed_builds(self):
702+ """See `ICharmRecipe`."""
703+ filter_term = Not(CharmRecipeBuild.status.is_in(self._pending_states))
704+ order_by = (
705+ NullsLast(Desc(Greatest(
706+ CharmRecipeBuild.date_started,
707+ CharmRecipeBuild.date_finished))),
708+ Desc(CharmRecipeBuild.id))
709+ return self._getBuilds(filter_term, order_by)
710+
711+ @property
712+ def pending_builds(self):
713+ """See `ICharmRecipe`."""
714+ filter_term = (CharmRecipeBuild.status.is_in(self._pending_states))
715+ # We want to order by date_created but this is the same as ordering
716+ # by id (since id increases monotonically) and is less expensive.
717+ order_by = Desc(CharmRecipeBuild.id)
718+ return self._getBuilds(filter_term, order_by)
719+
720 def destroySelf(self):
721 """See `ICharmRecipe`."""
722 IStore(CharmRecipe).remove(self)
723diff --git a/lib/lp/charms/templates/charmrecipe-index.pt b/lib/lp/charms/templates/charmrecipe-index.pt
724new file mode 100644
725index 0000000..90a849a
726--- /dev/null
727+++ b/lib/lp/charms/templates/charmrecipe-index.pt
728@@ -0,0 +1,166 @@
729+<html
730+ xmlns="http://www.w3.org/1999/xhtml"
731+ xmlns:tal="http://xml.zope.org/namespaces/tal"
732+ xmlns:metal="http://xml.zope.org/namespaces/metal"
733+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
734+ metal:use-macro="view/macro:page/main_side"
735+ i18n:domain="launchpad"
736+>
737+
738+<body>
739+ <metal:registering fill-slot="registering">
740+ Created by
741+ <tal:registrant replace="structure context/registrant/fmt:link"/>
742+ on
743+ <tal:created-on replace="structure context/date_created/fmt:date"/>
744+ and last modified on
745+ <tal:last-modified replace="structure context/date_last_modified/fmt:date"/>
746+ </metal:registering>
747+
748+ <metal:side fill-slot="side">
749+ <div tal:replace="structure context/@@+global-actions"/>
750+ </metal:side>
751+
752+ <metal:heading fill-slot="heading">
753+ <h1 tal:content="context/name"/>
754+ </metal:heading>
755+
756+ <div metal:fill-slot="main">
757+ <h2>Charm recipe information</h2>
758+ <div class="two-column-list">
759+ <dl id="owner">
760+ <dt>Owner:</dt>
761+ <dd tal:content="structure context/owner/fmt:link"/>
762+ </dl>
763+ <dl id="project" tal:define="project context/project">
764+ <dt>Project:</dt>
765+ <dd>
766+ <a tal:attributes="href context/project/fmt:url"
767+ tal:content="context/project/display_name"/>
768+ </dd>
769+ </dl>
770+ <dl id="source"
771+ tal:define="source context/source" tal:condition="source">
772+ <dt>Source:</dt>
773+ <dd tal:condition="view/user_can_see_source">
774+ <a tal:replace="structure source/fmt:link"/>
775+ </dd>
776+ <dd tal:condition="not: view/user_can_see_source">
777+ <span class="sprite private">&lt;redacted&gt;</span>
778+ </dd>
779+ </dl>
780+
781+ <dl id="auto_build">
782+ <dt>Build schedule:
783+ <a href="/+help-charms/charm-recipe-build-frequency.html"
784+ target="help" class="sprite maybe action-icon">(?)</a>
785+ </dt>
786+ <dd>
787+ <span tal:replace="view/build_frequency"/>
788+ </dd>
789+ </dl>
790+ <dl id="auto_build_channels" tal:condition="context/auto_build_channels">
791+ <dt>
792+ Source snap channels for automatic builds:
793+ </dt>
794+ <dd>
795+ <table class="listing compressed">
796+ <tbody>
797+ <tr tal:repeat="pair view/sorted_auto_build_channels_items">
798+ <td tal:repeat="value pair" tal:content="value"/>
799+ </tr>
800+ </tbody>
801+ </table>
802+ </dd>
803+ </dl>
804+ </div>
805+
806+ <div id="store_upload" class="two-column-list"
807+ tal:condition="context/store_upload">
808+ <dl id="store_name">
809+ <dt>Registered store package name:</dt>
810+ <dd>
811+ <span tal:content="context/store_name"/>
812+ </dd>
813+ </dl>
814+ <dl id="store_channels" tal:condition="view/store_channels">
815+ <dt>Store channels:</dt>
816+ <dd>
817+ <span tal:content="view/store_channels"/>
818+ </dd>
819+ </dl>
820+ <p id="store_channels" tal:condition="not: view/store_channels">
821+ This charm recipe will not be released to any channels on the store.
822+ </p>
823+ </div>
824+ <p id="store_upload" tal:condition="not: context/store_upload">
825+ Builds of this charm recipe are not automatically uploaded to the store.
826+ </p>
827+
828+ <h2>Latest builds</h2>
829+ <table id="latest-builds-listing" class="listing"
830+ style="margin-bottom: 1em;">
831+ <thead>
832+ <tr>
833+ <th>Status</th>
834+ <th>When complete</th>
835+ <th>Architecture</th>
836+ </tr>
837+ </thead>
838+ <tbody>
839+ <tal:charm-recipe-builds-and-requests repeat="item view/builds_and_requests">
840+ <tal:charm-recipe-build-request condition="item/date_requested|nothing">
841+ <tr tal:define="request item"
842+ tal:attributes="id string:request-${request/id}">
843+ <td tal:attributes="class string:request_status ${request/status/name}">
844+ <span tal:replace="structure request/image:icon"/>
845+ <tal:title replace="request/status/title"/> build request
846+ </td>
847+ <td>
848+ <tal:date condition="request/date_finished"
849+ replace="structure request/date_finished/fmt:displaydatetitle"/>
850+ <tal:error-message condition="request/error_message">
851+ (<span tal:replace="request/error_message"/>)
852+ </tal:error-message>
853+ </td>
854+ <td/>
855+ </tr>
856+ </tal:charm-recipe-build-request>
857+ <tal:charm-recipe-build condition="not: item/date_requested|nothing">
858+ <tr tal:define="build item"
859+ tal:attributes="id string:build-${build/id}">
860+ <td tal:attributes="class string:build_status ${build/status/name}">
861+ <span tal:replace="structure build/image:icon"/>
862+ <a tal:content="build/status/title"
863+ tal:attributes="href build/fmt:url"/>
864+ </td>
865+ <td class="datebuilt">
866+ <tal:date replace="structure build/date/fmt:displaydatetitle"/>
867+ <tal:estimate condition="build/estimate">
868+ (estimated)
869+ </tal:estimate>
870+
871+ <tal:build-log define="file build/log" tal:condition="file">
872+ <a class="sprite download"
873+ tal:attributes="href build/log_url">buildlog</a>
874+ (<span tal:replace="file/content/filesize/fmt:bytes"/>)
875+ </tal:build-log>
876+ </td>
877+ <td>
878+ <a class="sprite distribution"
879+ tal:define="archseries build/distro_arch_series"
880+ tal:attributes="href archseries/fmt:url"
881+ tal:content="archseries/architecturetag"/>
882+ </td>
883+ </tr>
884+ </tal:charm-recipe-build>
885+ </tal:charm-recipe-builds-and-requests>
886+ </tbody>
887+ </table>
888+ <p tal:condition="not: view/builds_and_requests">
889+ This charm recipe has not been built yet.
890+ </p>
891+ </div>
892+
893+</body>
894+</html>

Subscribers

People subscribed via source and target branches

to status/vote changes: