Merge ~cjwatson/launchpad:oci-recipe-basic-views into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 2e18d1a3d1efabe33d6cf3f5ffa46512071fbab0
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:oci-recipe-basic-views
Merge into: launchpad:master
Prerequisite: ~cjwatson/launchpad:oci-recipe-model-bugs
Diff against target: 1696 lines (+1417/-7)
20 files modified
lib/lp/oci/browser/__init__.py (+0/-0)
lib/lp/oci/browser/configure.zcml (+82/-0)
lib/lp/oci/browser/ocirecipe.py (+291/-0)
lib/lp/oci/browser/ocirecipebuild.py (+43/-0)
lib/lp/oci/browser/tests/__init__.py (+0/-0)
lib/lp/oci/browser/tests/test_ocirecipe.py (+454/-0)
lib/lp/oci/browser/tests/test_ocirecipebuild.py (+80/-0)
lib/lp/oci/configure.zcml (+2/-0)
lib/lp/oci/interfaces/ocirecipe.py (+3/-0)
lib/lp/oci/interfaces/ocirecipebuild.py (+17/-1)
lib/lp/oci/model/ocirecipe.py (+5/-0)
lib/lp/oci/model/ocirecipebuild.py (+49/-0)
lib/lp/oci/templates/ocirecipe-index.pt (+118/-0)
lib/lp/oci/templates/ocirecipe-new.pt (+41/-0)
lib/lp/oci/templates/ocirecipebuild-index.pt (+151/-0)
lib/lp/oci/tests/test_ocirecipebuild.py (+31/-0)
lib/lp/registry/browser/configure.zcml (+2/-1)
lib/lp/registry/browser/ociproject.py (+25/-2)
lib/lp/registry/browser/personociproject.py (+11/-1)
lib/lp/registry/templates/ociproject-index.pt (+12/-2)
Reviewer Review Type Date Requested Status
Tom Wardill (community) Approve
Review via email: mp+379540@code.launchpad.net

Commit message

Add basic OCI recipe views

Description of the change

I've omitted several pieces (including most of the substantive tests of the OCIRecipeBuild views) until we have more of OCIRecipeBuild in place, but this at least allows creating, editing, and deleting OCI recipes as well as viewing any existing builds.

To post a comment you must log in.
Revision history for this message
Tom Wardill (twom) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/oci/browser/__init__.py b/lib/lp/oci/browser/__init__.py
2new file mode 100644
3index 0000000..e69de29
4--- /dev/null
5+++ b/lib/lp/oci/browser/__init__.py
6diff --git a/lib/lp/oci/browser/configure.zcml b/lib/lp/oci/browser/configure.zcml
7new file mode 100644
8index 0000000..e3fcc08
9--- /dev/null
10+++ b/lib/lp/oci/browser/configure.zcml
11@@ -0,0 +1,82 @@
12+<!-- Copyright 2020 Canonical Ltd. This software is licensed under the
13+ GNU Affero General Public License version 3 (see the file LICENSE).
14+-->
15+
16+<configure
17+ xmlns="http://namespaces.zope.org/zope"
18+ xmlns:browser="http://namespaces.zope.org/browser"
19+ xmlns:i18n="http://namespaces.zope.org/i18n"
20+ i18n_domain="launchpad">
21+ <facet facet="overview">
22+ <browser:url
23+ for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
24+ path_expression="string:${oci_project/pillar/name}/+oci/${oci_project/name}/+recipe/${name}"
25+ attribute_to_parent="owner" />
26+ <browser:menus
27+ module="lp.oci.browser.ocirecipe"
28+ classes="OCIRecipeNavigationMenu" />
29+ <browser:navigation
30+ module="lp.oci.browser.ocirecipe"
31+ classes="OCIRecipeNavigation" />
32+ <browser:defaultView
33+ for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
34+ name="+index" />
35+ <browser:page
36+ for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
37+ class="lp.oci.browser.ocirecipe.OCIRecipeView"
38+ permission="launchpad.View"
39+ name="+index"
40+ template="../templates/ocirecipe-index.pt" />
41+ <browser:page
42+ for="lp.registry.interfaces.ociproject.IOCIProject"
43+ class="lp.oci.browser.ocirecipe.OCIRecipeAddView"
44+ permission="launchpad.AnyLegitimatePerson"
45+ name="+new-recipe"
46+ template="../templates/ocirecipe-new.pt" />
47+ <browser:page
48+ for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
49+ class="lp.oci.browser.ocirecipe.OCIRecipeAdminView"
50+ permission="launchpad.Admin"
51+ name="+admin"
52+ template="../../app/templates/generic-edit.pt" />
53+ <browser:page
54+ for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
55+ class="lp.oci.browser.ocirecipe.OCIRecipeEditView"
56+ permission="launchpad.Edit"
57+ name="+edit"
58+ template="../../app/templates/generic-edit.pt" />
59+ <browser:page
60+ for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
61+ class="lp.oci.browser.ocirecipe.OCIRecipeDeleteView"
62+ permission="launchpad.Edit"
63+ name="+delete"
64+ template="../../app/templates/generic-edit.pt" />
65+ <adapter
66+ provides="lp.services.webapp.interfaces.IBreadcrumb"
67+ for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
68+ factory="lp.oci.browser.ocirecipe.OCIRecipeBreadcrumb"
69+ permission="zope.Public" />
70+
71+ <browser:url
72+ for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild"
73+ path_expression="string:+build/${id}"
74+ attribute_to_parent="recipe" />
75+ <browser:navigation
76+ module="lp.oci.browser.ocirecipebuild"
77+ classes="OCIRecipeBuildNavigation" />
78+ <browser:defaultView
79+ for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild"
80+ name="+index" />
81+ <browser:page
82+ for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild"
83+ class="lp.oci.browser.ocirecipebuild.OCIRecipeBuildView"
84+ permission="launchpad.View"
85+ name="+index"
86+ template="../templates/ocirecipebuild-index.pt" />
87+ <adapter
88+ provides="lp.services.webapp.interfaces.IBreadcrumb"
89+ for="lp.oci.interfaces.ocirecipebuild.IOCIRecipeBuild"
90+ factory="lp.services.webapp.breadcrumb.TitleBreadcrumb"
91+ permission="zope.Public" />
92+ </facet>
93+</configure>
94diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
95new file mode 100644
96index 0000000..483eb85
97--- /dev/null
98+++ b/lib/lp/oci/browser/ocirecipe.py
99@@ -0,0 +1,291 @@
100+# Copyright 2020 Canonical Ltd. This software is licensed under the
101+# GNU Affero General Public License version 3 (see the file LICENSE).
102+
103+"""OCI recipe views."""
104+
105+from __future__ import absolute_import, print_function, unicode_literals
106+
107+__metaclass__ = type
108+__all__ = [
109+ 'OCIRecipeAddView',
110+ 'OCIRecipeAdminView',
111+ 'OCIRecipeDeleteView',
112+ 'OCIRecipeEditView',
113+ 'OCIRecipeNavigation',
114+ 'OCIRecipeNavigationMenu',
115+ 'OCIRecipeView',
116+ ]
117+
118+from lazr.restful.interface import (
119+ copy_field,
120+ use_template,
121+ )
122+from zope.component import getUtility
123+from zope.interface import Interface
124+
125+from lp.app.browser.launchpadform import (
126+ action,
127+ LaunchpadEditFormView,
128+ LaunchpadFormView,
129+ )
130+from lp.app.browser.lazrjs import InlinePersonEditPickerWidget
131+from lp.app.browser.tales import format_link
132+from lp.code.browser.widgets.gitref import GitRefWidget
133+from lp.oci.interfaces.ocirecipe import (
134+ IOCIRecipe,
135+ IOCIRecipeSet,
136+ NoSuchOCIRecipe,
137+ )
138+from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
139+from lp.services.propertycache import cachedproperty
140+from lp.services.webapp import (
141+ canonical_url,
142+ enabled_with_permission,
143+ LaunchpadView,
144+ Link,
145+ Navigation,
146+ NavigationMenu,
147+ stepthrough,
148+ )
149+from lp.services.webapp.breadcrumb import NameBreadcrumb
150+from lp.soyuz.browser.build import get_build_by_id_str
151+
152+
153+class OCIRecipeNavigation(Navigation):
154+
155+ usedfor = IOCIRecipe
156+
157+ @stepthrough('+build')
158+ def traverse_build(self, name):
159+ build = get_build_by_id_str(IOCIRecipeBuildSet, name)
160+ if build is None or build.recipe != self.context:
161+ return None
162+ return build
163+
164+
165+class OCIRecipeBreadcrumb(NameBreadcrumb):
166+
167+ @property
168+ def inside(self):
169+ return self.context.oci_project
170+
171+
172+class OCIRecipeNavigationMenu(NavigationMenu):
173+ """Navigation menu for OCI recipes."""
174+
175+ usedfor = IOCIRecipe
176+
177+ facet = "overview"
178+
179+ links = ("admin", "edit", "delete")
180+
181+ @enabled_with_permission("launchpad.Admin")
182+ def admin(self):
183+ return Link("+admin", "Administer OCI recipe", icon="edit")
184+
185+ @enabled_with_permission("launchpad.Edit")
186+ def edit(self):
187+ return Link("+edit", "Edit OCI recipe", icon="edit")
188+
189+ @enabled_with_permission("launchpad.Edit")
190+ def delete(self):
191+ return Link("+delete", "Delete OCI recipe", icon="trash-icon")
192+
193+
194+class OCIRecipeView(LaunchpadView):
195+ """Default view of an OCI recipe."""
196+
197+ @cachedproperty
198+ def builds(self):
199+ return builds_for_recipe(self.context)
200+
201+ @property
202+ def person_picker(self):
203+ field = copy_field(
204+ IOCIRecipe["owner"],
205+ vocabularyName="AllUserTeamsParticipationPlusSelfSimpleDisplay")
206+ return InlinePersonEditPickerWidget(
207+ self.context, field, format_link(self.context.owner),
208+ header="Change owner", step_title="Select a new owner")
209+
210+ @property
211+ def build_frequency(self):
212+ if self.context.build_daily:
213+ return "Built daily"
214+ else:
215+ return "Built on request"
216+
217+
218+def builds_for_recipe(recipe):
219+ """A list of interesting builds.
220+
221+ All pending builds are shown, as well as 1-10 recent builds. Recent
222+ builds are ordered by date finished (if completed) or date_started (if
223+ date finished is not set due to an error building or other circumstance
224+ which resulted in the build not being completed). This allows started
225+ but unfinished builds to show up in the view but be discarded as more
226+ recent builds become available.
227+
228+ Builds that the user does not have permission to see are excluded (by
229+ the model code).
230+ """
231+ builds = list(recipe.pending_builds)
232+ if len(builds) < 10:
233+ builds.extend(recipe.completed_builds[:10 - len(builds)])
234+ return builds
235+
236+
237+class IOCIRecipeEditSchema(Interface):
238+ """Schema for adding or editing an OCI recipe."""
239+
240+ use_template(IOCIRecipe, include=[
241+ "name",
242+ "owner",
243+ "description",
244+ "git_ref",
245+ "build_file",
246+ "build_daily",
247+ "require_virtualized",
248+ ])
249+
250+
251+class OCIRecipeAddView(LaunchpadFormView):
252+ """View for creating OCI recipes."""
253+
254+ page_title = label = "Create a new OCI recipe"
255+
256+ schema = IOCIRecipeEditSchema
257+ field_names = (
258+ "name",
259+ "owner",
260+ "description",
261+ "git_ref",
262+ "build_file",
263+ "build_daily",
264+ )
265+ custom_widget_git_ref = GitRefWidget
266+
267+ @property
268+ def cancel_url(self):
269+ """See `LaunchpadFormView`."""
270+ return canonical_url(self.context)
271+
272+ @property
273+ def initial_values(self):
274+ """See `LaunchpadFormView`."""
275+ return {
276+ "owner": self.user,
277+ "build_file": "Dockerfile",
278+ }
279+
280+ def validate(self, data):
281+ """See `LaunchpadFormView`."""
282+ super(OCIRecipeAddView, self).validate(data)
283+ owner = data.get("owner", None)
284+ name = data.get("name", None)
285+ if owner and name:
286+ if getUtility(IOCIRecipeSet).exists(owner, self.context, name):
287+ self.setFieldError(
288+ "name",
289+ "There is already an OCI recipe owned by %s in %s with "
290+ "this name." % (
291+ owner.display_name, self.context.display_name))
292+
293+ @action("Create OCI recipe", name="create")
294+ def create_action(self, action, data):
295+ recipe = getUtility(IOCIRecipeSet).new(
296+ name=data["name"], registrant=self.user, owner=data["owner"],
297+ oci_project=self.context, git_ref=data["git_ref"],
298+ build_file=data["build_file"], description=data["description"])
299+ self.next_url = canonical_url(recipe)
300+
301+
302+class BaseOCIRecipeEditView(LaunchpadEditFormView):
303+
304+ schema = IOCIRecipeEditSchema
305+
306+ @property
307+ def cancel_url(self):
308+ """See `LaunchpadFormView`."""
309+ return canonical_url(self.context)
310+
311+ @action("Update OCI recipe", name="update")
312+ def request_action(self, action, data):
313+ self.updateContextFromData(data)
314+ self.next_url = canonical_url(self.context)
315+
316+ @property
317+ def adapters(self):
318+ """See `LaunchpadFormView`."""
319+ return {IOCIRecipeEditSchema: self.context}
320+
321+
322+class OCIRecipeAdminView(BaseOCIRecipeEditView):
323+ """View for administering OCI recipes."""
324+
325+ @property
326+ def label(self):
327+ return "Administer %s OCI recipe" % self.context.name
328+
329+ page_title = "Administer"
330+
331+ field_names = ("require_virtualized",)
332+
333+
334+class OCIRecipeEditView(BaseOCIRecipeEditView):
335+ """View for editing OCI recipes."""
336+
337+ @property
338+ def label(self):
339+ return "Edit %s OCI recipe" % self.context.name
340+
341+ page_title = "Edit"
342+
343+ field_names = (
344+ "owner",
345+ "name",
346+ "description",
347+ "git_ref",
348+ "build_file",
349+ "build_daily",
350+ )
351+ custom_widget_git_ref = GitRefWidget
352+
353+ def validate(self, data):
354+ """See `LaunchpadFormView`."""
355+ super(OCIRecipeEditView, self).validate(data)
356+ # XXX cjwatson 2020-02-18: We should permit and check moving recipes
357+ # between OCI projects too.
358+ owner = data.get("owner", None)
359+ name = data.get("name", None)
360+ if owner and name:
361+ try:
362+ recipe = getUtility(IOCIRecipeSet).getByName(
363+ owner, self.context.oci_project, name)
364+ if recipe != self.context:
365+ self.setFieldError(
366+ "name",
367+ "There is already an OCI recipe owned by %s in %s "
368+ "with this name." % (
369+ owner.display_name,
370+ self.context.oci_project.display_name))
371+ except NoSuchOCIRecipe:
372+ pass
373+
374+
375+class OCIRecipeDeleteView(BaseOCIRecipeEditView):
376+ """View for deleting OCI recipes."""
377+
378+ @property
379+ def label(self):
380+ return "Delete %s OCI recipe" % self.context.name
381+
382+ page_title = "Delete"
383+
384+ field_names = ()
385+
386+ @action("Delete OCI recipe", name="delete")
387+ def delete_action(self, action, data):
388+ oci_project = self.context.oci_project
389+ self.context.destroySelf()
390+ self.next_url = canonical_url(oci_project)
391diff --git a/lib/lp/oci/browser/ocirecipebuild.py b/lib/lp/oci/browser/ocirecipebuild.py
392new file mode 100644
393index 0000000..8b3a727
394--- /dev/null
395+++ b/lib/lp/oci/browser/ocirecipebuild.py
396@@ -0,0 +1,43 @@
397+# Copyright 2020 Canonical Ltd. This software is licensed under the
398+# GNU Affero General Public License version 3 (see the file LICENSE).
399+
400+"""OCI recipe build views."""
401+
402+from __future__ import absolute_import, print_function, unicode_literals
403+
404+__metaclass__ = type
405+__all__ = [
406+ 'OCIRecipeBuildNavigation',
407+ 'OCIRecipeBuildView',
408+ ]
409+
410+from zope.interface import Interface
411+
412+from lp.app.browser.launchpadform import LaunchpadFormView
413+from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuild
414+from lp.services.webapp import (
415+ canonical_url,
416+ Navigation,
417+ )
418+
419+
420+class OCIRecipeBuildNavigation(Navigation):
421+
422+ usedfor = IOCIRecipeBuild
423+
424+
425+class OCIRecipeBuildView(LaunchpadFormView):
426+ """Default view of an OCIRecipeBuild."""
427+
428+ class schema(Interface):
429+ """Schema for uploading a build."""
430+
431+ @property
432+ def label(self):
433+ return self.context.title
434+
435+ page_title = label
436+
437+ @property
438+ def next_url(self):
439+ return canonical_url(self.context)
440diff --git a/lib/lp/oci/browser/tests/__init__.py b/lib/lp/oci/browser/tests/__init__.py
441new file mode 100644
442index 0000000..e69de29
443--- /dev/null
444+++ b/lib/lp/oci/browser/tests/__init__.py
445diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
446new file mode 100644
447index 0000000..7933074
448--- /dev/null
449+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
450@@ -0,0 +1,454 @@
451+# Copyright 2020 Canonical Ltd. This software is licensed under the
452+# GNU Affero General Public License version 3 (see the file LICENSE).
453+
454+"""Test OCI recipe views."""
455+
456+from __future__ import absolute_import, print_function, unicode_literals
457+
458+__metaclass__ = type
459+
460+from datetime import (
461+ datetime,
462+ timedelta,
463+ )
464+import re
465+
466+from fixtures import FakeLogger
467+import pytz
468+import soupmatchers
469+from zope.component import getUtility
470+from zope.publisher.interfaces import NotFound
471+from zope.security.interfaces import Unauthorized
472+from zope.testbrowser.browser import LinkNotFoundError
473+
474+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
475+from lp.buildmaster.enums import BuildStatus
476+from lp.buildmaster.interfaces.processor import IProcessorSet
477+from lp.oci.browser.ocirecipe import (
478+ OCIRecipeAdminView,
479+ OCIRecipeEditView,
480+ OCIRecipeView,
481+ )
482+from lp.services.database.constants import UTC_NOW
483+from lp.services.propertycache import get_property_cache
484+from lp.services.webapp import canonical_url
485+from lp.services.webapp.servers import LaunchpadTestRequest
486+from lp.testing import (
487+ BrowserTestCase,
488+ login,
489+ login_person,
490+ person_logged_in,
491+ TestCaseWithFactory,
492+ time_counter,
493+ )
494+from lp.testing.layers import (
495+ DatabaseFunctionalLayer,
496+ LaunchpadFunctionalLayer,
497+ )
498+from lp.testing.matchers import (
499+ MatchesPickerText,
500+ MatchesTagText,
501+ )
502+from lp.testing.pages import (
503+ extract_text,
504+ find_main_content,
505+ find_tags_by_class,
506+ )
507+from lp.testing.publication import test_traverse
508+from lp.testing.views import create_view
509+
510+
511+class TestOCIRecipeNavigation(TestCaseWithFactory):
512+
513+ layer = DatabaseFunctionalLayer
514+
515+ def test_canonical_url(self):
516+ owner = self.factory.makePerson(name="person")
517+ distribution = self.factory.makeDistribution(name="distro")
518+ oci_project = self.factory.makeOCIProject(
519+ pillar=distribution, ociprojectname="oci-project")
520+ recipe = self.factory.makeOCIRecipe(
521+ name="recipe", registrant=owner, owner=owner,
522+ oci_project=oci_project)
523+ self.assertEqual(
524+ "http://launchpad.test/~person/distro/+oci/oci-project/"
525+ "+recipe/recipe", canonical_url(recipe))
526+
527+ def test_recipe(self):
528+ recipe = self.factory.makeOCIRecipe()
529+ obj, _, _ = test_traverse(
530+ "http://launchpad.test/~%s/%s/+oci/%s/+recipe/%s" % (
531+ recipe.owner.name, recipe.oci_project.pillar.name,
532+ recipe.oci_project.name, recipe.name))
533+ self.assertEqual(recipe, obj)
534+
535+
536+class BaseTestOCIRecipeView(BrowserTestCase):
537+
538+ layer = LaunchpadFunctionalLayer
539+
540+ def setUp(self):
541+ super(BaseTestOCIRecipeView, self).setUp()
542+ self.useFixture(FakeLogger())
543+ self.person = self.factory.makePerson(
544+ name="test-person", displayname="Test Person")
545+
546+
547+class TestOCIRecipeAddView(BaseTestOCIRecipeView):
548+
549+ def test_create_new_recipe_not_logged_in(self):
550+ oci_project = self.factory.makeOCIProject()
551+ self.assertRaises(
552+ Unauthorized, self.getViewBrowser, oci_project,
553+ view_name="+new-recipe", no_login=True)
554+
555+ def test_create_new_recipe(self):
556+ oci_project = self.factory.makeOCIProject()
557+ oci_project_display = oci_project.display_name
558+ [git_ref] = self.factory.makeGitRefs()
559+ source_display = git_ref.display_name
560+ browser = self.getViewBrowser(
561+ oci_project, view_name="+new-recipe", user=self.person)
562+ browser.getControl(name="field.name").value = "recipe-name"
563+ browser.getControl("Description").value = "Recipe description"
564+ browser.getControl("Git repository").value = (
565+ git_ref.repository.identity)
566+ browser.getControl("Git branch").value = git_ref.path
567+ browser.getControl("Create OCI recipe").click()
568+
569+ content = find_main_content(browser.contents)
570+ self.assertEqual("recipe-name", extract_text(content.h1))
571+ self.assertThat(
572+ "Recipe description",
573+ MatchesTagText(content, "recipe-description"))
574+ self.assertThat(
575+ "Test Person", MatchesPickerText(content, "edit-owner"))
576+ self.assertThat(
577+ "OCI project:\n%s" % oci_project_display,
578+ MatchesTagText(content, "oci-project"))
579+ self.assertThat(
580+ "Source:\n%s\nEdit OCI recipe" % source_display,
581+ MatchesTagText(content, "source"))
582+ self.assertThat(
583+ "Build file path:\nDockerfile\nEdit OCI recipe",
584+ MatchesTagText(content, "build-file"))
585+ self.assertThat(
586+ "Build schedule:\nBuilt on request\nEdit OCI recipe\n",
587+ MatchesTagText(content, "build-schedule"))
588+
589+ def test_create_new_recipe_users_teams_as_owner_options(self):
590+ # Teams that the user is in are options for the OCI recipe owner.
591+ self.factory.makeTeam(
592+ name="test-team", displayname="Test Team", members=[self.person])
593+ oci_project = self.factory.makeOCIProject()
594+ browser = self.getViewBrowser(
595+ oci_project, view_name="+new-recipe", user=self.person)
596+ options = browser.getControl("Owner").displayOptions
597+ self.assertEqual(
598+ ["Test Person (test-person)", "Test Team (test-team)"],
599+ sorted(str(option) for option in options))
600+
601+
602+class TestOCIRecipeAdminView(BaseTestOCIRecipeView):
603+
604+ def test_unauthorized(self):
605+ # A non-admin user cannot administer an OCI recipe.
606+ login_person(self.person)
607+ recipe = self.factory.makeOCIRecipe(registrant=self.person)
608+ recipe_url = canonical_url(recipe)
609+ browser = self.getViewBrowser(recipe, user=self.person)
610+ self.assertRaises(
611+ LinkNotFoundError, browser.getLink, "Administer OCI recipe")
612+ self.assertRaises(
613+ Unauthorized, self.getUserBrowser, recipe_url + "/+admin",
614+ user=self.person)
615+
616+ def test_admin_recipe(self):
617+ # Admins can change require_virtualized.
618+ login("admin@canonical.com")
619+ commercial_admin = self.factory.makePerson(
620+ member_of=[getUtility(ILaunchpadCelebrities).commercial_admin])
621+ login_person(self.person)
622+ recipe = self.factory.makeOCIRecipe(registrant=self.person)
623+ self.assertTrue(recipe.require_virtualized)
624+
625+ browser = self.getViewBrowser(recipe, user=commercial_admin)
626+ browser.getLink("Administer OCI recipe").click()
627+ browser.getControl("Require virtualized builders").selected = False
628+ browser.getControl("Update OCI recipe").click()
629+
630+ login_person(self.person)
631+ self.assertFalse(recipe.require_virtualized)
632+
633+ def test_admin_recipe_sets_date_last_modified(self):
634+ # Administering an OCI recipe sets the date_last_modified property.
635+ login("admin@canonical.com")
636+ ppa_admin = self.factory.makePerson(
637+ member_of=[getUtility(ILaunchpadCelebrities).ppa_admin])
638+ login_person(self.person)
639+ date_created = datetime(2000, 1, 1, tzinfo=pytz.UTC)
640+ recipe = self.factory.makeOCIRecipe(
641+ registrant=self.person, date_created=date_created)
642+ login_person(ppa_admin)
643+ view = OCIRecipeAdminView(recipe, LaunchpadTestRequest())
644+ view.initialize()
645+ view.request_action.success({"require_virtualized": False})
646+ self.assertSqlAttributeEqualsDate(
647+ recipe, "date_last_modified", UTC_NOW)
648+
649+
650+class TestOCIRecipeEditView(BaseTestOCIRecipeView):
651+
652+ def test_edit_recipe(self):
653+ oci_project = self.factory.makeOCIProject()
654+ oci_project_display = oci_project.display_name
655+ [old_git_ref] = self.factory.makeGitRefs()
656+ recipe = self.factory.makeOCIRecipe(
657+ registrant=self.person, owner=self.person,
658+ oci_project=oci_project, git_ref=old_git_ref)
659+ self.factory.makeTeam(
660+ name="new-team", displayname="New Team", members=[self.person])
661+ [new_git_ref] = self.factory.makeGitRefs()
662+
663+ browser = self.getViewBrowser(recipe, user=self.person)
664+ browser.getLink("Edit OCI recipe").click()
665+ browser.getControl("Owner").value = ["new-team"]
666+ browser.getControl(name="field.name").value = "new-name"
667+ browser.getControl("Description").value = "New description"
668+ browser.getControl("Git repository").value = (
669+ new_git_ref.repository.identity)
670+ browser.getControl("Git branch").value = new_git_ref.path
671+ browser.getControl("Build file path").value = "Dockerfile-2"
672+ browser.getControl("Build daily").selected = True
673+ browser.getControl("Update OCI recipe").click()
674+
675+ content = find_main_content(browser.contents)
676+ self.assertEqual("new-name", extract_text(content.h1))
677+ self.assertThat("New Team", MatchesPickerText(content, "edit-owner"))
678+ self.assertThat(
679+ "OCI project:\n%s" % oci_project_display,
680+ MatchesTagText(content, "oci-project"))
681+ self.assertThat(
682+ "Source:\n%s\nEdit OCI recipe" % new_git_ref.display_name,
683+ MatchesTagText(content, "source"))
684+ self.assertThat(
685+ "Build file path:\nDockerfile-2\nEdit OCI recipe",
686+ MatchesTagText(content, "build-file"))
687+ self.assertThat(
688+ "Build schedule:\nBuilt daily\nEdit OCI recipe\n",
689+ MatchesTagText(content, "build-schedule"))
690+
691+ def test_edit_recipe_sets_date_last_modified(self):
692+ # Editing an OCI recipe sets the date_last_modified property.
693+ date_created = datetime(2000, 1, 1, tzinfo=pytz.UTC)
694+ recipe = self.factory.makeOCIRecipe(
695+ registrant=self.person, date_created=date_created)
696+ with person_logged_in(self.person):
697+ view = OCIRecipeEditView(recipe, LaunchpadTestRequest())
698+ view.initialize()
699+ view.request_action.success({
700+ "owner": recipe.owner,
701+ "name": "changed",
702+ "description": "changed",
703+ })
704+ self.assertSqlAttributeEqualsDate(
705+ recipe, "date_last_modified", UTC_NOW)
706+
707+ def test_edit_recipe_already_exists(self):
708+ oci_project = self.factory.makeOCIProject()
709+ oci_project_display = oci_project.display_name
710+ recipe = self.factory.makeOCIRecipe(
711+ registrant=self.person, owner=self.person,
712+ oci_project=oci_project, name="one")
713+ self.factory.makeOCIRecipe(
714+ registrant=self.person, owner=self.person,
715+ oci_project=oci_project, name="two")
716+ browser = self.getViewBrowser(recipe, user=self.person)
717+ browser.getLink("Edit OCI recipe").click()
718+ browser.getControl(name="field.name").value = "two"
719+ browser.getControl("Update OCI recipe").click()
720+ self.assertEqual(
721+ "There is already an OCI recipe owned by Test Person in %s with "
722+ "this name." % oci_project_display,
723+ extract_text(find_tags_by_class(browser.contents, "message")[1]))
724+
725+
726+class TestOCIRecipeDeleteView(BaseTestOCIRecipeView):
727+
728+ def test_unauthorized(self):
729+ # A user without edit access cannot delete an OCI recipe.
730+ recipe = self.factory.makeOCIRecipe(
731+ registrant=self.person, owner=self.person)
732+ recipe_url = canonical_url(recipe)
733+ other_person = self.factory.makePerson()
734+ browser = self.getViewBrowser(recipe, user=other_person)
735+ self.assertRaises(
736+ LinkNotFoundError, browser.getLink, "Delete OCI recipe")
737+ self.assertRaises(
738+ Unauthorized, self.getUserBrowser, recipe_url + "/+delete",
739+ user=other_person)
740+
741+ def test_delete_recipe_without_builds(self):
742+ # An OCI recipe without builds can be deleted.
743+ recipe = self.factory.makeOCIRecipe(
744+ registrant=self.person, owner=self.person)
745+ recipe_url = canonical_url(recipe)
746+ oci_project_url = canonical_url(recipe.oci_project)
747+ browser = self.getViewBrowser(recipe, user=self.person)
748+ browser.getLink("Delete OCI recipe").click()
749+ browser.getControl("Delete OCI recipe").click()
750+ self.assertEqual(oci_project_url, browser.url)
751+ self.assertRaises(NotFound, browser.open, recipe_url)
752+
753+ def test_delete_recipe_with_builds(self):
754+ # An OCI recipe with builds can be deleted.
755+ recipe = self.factory.makeOCIRecipe(
756+ registrant=self.person, owner=self.person)
757+ self.factory.makeOCIRecipeBuild(recipe=recipe)
758+ # XXX cjwatson 2020-02-19: This should also add a file to the build
759+ # once that works.
760+ recipe_url = canonical_url(recipe)
761+ oci_project_url = canonical_url(recipe.oci_project)
762+ browser = self.getViewBrowser(recipe, user=self.person)
763+ browser.getLink("Delete OCI recipe").click()
764+ browser.getControl("Delete OCI recipe").click()
765+ self.assertEqual(oci_project_url, browser.url)
766+ self.assertRaises(NotFound, browser.open, recipe_url)
767+
768+
769+class TestOCIRecipeView(BaseTestOCIRecipeView):
770+
771+ def setUp(self):
772+ super(TestOCIRecipeView, self).setUp()
773+ self.distroseries = self.factory.makeDistroSeries()
774+ processor = getUtility(IProcessorSet).getByName("386")
775+ self.distroarchseries = self.factory.makeDistroArchSeries(
776+ distroseries=self.distroseries, architecturetag="i386",
777+ processor=processor)
778+ self.factory.makeBuilder(virtualized=True)
779+
780+ def makeOCIRecipe(self, **kwargs):
781+ return self.factory.makeOCIRecipe(
782+ registrant=self.person, owner=self.person, name="recipe-name",
783+ **kwargs)
784+
785+ def makeBuild(self, recipe=None, date_created=None, **kwargs):
786+ if recipe is None:
787+ recipe = self.makeOCIRecipe()
788+ if date_created is None:
789+ date_created = datetime.now(pytz.UTC) - timedelta(hours=1)
790+ return self.factory.makeOCIRecipeBuild(
791+ requester=self.person, recipe=recipe,
792+ distro_arch_series=self.distroarchseries,
793+ date_created=date_created, **kwargs)
794+
795+ def test_breadcrumb(self):
796+ oci_project = self.factory.makeOCIProject()
797+ oci_project_name = oci_project.name
798+ oci_project_url = canonical_url(oci_project)
799+ recipe = self.makeOCIRecipe(oci_project=oci_project)
800+ view = create_view(recipe, "+index")
801+ # To test the breadcrumbs we need a correct traversal stack.
802+ view.request.traversed_objects = [self.person, recipe, view]
803+ view.initialize()
804+ breadcrumbs_tag = soupmatchers.Tag(
805+ "breadcrumbs", "ol", attrs={"class": "breadcrumbs"})
806+ self.assertThat(
807+ view(),
808+ soupmatchers.HTMLContains(
809+ soupmatchers.Within(
810+ breadcrumbs_tag,
811+ soupmatchers.Tag(
812+ "OCI project breadcrumb", "a",
813+ text="%s OCI project" % oci_project_name,
814+ attrs={"href": oci_project_url})),
815+ soupmatchers.Within(
816+ breadcrumbs_tag,
817+ soupmatchers.Tag(
818+ "OCI recipe breadcrumb", "li",
819+ text=re.compile(r"\srecipe-name\s")))))
820+
821+ def test_index(self):
822+ oci_project = self.factory.makeOCIProject()
823+ oci_project_name = oci_project.name
824+ oci_project_display = oci_project.display_name
825+ [ref] = self.factory.makeGitRefs(
826+ owner=self.person, target=self.person, name="recipe-repository",
827+ paths=["refs/heads/master"])
828+ recipe = self.makeOCIRecipe(
829+ oci_project=oci_project, git_ref=ref, build_file="Dockerfile")
830+ build = self.makeBuild(
831+ recipe=recipe, status=BuildStatus.FULLYBUILT,
832+ duration=timedelta(minutes=30))
833+ self.assertTextMatchesExpressionIgnoreWhitespace("""\
834+ %s OCI project
835+ recipe-name
836+ .*
837+ OCI recipe information
838+ Owner: Test Person
839+ OCI project: %s
840+ Source: ~test-person/\\+git/recipe-repository:master
841+ Build file path: Dockerfile
842+ Build schedule: Built on request
843+ Latest builds
844+ Status When complete Architecture
845+ Successfully built 30 minutes ago 386
846+ """ % (oci_project_name, oci_project_display),
847+ self.getMainText(build.recipe))
848+
849+ def test_index_success_with_buildlog(self):
850+ # The build log is shown if it is there.
851+ build = self.makeBuild(
852+ status=BuildStatus.FULLYBUILT, duration=timedelta(minutes=30))
853+ build.setLog(self.factory.makeLibraryFileAlias())
854+ self.assertTextMatchesExpressionIgnoreWhitespace("""\
855+ Latest builds
856+ Status When complete Architecture
857+ Successfully built 30 minutes ago buildlog \(.*\) 386
858+ """, self.getMainText(build.recipe))
859+
860+ def test_index_no_builds(self):
861+ # A message is shown when there are no builds.
862+ recipe = self.factory.makeOCIRecipe()
863+ self.assertIn(
864+ "This OCI recipe has not been built yet.",
865+ self.getMainText(recipe))
866+
867+ def test_index_pending_build(self):
868+ # A pending build is listed as such.
869+ build = self.makeBuild()
870+ build.queueBuild()
871+ self.assertTextMatchesExpressionIgnoreWhitespace("""\
872+ Latest builds
873+ Status When complete Architecture
874+ Needs building in .* \(estimated\) 386
875+ """, self.getMainText(build.recipe))
876+
877+ def setStatus(self, build, status):
878+ build.updateStatus(
879+ BuildStatus.BUILDING, date_started=build.date_created)
880+ build.updateStatus(
881+ status, date_finished=build.date_started + timedelta(minutes=30))
882+
883+ def test_builds(self):
884+ # OCIRecipeView.builds produces reasonable results.
885+ recipe = self.makeOCIRecipe()
886+ # Create oldest builds first so that they sort properly by id.
887+ date_gen = time_counter(
888+ datetime(2000, 1, 1, tzinfo=pytz.UTC), timedelta(days=1))
889+ builds = [
890+ self.makeBuild(recipe=recipe, date_created=next(date_gen))
891+ for i in range(11)]
892+ view = OCIRecipeView(recipe, None)
893+ self.assertEqual(list(reversed(builds)), view.builds)
894+ self.setStatus(builds[10], BuildStatus.FULLYBUILT)
895+ self.setStatus(builds[9], BuildStatus.FAILEDTOBUILD)
896+ del get_property_cache(view).builds
897+ # When there are >= 9 pending builds, only the most recent of any
898+ # completed builds is returned.
899+ self.assertEqual(
900+ list(reversed(builds[:9])) + [builds[10]], view.builds)
901+ for build in builds[:9]:
902+ self.setStatus(build, BuildStatus.FULLYBUILT)
903+ del get_property_cache(view).builds
904+ self.assertEqual(list(reversed(builds[1:])), view.builds)
905diff --git a/lib/lp/oci/browser/tests/test_ocirecipebuild.py b/lib/lp/oci/browser/tests/test_ocirecipebuild.py
906new file mode 100644
907index 0000000..ed3cb9b
908--- /dev/null
909+++ b/lib/lp/oci/browser/tests/test_ocirecipebuild.py
910@@ -0,0 +1,80 @@
911+# Copyright 2020 Canonical Ltd. This software is licensed under the
912+# GNU Affero General Public License version 3 (see the file LICENSE).
913+
914+"""Test OCI recipe build views."""
915+
916+from __future__ import absolute_import, print_function, unicode_literals
917+
918+__metaclass__ = type
919+
920+import re
921+
922+from storm.locals import Store
923+from testtools.matchers import StartsWith
924+
925+from lp.buildmaster.enums import BuildStatus
926+from lp.services.webapp import canonical_url
927+from lp.testing import (
928+ BrowserTestCase,
929+ TestCaseWithFactory,
930+ )
931+from lp.testing.layers import DatabaseFunctionalLayer
932+from lp.testing.pages import (
933+ extract_text,
934+ find_main_content,
935+ )
936+
937+
938+class TestCanonicalUrlForOCIRecipeBuild(TestCaseWithFactory):
939+
940+ layer = DatabaseFunctionalLayer
941+
942+ def test_canonical_url(self):
943+ owner = self.factory.makePerson(name="person")
944+ distribution = self.factory.makeDistribution(name="distro")
945+ oci_project = self.factory.makeOCIProject(
946+ pillar=distribution, ociprojectname="oci-project")
947+ recipe = self.factory.makeOCIRecipe(
948+ name="recipe", registrant=owner, owner=owner,
949+ oci_project=oci_project)
950+ build = self.factory.makeOCIRecipeBuild(requester=owner, recipe=recipe)
951+ self.assertThat(
952+ canonical_url(build),
953+ StartsWith(
954+ "http://launchpad.test/~person/distro/+oci/oci-project/"
955+ "+recipe/recipe/+build/"))
956+
957+
958+class TestOCIRecipeBuildOperations(BrowserTestCase):
959+
960+ layer = DatabaseFunctionalLayer
961+
962+ def setUp(self):
963+ super(TestOCIRecipeBuildOperations, self).setUp()
964+ self.build = self.factory.makeOCIRecipeBuild()
965+ self.build_url = canonical_url(self.build)
966+
967+ def test_builder_history(self):
968+ Store.of(self.build).flush()
969+ self.build.updateStatus(
970+ BuildStatus.FULLYBUILT, builder=self.factory.makeBuilder())
971+ title = self.build.title
972+ browser = self.getViewBrowser(self.build.builder, "+history")
973+ self.assertTextMatchesExpressionIgnoreWhitespace(
974+ "Build history.*%s" % re.escape(title),
975+ extract_text(find_main_content(browser.contents)))
976+ self.assertEqual(self.build_url, browser.getLink(title).url)
977+
978+ def makeBuildingOCIRecipe(self):
979+ builder = self.factory.makeBuilder()
980+ build = self.factory.makeOCIRecipeBuild()
981+ build.updateStatus(BuildStatus.BUILDING, builder=builder)
982+ build.queueBuild()
983+ build.buildqueue_record.builder = builder
984+ build.buildqueue_record.logtail = "tail of the log"
985+ return build
986+
987+ def test_builder_index(self):
988+ build = self.makeBuildingOCIRecipe()
989+ browser = self.getViewBrowser(build.builder, no_login=True)
990+ self.assertIn("tail of the log", browser.contents)
991diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
992index 4a67bbb..b86afcb 100644
993--- a/lib/lp/oci/configure.zcml
994+++ b/lib/lp/oci/configure.zcml
995@@ -7,6 +7,8 @@
996 xmlns:lp="http://namespaces.canonical.com/lp"
997 i18n_domain="launchpad">
998
999+ <include package=".browser" />
1000+
1001 <!-- OCIRecipe -->
1002 <class
1003 class="lp.oci.model.ocirecipe.OCIRecipe">
1004diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
1005index f9ed77a..d93cbe9 100644
1006--- a/lib/lp/oci/interfaces/ocirecipe.py
1007+++ b/lib/lp/oci/interfaces/ocirecipe.py
1008@@ -248,5 +248,8 @@ class IOCIRecipeSet(Interface):
1009 def findByOwner(owner):
1010 """Return all OCI Recipes with the given `owner`."""
1011
1012+ def findByOCIProject(oci_project):
1013+ """Return all OCI recipes with the given `oci_project`."""
1014+
1015 def preloadDataForOCIRecipes(recipes, user):
1016 """Load the data reloated to a list of OCI Recipes."""
1017diff --git a/lib/lp/oci/interfaces/ocirecipebuild.py b/lib/lp/oci/interfaces/ocirecipebuild.py
1018index 95af3f6..30c3835 100644
1019--- a/lib/lp/oci/interfaces/ocirecipebuild.py
1020+++ b/lib/lp/oci/interfaces/ocirecipebuild.py
1021@@ -14,7 +14,11 @@ __all__ = [
1022
1023 from lazr.restful.fields import Reference
1024 from zope.interface import Interface
1025-from zope.schema import TextLine
1026+from zope.schema import (
1027+ Bool,
1028+ Datetime,
1029+ TextLine,
1030+ )
1031
1032 from lp import _
1033 from lp.buildmaster.interfaces.buildfarmjob import ISpecificBuildFarmJobSource
1034@@ -51,6 +55,18 @@ class IOCIRecipeBuildView(IPackageBuild):
1035 required=True,
1036 readonly=True)
1037
1038+ eta = Datetime(
1039+ title=_("The datetime when the build job is estimated to complete."),
1040+ readonly=True)
1041+
1042+ estimate = Bool(
1043+ title=_("If true, the date value is an estimate."), readonly=True)
1044+
1045+ date = Datetime(
1046+ title=_(
1047+ "The date when the build completed or is estimated to complete."),
1048+ readonly=True)
1049+
1050 def getByFileName():
1051 """Retrieve a file by filename
1052
1053diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
1054index 4555a38..2bf8068 100644
1055--- a/lib/lp/oci/model/ocirecipe.py
1056+++ b/lib/lp/oci/model/ocirecipe.py
1057@@ -309,6 +309,11 @@ class OCIRecipeSet:
1058 """See `IOCIRecipe`."""
1059 return IStore(OCIRecipe).find(OCIRecipe, OCIRecipe.owner == owner)
1060
1061+ def findByOCIProject(self, oci_project):
1062+ """See `IOCIRecipe`."""
1063+ return IStore(OCIRecipe).find(
1064+ OCIRecipe, OCIRecipe.oci_project == oci_project)
1065+
1066 def preloadDataForOCIRecipes(self, recipes, user=None):
1067 """See `IOCIRecipeSet`."""
1068 recipes = [removeSecurityProxy(recipe) for recipe in recipes]
1069diff --git a/lib/lp/oci/model/ocirecipebuild.py b/lib/lp/oci/model/ocirecipebuild.py
1070index 307fbd2..c010817 100644
1071--- a/lib/lp/oci/model/ocirecipebuild.py
1072+++ b/lib/lp/oci/model/ocirecipebuild.py
1073@@ -32,6 +32,7 @@ from zope.interface import implementer
1074 from lp.app.errors import NotFoundError
1075 from lp.buildmaster.enums import (
1076 BuildFarmJobType,
1077+ BuildQueueStatus,
1078 BuildStatus,
1079 )
1080 from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJobSource
1081@@ -56,6 +57,7 @@ from lp.services.librarian.model import (
1082 LibraryFileAlias,
1083 LibraryFileContent,
1084 )
1085+from lp.services.propertycache import cachedproperty
1086
1087
1088 @implementer(IOCIFile)
1089@@ -142,6 +144,20 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
1090 self.status = BuildStatus.NEEDSBUILD
1091 self.build_farm_job = build_farm_job
1092
1093+ def __repr__(self):
1094+ return "<OCIRecipeBuild ~%s/%s/+oci/%s/+recipe/%s/+build/%d>" % (
1095+ self.recipe.owner.name, self.recipe.oci_project.pillar.name,
1096+ self.recipe.oci_project.name, self.recipe.name, self.id)
1097+
1098+ @property
1099+ def title(self):
1100+ # XXX cjwatson 2020-02-19: This should use a DAS architecture tag
1101+ # rather than a processor name once we can do that.
1102+ return "%s build of ~%s/%s/+oci/%s/+recipe/%s" % (
1103+ self.processor.name, self.recipe.owner.name,
1104+ self.recipe.oci_project.pillar.name, self.recipe.oci_project.name,
1105+ self.recipe.name)
1106+
1107 def calculateScore(self):
1108 # XXX twom 2020-02-11 - This might need an addition?
1109 return 2510
1110@@ -196,6 +212,39 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
1111 IMasterStore(OCIFile).add(oci_file)
1112 return oci_file
1113
1114+ @cachedproperty
1115+ def eta(self):
1116+ """The datetime when the build job is estimated to complete.
1117+
1118+ This is the BuildQueue.estimated_duration plus the
1119+ Job.date_started or BuildQueue.getEstimatedJobStartTime.
1120+ """
1121+ if self.buildqueue_record is None:
1122+ return None
1123+ queue_record = self.buildqueue_record
1124+ if queue_record.status == BuildQueueStatus.WAITING:
1125+ start_time = queue_record.getEstimatedJobStartTime()
1126+ else:
1127+ start_time = queue_record.date_started
1128+ if start_time is None:
1129+ return None
1130+ duration = queue_record.estimated_duration
1131+ return start_time + duration
1132+
1133+ @property
1134+ def estimate(self):
1135+ """If true, the date value is an estimate."""
1136+ if self.date_finished is not None:
1137+ return False
1138+ return self.eta is not None
1139+
1140+ @property
1141+ def date(self):
1142+ """The date when the build completed or is estimated to complete."""
1143+ if self.estimate:
1144+ return self.eta
1145+ return self.date_finished
1146+
1147 @property
1148 def archive(self):
1149 # XXX twom 2019-12-05 This may need to change when an OCIProject
1150diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt
1151new file mode 100644
1152index 0000000..fc75d75
1153--- /dev/null
1154+++ b/lib/lp/oci/templates/ocirecipe-index.pt
1155@@ -0,0 +1,118 @@
1156+<html
1157+ xmlns="http://www.w3.org/1999/xhtml"
1158+ xmlns:tal="http://xml.zope.org/namespaces/tal"
1159+ xmlns:metal="http://xml.zope.org/namespaces/metal"
1160+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
1161+ metal:use-macro="view/macro:page/main_side"
1162+ i18n:domain="launchpad"
1163+>
1164+
1165+<body>
1166+ <metal:registering fill-slot="registering">
1167+ Created by
1168+ <tal:registrant replace="structure context/registrant/fmt:link"/>
1169+ on
1170+ <tal:created-on replace="structure context/date_created/fmt:date"/>
1171+ and last modified on
1172+ <tal:last-modified replace="structure context/date_last_modified/fmt:date"/>
1173+ </metal:registering>
1174+
1175+ <metal:side fill-slot="side">
1176+ <div tal:replace="structure context/@@+global-actions"/>
1177+ </metal:side>
1178+
1179+ <metal:heading fill-slot="heading">
1180+ <h1 tal:content="context/name"/>
1181+ </metal:heading>
1182+
1183+ <div metal:fill-slot="main">
1184+ <div id="recipe-description" tal:condition="context/description"
1185+ class="summary"
1186+ tal:content="structure context/description/fmt:text-to-html"/>
1187+
1188+ <h2>OCI recipe information</h2>
1189+ <div class="two-column-list">
1190+ <dl id="owner">
1191+ <dt>Owner:</dt>
1192+ <dd tal:content="structure view/person_picker"/>
1193+ </dl>
1194+ <dl id="oci-project" tal:define="oci_project context/oci_project">
1195+ <dt>OCI project:</dt>
1196+ <dd>
1197+ <a tal:attributes="href oci_project/fmt:url"
1198+ tal:content="oci_project/display_name"/>
1199+ </dd>
1200+ </dl>
1201+ <dl id="source" tal:define="source context/git_ref">
1202+ <dt>Source:</dt>
1203+ <dd>
1204+ <a tal:replace="structure source/fmt:link"/>
1205+ <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
1206+ </dd>
1207+ </dl>
1208+ <dl id="build-file">
1209+ <dt>Build file path:</dt>
1210+ <dd>
1211+ <span tal:content="context/build_file"/>
1212+ <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
1213+ </dd>
1214+ </dl>
1215+ <dl id="build-schedule">
1216+ <dt>Build schedule:</dt>
1217+ <dd>
1218+ <span tal:replace="view/build_frequency"/>
1219+ <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
1220+ </dd>
1221+ </dl>
1222+ </div>
1223+
1224+ <h2>Latest builds</h2>
1225+ <table id="latest-builds-listing" class="listing"
1226+ style="margin-bottom: 1em;">
1227+ <thead>
1228+ <tr>
1229+ <th>Status</th>
1230+ <th>When complete</th>
1231+ <th>Architecture</th>
1232+ </tr>
1233+ </thead>
1234+ <tbody>
1235+ <tal:recipe-builds repeat="item view/builds">
1236+ <tr tal:define="build item"
1237+ tal:attributes="id string:build-${build/id}">
1238+ <td tal:attributes="class string:build_status ${build/status/name}">
1239+ <span tal:replace="structure build/image:icon"/>
1240+ <a tal:content="build/status/title"
1241+ tal:attributes="href build/fmt:url"/>
1242+ </td>
1243+ <td class="datebuilt">
1244+ <tal:date replace="build/date/fmt:displaydate"/>
1245+ <tal:estimate condition="build/estimate">
1246+ (estimated)
1247+ </tal:estimate>
1248+
1249+ <tal:build-log define="file build/log" tal:condition="file">
1250+ <a class="sprite download"
1251+ tal:attributes="href build/log_url">buildlog</a>
1252+ (<span tal:replace="file/content/filesize/fmt:bytes"/>)
1253+ </tal:build-log>
1254+ </td>
1255+ <td>
1256+ <!-- XXX cjwatson 2020-02-19: This should show a DAS
1257+ architecture tag rather than a processor name once we can
1258+ do that. -->
1259+ <a class="sprite distribution"
1260+ tal:define="processor build/processor"
1261+ tal:content="processor/name"/>
1262+ </td>
1263+ </tr>
1264+ </tal:recipe-builds>
1265+ </tbody>
1266+ </table>
1267+ <p tal:condition="not: view/builds">
1268+ This OCI recipe has not been built yet.
1269+ </p>
1270+ </div>
1271+
1272+</body>
1273+</html>
1274diff --git a/lib/lp/oci/templates/ocirecipe-new.pt b/lib/lp/oci/templates/ocirecipe-new.pt
1275new file mode 100644
1276index 0000000..a2d3809
1277--- /dev/null
1278+++ b/lib/lp/oci/templates/ocirecipe-new.pt
1279@@ -0,0 +1,41 @@
1280+<html
1281+ xmlns="http://www.w3.org/1999/xhtml"
1282+ xmlns:tal="http://xml.zope.org/namespaces/tal"
1283+ xmlns:metal="http://xml.zope.org/namespaces/metal"
1284+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
1285+ metal:use-macro="view/macro:page/main_side"
1286+ i18n:domain="launchpad">
1287+<body>
1288+
1289+<div metal:fill-slot="main">
1290+ <!-- XXX cjwatson 2020-02-18: Add an introductory paragraph explaining
1291+ what OCI recipes are. -->
1292+
1293+ <div metal:use-macro="context/@@launchpad_form/form">
1294+ <metal:formbody fill-slot="widgets">
1295+ <table class="form">
1296+ <tal:widget define="widget nocall:view/widgets/name">
1297+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1298+ </tal:widget>
1299+ <tal:widget define="widget nocall:view/widgets/owner">
1300+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1301+ </tal:widget>
1302+ <tal:widget define="widget nocall:view/widgets/description">
1303+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1304+ </tal:widget>
1305+ <tal:widget define="widget nocall:view/widgets/git_ref">
1306+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1307+ </tal:widget>
1308+ <tal:widget define="widget nocall:view/widgets/build_file">
1309+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1310+ </tal:widget>
1311+ <tal:widget define="widget nocall:view/widgets/build_daily">
1312+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1313+ </tal:widget>
1314+ </table>
1315+ </metal:formbody>
1316+ </div>
1317+</div>
1318+
1319+</body>
1320+</html>
1321diff --git a/lib/lp/oci/templates/ocirecipebuild-index.pt b/lib/lp/oci/templates/ocirecipebuild-index.pt
1322new file mode 100644
1323index 0000000..04b7fcf
1324--- /dev/null
1325+++ b/lib/lp/oci/templates/ocirecipebuild-index.pt
1326@@ -0,0 +1,151 @@
1327+<html
1328+ xmlns="http://www.w3.org/1999/xhtml"
1329+ xmlns:tal="http://xml.zope.org/namespaces/tal"
1330+ xmlns:metal="http://xml.zope.org/namespaces/metal"
1331+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
1332+ metal:use-macro="view/macro:page/main_only"
1333+ i18n:domain="launchpad"
1334+>
1335+
1336+ <body>
1337+
1338+ <tal:registering metal:fill-slot="registering">
1339+ created
1340+ <span tal:content="context/date_created/fmt:displaydate"
1341+ tal:attributes="title context/date_created/fmt:datetime"/>
1342+ </tal:registering>
1343+
1344+ <div metal:fill-slot="main">
1345+ <div class="yui-g">
1346+ <div id="status" class="yui-u first">
1347+ <div class="portlet">
1348+ <div metal:use-macro="template/macros/status"/>
1349+ </div>
1350+ </div>
1351+
1352+ <div id="details" class="yui-u">
1353+ <div class="portlet">
1354+ <div metal:use-macro="template/macros/details"/>
1355+ </div>
1356+ </div>
1357+ </div> <!-- yui-g -->
1358+
1359+ <div id="buildlog" class="portlet"
1360+ tal:condition="context/status/enumvalue:BUILDING">
1361+ <div metal:use-macro="template/macros/buildlog"/>
1362+ </div>
1363+ </div>
1364+
1365+
1366+<metal:macros fill-slot="bogus">
1367+
1368+ <metal:macro define-macro="details">
1369+ <tal:comment replace="nothing">
1370+ Details section.
1371+ </tal:comment>
1372+ <h2>Build details</h2>
1373+ <div class="two-column-list">
1374+ <dl>
1375+ <dt>Recipe:</dt>
1376+ <dd><tal:recipe replace="structure context/recipe/fmt:link"/></dd>
1377+ </dl>
1378+ <dl>
1379+ <dt>Architecture:</dt>
1380+ <dd>
1381+ <a class="sprite distribution"
1382+ tal:define="archseries context/distro_arch_series"
1383+ tal:attributes="href archseries/fmt:url"
1384+ tal:content="archseries/architecturetag"/>
1385+ </dd>
1386+ </dl>
1387+ </div>
1388+ </metal:macro>
1389+
1390+ <metal:macro define-macro="status">
1391+ <tal:comment replace="nothing">
1392+ Status section.
1393+ </tal:comment>
1394+ <h2>Build status</h2>
1395+ <p>
1396+ <span tal:replace="structure context/image:icon" />
1397+ <span tal:attributes="
1398+ class string:buildstatus${context/status/name};"
1399+ tal:content="context/status/title"/>
1400+ <tal:building condition="context/status/enumvalue:BUILDING">
1401+ on <a tal:content="context/buildqueue_record/builder/title"
1402+ tal:attributes="href context/buildqueue_record/builder/fmt:url"/>
1403+ </tal:building>
1404+ <tal:built condition="context/builder">
1405+ on <a tal:content="context/builder/title"
1406+ tal:attributes="href context/builder/fmt:url"/>
1407+ </tal:built>
1408+ </p>
1409+
1410+ <ul>
1411+ <li tal:condition="context/dependencies">
1412+ Missing build dependencies: <em tal:content="context/dependencies"/>
1413+ </li>
1414+ <tal:reallypending condition="context/buildqueue_record">
1415+ <tal:pending condition="context/buildqueue_record/status/enumvalue:WAITING">
1416+ <li tal:define="eta context/buildqueue_record/getEstimatedJobStartTime">
1417+ Start <tal:eta replace="eta/fmt:approximatedate"/>
1418+ (<span tal:replace="context/buildqueue_record/lastscore"/>)
1419+ <a href="https://help.launchpad.net/Packaging/BuildScores"
1420+ target="_blank">What's this?</a>
1421+ </li>
1422+ </tal:pending>
1423+ </tal:reallypending>
1424+ <tal:started condition="context/date_started">
1425+ <li tal:condition="context/date_started">
1426+ Started <span
1427+ tal:define="start context/date_started"
1428+ tal:attributes="title start/fmt:datetime"
1429+ tal:content="start/fmt:displaydate"/>
1430+ </li>
1431+ </tal:started>
1432+ <tal:finish condition="not: context/date_finished">
1433+ <li tal:define="eta context/eta" tal:condition="context/eta">
1434+ Estimated finish <tal:eta replace="eta/fmt:approximatedate"/>
1435+ </li>
1436+ </tal:finish>
1437+
1438+ <li tal:condition="context/date_finished">
1439+ Finished <span
1440+ tal:attributes="title context/date_finished/fmt:datetime"
1441+ tal:content="context/date_finished/fmt:displaydate"/>
1442+ <tal:duration condition="context/duration">
1443+ (took <span tal:replace="context/duration/fmt:exactduration"/>)
1444+ </tal:duration>
1445+ </li>
1446+ <li tal:define="file context/log"
1447+ tal:condition="file">
1448+ <a class="sprite download"
1449+ tal:attributes="href context/log_url">buildlog</a>
1450+ (<span tal:replace="file/content/filesize/fmt:bytes" />)
1451+ </li>
1452+ <li tal:define="file context/upload_log"
1453+ tal:condition="file">
1454+ <a class="sprite download"
1455+ tal:attributes="href context/upload_log_url">uploadlog</a>
1456+ (<span tal:replace="file/content/filesize/fmt:bytes" />)
1457+ </li>
1458+ </ul>
1459+ </metal:macro>
1460+
1461+ <metal:macro define-macro="buildlog">
1462+ <tal:comment replace="nothing">
1463+ Buildlog section.
1464+ </tal:comment>
1465+ <h2>Buildlog</h2>
1466+ <div id="buildlog-tail" class="logtail"
1467+ tal:define="logtail context/buildqueue_record/logtail"
1468+ tal:content="structure logtail/fmt:text-to-html"/>
1469+ <p class="lesser" tal:condition="view/user">
1470+ Updated on <span tal:replace="structure view/user/fmt:local-time"/>
1471+ </p>
1472+ </metal:macro>
1473+
1474+</metal:macros>
1475+
1476+ </body>
1477+</html>
1478diff --git a/lib/lp/oci/tests/test_ocirecipebuild.py b/lib/lp/oci/tests/test_ocirecipebuild.py
1479index 3d51de2..b768e1d 100644
1480--- a/lib/lp/oci/tests/test_ocirecipebuild.py
1481+++ b/lib/lp/oci/tests/test_ocirecipebuild.py
1482@@ -8,6 +8,7 @@ from __future__ import absolute_import, print_function, unicode_literals
1483 from datetime import timedelta
1484
1485 import six
1486+from testtools.matchers import Equals
1487 from zope.component import getUtility
1488 from zope.security.proxy import removeSecurityProxy
1489
1490@@ -20,14 +21,17 @@ from lp.oci.interfaces.ocirecipebuild import (
1491 IOCIRecipeBuildSet,
1492 )
1493 from lp.oci.model.ocirecipebuild import OCIRecipeBuildSet
1494+from lp.services.propertycache import clear_property_cache
1495 from lp.testing import (
1496 admin_logged_in,
1497+ StormStatementRecorder,
1498 TestCaseWithFactory,
1499 )
1500 from lp.testing.layers import (
1501 DatabaseFunctionalLayer,
1502 LaunchpadZopelessLayer,
1503 )
1504+from lp.testing.matchers import HasQueryCount
1505
1506
1507 class TestOCIRecipeBuild(TestCaseWithFactory):
1508@@ -105,6 +109,33 @@ class TestOCIRecipeBuild(TestCaseWithFactory):
1509 self.assertIsNotNone(bq.processor)
1510 self.assertEqual(bq, self.build.buildqueue_record)
1511
1512+ def test_eta(self):
1513+ # OCIRecipeBuild.eta returns a non-None value when it should, or
1514+ # None when there's no start time.
1515+ self.build.queueBuild()
1516+ self.assertIsNone(self.build.eta)
1517+ self.factory.makeBuilder(processors=[self.build.processor])
1518+ clear_property_cache(self.build)
1519+ self.assertIsNotNone(self.build.eta)
1520+
1521+ def test_eta_cached(self):
1522+ # The expensive completion time estimate is cached.
1523+ self.build.queueBuild()
1524+ self.build.eta
1525+ with StormStatementRecorder() as recorder:
1526+ self.build.eta
1527+ self.assertThat(recorder, HasQueryCount(Equals(0)))
1528+
1529+ def test_estimate(self):
1530+ # OCIRecipeBuild.estimate returns True until the job is completed.
1531+ self.build.queueBuild()
1532+ self.factory.makeBuilder(processors=[self.build.processor])
1533+ self.build.updateStatus(BuildStatus.BUILDING)
1534+ self.assertTrue(self.build.estimate)
1535+ self.build.updateStatus(BuildStatus.FULLYBUILT)
1536+ clear_property_cache(self.build)
1537+ self.assertFalse(self.build.estimate)
1538+
1539
1540 class TestOCIRecipeBuildSet(TestCaseWithFactory):
1541
1542diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
1543index 993fe23..72864b9 100644
1544--- a/lib/lp/registry/browser/configure.zcml
1545+++ b/lib/lp/registry/browser/configure.zcml
1546@@ -633,7 +633,8 @@
1547 module="lp.registry.browser.ociproject"
1548 classes="
1549 OCIProjectFacets
1550- OCIProjectNavigationMenu"
1551+ OCIProjectNavigationMenu
1552+ OCIProjectContextMenu"
1553 />
1554 <adapter
1555 name="fmt"
1556diff --git a/lib/lp/registry/browser/ociproject.py b/lib/lp/registry/browser/ociproject.py
1557index 4d760a4..3562a03 100644
1558--- a/lib/lp/registry/browser/ociproject.py
1559+++ b/lib/lp/registry/browser/ociproject.py
1560@@ -8,8 +8,10 @@ from __future__ import absolute_import, print_function, unicode_literals
1561 __metaclass__ = type
1562 __all__ = [
1563 'OCIProjectBreadcrumb',
1564+ 'OCIProjectContextMenu',
1565 'OCIProjectFacets',
1566 'OCIProjectNavigation',
1567+ 'OCIProjectNavigationMenu',
1568 ]
1569
1570 from zope.component import getUtility
1571@@ -20,14 +22,15 @@ from lp.app.browser.launchpadform import (
1572 LaunchpadEditFormView,
1573 )
1574 from lp.app.browser.tales import CustomizableFormatter
1575-from lp.app.interfaces.headings import IHeadingBreadcrumb
1576 from lp.code.browser.vcslisting import TargetDefaultVCSNavigationMixin
1577+from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
1578 from lp.registry.interfaces.ociproject import (
1579 IOCIProject,
1580 IOCIProjectSet,
1581 )
1582 from lp.services.webapp import (
1583 canonical_url,
1584+ ContextMenu,
1585 enabled_with_permission,
1586 Link,
1587 Navigation,
1588@@ -53,7 +56,7 @@ class OCIProjectNavigation(TargetDefaultVCSNavigationMixin, Navigation):
1589 usedfor = IOCIProject
1590
1591
1592-@implementer(IHeadingBreadcrumb, IMultiFacetedBreadcrumb)
1593+@implementer(IMultiFacetedBreadcrumb)
1594 class OCIProjectBreadcrumb(Breadcrumb):
1595 """Builds a breadcrumb for an `IOCIProject`."""
1596
1597@@ -85,6 +88,26 @@ class OCIProjectNavigationMenu(NavigationMenu):
1598 return Link('+edit', 'Edit OCI project', icon='edit')
1599
1600
1601+class OCIProjectContextMenu(ContextMenu):
1602+ """Context menu for OCI projects."""
1603+
1604+ usedfor = IOCIProject
1605+
1606+ facet = 'overview'
1607+
1608+ links = ('create_recipe', 'view_recipes')
1609+
1610+ @enabled_with_permission('launchpad.AnyLegitimatePerson')
1611+ def create_recipe(self):
1612+ return Link('+new-recipe', 'Create OCI recipe', icon='add')
1613+
1614+ def view_recipes(self):
1615+ enabled = not getUtility(IOCIRecipeSet).findByOCIProject(
1616+ self.context).is_empty()
1617+ return Link(
1618+ '+recipes', 'View OCI recipes', icon='info', enabled=enabled)
1619+
1620+
1621 class OCIProjectEditView(LaunchpadEditFormView):
1622 """Edit an OCI project."""
1623
1624diff --git a/lib/lp/registry/browser/personociproject.py b/lib/lp/registry/browser/personociproject.py
1625index aa991eb..3b81dba 100644
1626--- a/lib/lp/registry/browser/personociproject.py
1627+++ b/lib/lp/registry/browser/personociproject.py
1628@@ -10,16 +10,21 @@ __all__ = [
1629 'PersonOCIProjectNavigation',
1630 ]
1631
1632-from zope.component import queryAdapter
1633+from zope.component import (
1634+ getUtility,
1635+ queryAdapter,
1636+ )
1637 from zope.interface import implementer
1638 from zope.traversing.interfaces import IPathAdapter
1639
1640 from lp.code.browser.vcslisting import PersonTargetDefaultVCSNavigationMixin
1641+from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
1642 from lp.registry.interfaces.personociproject import IPersonOCIProject
1643 from lp.services.webapp import (
1644 canonical_url,
1645 Navigation,
1646 StandardLaunchpadFacets,
1647+ stepthrough,
1648 )
1649 from lp.services.webapp.breadcrumb import Breadcrumb
1650 from lp.services.webapp.interfaces import IMultiFacetedBreadcrumb
1651@@ -30,6 +35,11 @@ class PersonOCIProjectNavigation(
1652
1653 usedfor = IPersonOCIProject
1654
1655+ @stepthrough('+recipe')
1656+ def traverse_recipe(self, name):
1657+ return getUtility(IOCIRecipeSet).getByName(
1658+ self.context.person, self.context.oci_project, name)
1659+
1660
1661 # XXX cjwatson 2019-11-26: Do we need two breadcrumbs, one for the
1662 # distribution and one for the OCI project?
1663diff --git a/lib/lp/registry/templates/ociproject-index.pt b/lib/lp/registry/templates/ociproject-index.pt
1664index 8a9a6a1..427b383 100644
1665--- a/lib/lp/registry/templates/ociproject-index.pt
1666+++ b/lib/lp/registry/templates/ociproject-index.pt
1667@@ -33,17 +33,27 @@
1668 <dd>
1669 <a tal:attributes="href distribution/fmt:url"
1670 tal:content="distribution/display_name"/>
1671- <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
1672+ <a tal:replace="structure context/menu:overview/edit/fmt:icon"/>
1673 </dd>
1674 </dl>
1675 <dl id="name">
1676 <dt>Name:</dt>
1677 <dd>
1678 <span tal:content="context/name"/>
1679- <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
1680+ <a tal:replace="structure context/menu:overview/edit/fmt:icon"/>
1681 </dd>
1682 </dl>
1683 </div>
1684+
1685+ <h2>Recipes</h2>
1686+ <div id="recipe-summary"
1687+ tal:define="link context/menu:context/view_recipes"
1688+ tal:condition="link/enabled"
1689+ tal:content="structure link/render"/>
1690+ <tal:create-recipe
1691+ define="link context/menu:context/create_recipe"
1692+ condition="link/enabled"
1693+ replace="structure link/render"/>
1694 </div>
1695 </body>
1696 </html>

Subscribers

People subscribed via source and target branches

to status/vote changes: