Merge lp:~wallyworld/launchpad/built-packages-listing into lp:launchpad

Proposed by Ian Booth
Status: Merged
Approved by: Ian Booth
Approved revision: no longer in the source branch.
Merged at revision: 12044
Proposed branch: lp:~wallyworld/launchpad/built-packages-listing
Merge into: lp:launchpad
Diff against target: 740 lines (+576/-13)
11 files modified
lib/lp/code/browser/configure.zcml (+13/-6)
lib/lp/code/browser/recipebuildslisting.py (+42/-0)
lib/lp/code/browser/tests/test_recipebuildslisting.py (+89/-0)
lib/lp/code/configure.zcml (+19/-0)
lib/lp/code/interfaces/recipebuild.py (+27/-0)
lib/lp/code/model/recipebuild.py (+178/-0)
lib/lp/code/stories/branches/xx-bazaar-home.txt (+24/-3)
lib/lp/code/templates/bazaar-index.pt (+6/-0)
lib/lp/code/templates/daily-builds-listing.pt (+93/-0)
lib/lp/testing/__init__.py (+7/-4)
lib/lp/testing/factory.py (+78/-0)
To merge this branch: bzr merge lp:~wallyworld/launchpad/built-packages-listing
Reviewer Review Type Date Requested Status
Tim Penhey (community) Approve
Steve Kowalik (community) code* Approve
Review via email: mp+40634@code.launchpad.net

Commit message

[r=stevenk,thumper][ui=none][bug=671262] provide a listing of recently built packages from recipes

Description of the change

= Summary =

Bug 671262 - provide a listing of recently built packages from recipes

= Implementation =

URL for new view is http://code.launchpad.net/+daily-builds
A "Recent recipe builds..." link has been added to the footer of the code index page.
Displays recipe build records which have been built in the last 30 days.

New classes:

  IRecipeBuildRecordSet
  RecipeBuildRecordSet
    Loads recipe build records - the data model for the view.
    The records for the view are constructed as a named tuple.
    By default, records built in the last 30 days are loaded.

  RecipeBuildRecordResultSet
    Extends DecoratedResultSet to override the count() method so that it works properly with the group by query.

  CompletedDailyBuildsView
    The view class - displays source package name, recipe, recipe owner, archive, most recent build datetime.
    Supports display of build records where archive is PPA (in which case a link is rendered) or Primary (renders text).

  Page Template
    lp/code/templates/daily-builds-listing.pt

= Screenshot =

Listing page:

http://people.canonical.com/~ianb/dailyrecipebuildlisting.png

Code home page:

http://people.canonical.com/~ianb/CodeHomePage-Recipes.png

= Tests =

New tests:
lp.code.browser.tests.test_recipebuildlisting.py
  The test creates records with primary and ppa archives and also outside the default 30 day window.
xx-bazaar-home.txt
  Added tests to check content on code home page.

bin/test -vvt test_recipebuildslisting
bin/test -vvt xx-baxaar-home.txt

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  logs/
  lib/lp/code/configure.zcml
  lib/lp/code/browser/configure.zcml
  lib/lp/code/browser/recipebuildslisting.py
  lib/lp/code/browser/tests/test_recipebuildslisting.py
  lib/lp/code/interfaces/recipebuild.py
  lib/lp/code/model/recipebuild.py
  lib/lp/code/templates/bazaar-index.pt
  lib/lp/code/templates/daily-builds-listing.pt
  lib/lp/testing/__init__.py
  lib/lp/code/stories/branches/xx-bazaar-home.txt
       1: narrative uses a moin header.
      40: narrative uses a moin header.
      48: narrative uses a moin header.
      83: narrative uses a moin header.
     109: narrative uses a moin header.
     138: narrative uses a moin header.
  lib/lp/code/model/recipebuild.py
     150: E302 expected 2 blank lines, found 1
  lib/lp/testing/__init__.py
     130: 'anonymous_logged_in' imported but unused
     130: 'with_anonymous_login' imported but unused
     149: 'launchpadlib_for' imported but unused
     149: 'launchpadlib_credentials_for' imported but unused
     130: 'person_logged_in' imported but unused
     149: 'oauth_access_token_for' imported but unused
     130: 'login_celebrity' imported but unused
     130: 'with_celebrity_logged_in' imported but unused
     148: 'test_tales' imported but unused
     130: 'celebrity_logged_in' imported but unused
     130: 'run_with_login' imported but unused
     130: 'is_logged_in' imported but unused
     130: 'with_person_logged_in' imported but unused
     130: 'login_team' imported but unused
     130: 'login_person' imported but unused
     130: 'login_as' imported but unused
     430: E301 expected 1 blank line, found 0
     859: E301 expected 1 blank line, found 0
     885: E302 expected 2 blank lines, found 1
     961: E302 expected 2 blank lines, found 1
     110: Line exceeds 78 characters.

To post a comment you must log in.
Revision history for this message
Steve Kowalik (stevenk) wrote :

Hi,

This mostly looks good. I have a few comments:

* Why are you including imports in the meat of the test, rather than with the rest of them at the top of the file?
* Would _makeRecipeBuildRecords() better exist in the factory so other tests could make use of it?
* Would it make sense to fix __eq__ on SourcePackageRecipe, rather than working around it?
* Use of IStoreSelector, which is deprecated. Could you look at switching to I{Master,}Store?
* Please update your XXX per the XXX policy.

review: Needs Fixing (code*)
Revision history for this message
Ian Booth (wallyworld) wrote :

Thanks Steve for the review.

>
> * Why are you including imports in the meat of the test, rather than with the rest of them at the top of the file?

"Personal preference". I wanted to try and avoid the top imports list
getting too large and cluttered with imports for things that were just
used in one localised place. I'll move them.

> * Would _makeRecipeBuildRecords() better exist in the factory so other tests could make use of it?

Possibly, if and when those other tests get written. ATM, that method is
only used in the one file and I was thinking it best to avoid cluttering
the factory with a method that may never be used outside the test in
which is was originally written.

> * Would it make sense to fix __eq__ on SourcePackageRecipe, rather than working around it?

Drilling down, the __eq__ fails inside storm when attributes of
SourcePackageReipe are compared as part of the __eq__ operation. So it's
not SPR itself that needs fixing. It's a bit messy. I can't recall the
exact error ATM; it could have been because the storm reference
attributes were detached from the db and there was no connection
available to load the required data when __eq__ was invoked.

> * Use of IStoreSelector, which is deprecated. Could you look at switching to I{Master,}Store?

ok.

> * Please update your XXX per the XXX policy.

ok.

Revision history for this message
Robert Collins (lifeless) wrote :

A couple of meta thoughts.

On Wed, Nov 24, 2010 at 2:31 AM, Ian Booth <email address hidden> wrote:
> Thanks Steve for the review.
>
>>
>> * Why are you including imports in the meat of the test, rather than with the rest of them at the top of the file?
>
> "Personal preference". I wanted to try and avoid the top imports list
> getting too large and cluttered with imports for things that were just
> used in one localised place. I'll move them.

While I feel the same pressure the thing is that this quickly becomes
larger-than-one-list - because of redundancy in other tests, and
fixing it becomes a global-scan of the file to find such imports. My
personal conclusion has been that its a pessimisation to do such local
imports with a couple of caveats:
 - in some dynamic-loading scenarios I'll import globally with an
ImportError catch and import in the test to show the actual error if
things don't import, as a test failure
 - circular imports [which should rarely if ever affect tests].

>> * Would _makeRecipeBuildRecords() better exist in the factory so other tests could make use of it?
>
> Possibly, if and when those other tests get written. ATM, that method is
> only used in the one file and I was thinking it best to avoid cluttering
> the factory with a method that may never be used outside the test in
> which is was originally written.

Similar to the import issue, this is a lurking maintenance problem:
how will someone else working on this know to look in your test script
for a test helper. If the helper is truely specfic, then they don't
need to. If you were to write recipe tests elsewhere, would you want
this helper? If so, I think its worth being kind to your future self
and making it discoverable (using whatever idioms we are using at the
time to do that: the current one being 'extend the factory Luke').

-Rob

Revision history for this message
Tim Penhey (thumper) wrote :

Additional review comments:

There are a lot of classes and methods that don't have docstrings. Even a
short docstring is often more useful than nothing.

PEP-8 says a blank line between class definition line and methods.

sorted(builds, key=attrgetter('id')) is a way to avoid the zope proxy issue
and sort on something that is unique and has a __lt__ implementation.

expected_text = "No recently completed daily builds found."

is more readable IMO than: (also PEP-8)

expected_text = """
    No recently completed daily builds found.
    """

In the bazaar doctest, instead of using browser.goBack(), just open the page
directly. This way I don't have to know where the browser was before.

You want to avoid string concatenation in the TAL:
  tal:attributes="href
  string:${dailybuild/recipebuild/distribution/fmt:url}/+source/${dailybuild/sourcepackagename/name

This should be change to be a method somewhere either in the view or the items
in the result set. That way it would just be:

  tal:attributes="href xxx/distro_source_package/fmt:url"

review: Needs Fixing
Revision history for this message
Ian Booth (wallyworld) wrote :

Thanks for the review.

>
> There are a lot of classes and methods that don't have docstrings. Even a
> short docstring is often more useful than nothing.
>

Done.

> PEP-8 says a blank line between class definition line and methods.
>
> sorted(builds, key=attrgetter('id')) is a way to avoid the zope proxy issue
> and sort on something that is unique and has a __lt__ implementation.
>

RecipeBuildRecord, being a named tuple of entities, doesn't have an id
attribute so this suggestion is not relevant here.

> expected_text = "No recently completed daily builds found."
>
> is more readable IMO than: (also PEP-8)
>
> expected_text = """
> No recently completed daily builds found.
> """

Indeed.

>
> In the bazaar doctest, instead of using browser.goBack(), just open the page
> directly. This way I don't have to know where the browser was before.
>

The original test was written using browser.goBack() so I was extending
what was there already. I've implemented the suggestion and as a drive
by fixed the other places where goBack() was used.

> You want to avoid string concatenation in the TAL:
> tal:attributes="href
> string:${dailybuild/recipebuild/distribution/fmt:url}/+source/${dailybuild/sourcepackagename/name
>
> This should be change to be a method somewhere either in the view or the items
> in the result set. That way it would just be:
>
> tal:attributes="href xxx/distro_source_package/fmt:url"
>
>

My original implementation was lifted exactly from:
 question-portlet-details.pt
I've reworked it as per the suggestion by adding a method to the
RecipeBuildRecord class

Revision history for this message
Steve Kowalik (stevenk) wrote :

Hi Ian,

Thanks for all the clean-ups! My only comment is that your multi-line function definitions don't use the preferred form as stated in:

https://dev.launchpad.net/PythonStyleGuide#Multiline%20function%20definitions

I'll leave it up to you if you want to change them, though.

review: Approve (code*)
Revision history for this message
Tim Penhey (thumper) wrote :

A few notes...

This is just styalistic, but personally I tend to use cached properties rather
than overriding initialize.

In lib/lp/code/browser/tests/test_recipebuildslisting.py you import BaseLayer
just to use the appserver_root_url. The layers use inheritance and the method
is a class method, so you can call that through the DatabaseFunctionalLayer.
So you can do:
  root_url = self.layer.appserver_root_url(facet='code')
rather than:
  root_url = BaseLayer.appserver_root_url(facet='code')
and avoid an extra import.

Also, can you please change the code.launchpad.dev page to look like:
http://people.canonical.com/~tim/package-listing-link-on-homepage.png

I think it reads much better. The first two parts are optional, the changing of the wording on the main page isn't. Since it is a pure appearance thing, I'll approve now.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/code/browser/configure.zcml'
2--- lib/lp/code/browser/configure.zcml 2010-11-18 12:05:34 +0000
3+++ lib/lp/code/browser/configure.zcml 2010-12-13 10:14:40 +0000
4@@ -138,16 +138,23 @@
5 for="canonical.launchpad.interfaces.launchpad.IBazaarApplication"
6 layer="lp.code.publisher.CodeLayer"
7 name="+index"/>
8- <browser:pages
9+ <browser:page
10 for="canonical.launchpad.interfaces.launchpad.IBazaarApplication"
11 layer="lp.code.publisher.CodeLayer"
12 permission="zope.Public"
13 class="lp.code.browser.bazaar.BazaarApplicationView"
14- facet="branches">
15- <browser:page
16- name="+index"
17- template="../templates/bazaar-index.pt"/>
18- </browser:pages>
19+ facet="branches"
20+ name="+index"
21+ template="../templates/bazaar-index.pt" />
22+ <browser:page
23+ for="canonical.launchpad.webapp.interfaces.ILaunchpadRoot"
24+ layer="lp.code.publisher.CodeLayer"
25+ permission="zope.Public"
26+ class="lp.code.browser.recipebuildslisting.CompletedDailyBuildsView"
27+ facet="branches"
28+ name="+daily-builds"
29+ template="../templates/daily-builds-listing.pt" />
30+
31 <browser:page
32 for="lp.code.interfaces.branchmergeproposal.IBranchMergeProposal"
33 layer="lp.code.publisher.CodeLayer"
34
35=== added file 'lib/lp/code/browser/recipebuildslisting.py'
36--- lib/lp/code/browser/recipebuildslisting.py 1970-01-01 00:00:00 +0000
37+++ lib/lp/code/browser/recipebuildslisting.py 2010-12-13 10:14:40 +0000
38@@ -0,0 +1,42 @@
39+# Copyright 2010 Canonical Ltd. This software is licensed under the
40+# GNU Affero General Public License version 3 (see the file LICENSE).
41+
42+"""View for daily builds listings."""
43+
44+__metaclass__ = type
45+
46+__all__ = [
47+ 'CompletedDailyBuildsView',
48+ ]
49+
50+from zope.component import getUtility
51+
52+from canonical.launchpad.webapp import LaunchpadView
53+from canonical.launchpad.webapp.batching import BatchNavigator
54+from lp.code.interfaces.recipebuild import IRecipeBuildRecordSet
55+
56+
57+class RecipeBuildBatchNavigator(BatchNavigator):
58+ """A Batch Navigator turn activate table sorting for single page views."""
59+
60+ @property
61+ def table_class(self):
62+ if self.has_multiple_pages:
63+ return "listing"
64+ else:
65+ return "listing sortable"
66+
67+
68+class CompletedDailyBuildsView(LaunchpadView):
69+ """The view to show completed builds for source package recipes."""
70+
71+ @property
72+ def page_title(self):
73+ return 'Most Recently Completed Daily Recipe Builds'
74+
75+ def initialize(self):
76+ LaunchpadView.initialize(self)
77+ recipe_build_set = getUtility(IRecipeBuildRecordSet)
78+ self.dailybuilds = recipe_build_set.findCompletedDailyBuilds()
79+ self.batchnav = RecipeBuildBatchNavigator(
80+ self.dailybuilds, self.request)
81
82=== added file 'lib/lp/code/browser/tests/test_recipebuildslisting.py'
83--- lib/lp/code/browser/tests/test_recipebuildslisting.py 1970-01-01 00:00:00 +0000
84+++ lib/lp/code/browser/tests/test_recipebuildslisting.py 2010-12-13 10:14:40 +0000
85@@ -0,0 +1,89 @@
86+# Copyright 2010 Canonical Ltd. This software is licensed under the
87+# GNU Affero General Public License version 3 (see the file LICENSE).
88+
89+"""Tests for recipe build listings."""
90+
91+__metaclass__ = type
92+
93+
94+from zope.component import getUtility
95+from zope.security.proxy import removeSecurityProxy
96+
97+from canonical.launchpad.webapp.interfaces import ILaunchpadRoot
98+from canonical.testing.layers import DatabaseFunctionalLayer
99+from lp.testing import (
100+ ANONYMOUS,
101+ BrowserTestCase,
102+ login,
103+ TestCaseWithFactory,
104+ )
105+from lp.testing.views import create_initialized_view
106+
107+
108+class TestRecipeBuildView(TestCaseWithFactory):
109+ """Tests for `CompletedDailyBuildsView`."""
110+
111+ layer = DatabaseFunctionalLayer
112+
113+ def test_recipebuildrecords(self):
114+ # Check that the view is created from the url and loads the expected
115+ # records.
116+ records = self.factory.makeRecipeBuildRecords(10, 5)
117+ login(ANONYMOUS)
118+ root = getUtility(ILaunchpadRoot)
119+ view = create_initialized_view(root, "+daily-builds", rootsite='code')
120+ # It's easier to do it this way than sorted() since __lt__ doesn't
121+ # work properly on zope proxies.
122+ self.assertEqual(10, view.dailybuilds.count())
123+ self.assertEqual(set(records), set(view.dailybuilds))
124+
125+
126+class TestRecipeBuildListing(BrowserTestCase):
127+ """Browser tests for the Recipe Build Listing page."""
128+ layer = DatabaseFunctionalLayer
129+
130+ def _test_recipebuild_listing(self, no_login=False):
131+ # Test the content of the listing when there is data.
132+ [record] = self.factory.makeRecipeBuildRecords(1, 5)
133+ naked_recipebuild = removeSecurityProxy(record.recipebuild)
134+ naked_distribution = removeSecurityProxy(
135+ naked_recipebuild.distribution)
136+ root = getUtility(ILaunchpadRoot)
137+ text = self.getMainText(
138+ root, '+daily-builds', rootsite='code', no_login=no_login)
139+ expected_text = '\n'.join(str(item) for item in (
140+ """Most Recently Completed Daily Recipe Builds
141+ Source Package
142+ Recipe
143+ Recipe Owner
144+ Archive
145+ Most Recent Build Time""",
146+ naked_distribution.displayname,
147+ record.sourcepackagename.name,
148+ naked_recipebuild.recipe.name,
149+ record.recipeowner.displayname,
150+ record.archive.displayname,
151+ record.most_recent_build_time.strftime('%Y-%m-%d %H:%M:%S')))
152+ self.assertTextMatchesExpressionIgnoreWhitespace(expected_text, text)
153+
154+ def test_recipebuild_listing_no_records(self):
155+ # Test the expected text when there is no data.
156+ root = getUtility(ILaunchpadRoot)
157+ text = self.getMainText(root, '+daily-builds', rootsite='code')
158+ expected_text = "No recently completed daily builds found."
159+ self.assertTextMatchesExpressionIgnoreWhitespace(expected_text, text)
160+
161+ def test_recipebuild_listing_anonymous(self):
162+ # Ensure we can see the listing when we are not logged in.
163+ self._test_recipebuild_listing(no_login=True)
164+
165+ def test_recipebuild_listing_with_user(self):
166+ # Ensure we can see the listing when we are logged in.
167+ self._test_recipebuild_listing()
168+
169+ def test_recipebuild_url(self):
170+ # Check the browser URL is as expected.
171+ root_url = self.layer.appserver_root_url(facet='code')
172+ user_browser = self.getUserBrowser("%s/+daily-builds" % root_url)
173+ self.assertEqual(
174+ user_browser.url, "%s/+daily-builds" % root_url)
175
176=== modified file 'lib/lp/code/configure.zcml'
177--- lib/lp/code/configure.zcml 2010-11-18 12:05:34 +0000
178+++ lib/lp/code/configure.zcml 2010-12-13 10:14:40 +0000
179@@ -1002,4 +1002,23 @@
180 factory="lp.code.model.sourcepackagerecipebuild.get_recipe_build_for_build_farm_job"
181 name="RECIPEBRANCHBUILD"
182 permission="zope.Public"/>
183+
184+ <!-- RecipeBuildRecordSet and related classes-->
185+
186+ <securedutility
187+ class="lp.code.model.recipebuild.RecipeBuildRecordSet"
188+ provides="lp.code.interfaces.recipebuild.IRecipeBuildRecordSet">
189+ <allow interface="lp.code.interfaces.recipebuild.IRecipeBuildRecordSet"/>
190+ </securedutility>
191+
192+ <class class="lp.code.model.recipebuild.RecipeBuildRecord">
193+ <allow attributes="sourcepackagename recipeowner recipebuild archive
194+ most_recent_build_time distro_source_package"/>
195+ </class>
196+
197+ <class class="lp.code.model.recipebuild.RecipeBuildRecordResultSet">
198+ <allow interface="storm.zope.interfaces.IResultSet" />
199+ <allow attributes="__getslice__" />
200+ </class>
201+
202 </configure>
203
204=== added file 'lib/lp/code/interfaces/recipebuild.py'
205--- lib/lp/code/interfaces/recipebuild.py 1970-01-01 00:00:00 +0000
206+++ lib/lp/code/interfaces/recipebuild.py 2010-12-13 10:14:40 +0000
207@@ -0,0 +1,27 @@
208+# Copyright 2010 Canonical Ltd. This software is licensed under the
209+# GNU Affero General Public License version 3 (see the file LICENSE).
210+
211+# pylint: disable-msg=E0211,E0213
212+
213+"""Recipe build interfaces."""
214+
215+__metaclass__ = type
216+
217+__all__ = [
218+ 'IRecipeBuildRecordSet',
219+ ]
220+
221+from zope.interface import (
222+ Interface,
223+ )
224+
225+
226+class IRecipeBuildRecordSet(Interface):
227+ """Interface representing a set of recipe build records."""
228+
229+ def findCompletedDailyBuilds(epoch_days=30):
230+ """Find the recently completed daily builds.
231+
232+ :param epoch_days: only find builds completed in last epoch_days
233+ days. If None, return all completed builds.
234+ """
235
236=== added file 'lib/lp/code/model/recipebuild.py'
237--- lib/lp/code/model/recipebuild.py 1970-01-01 00:00:00 +0000
238+++ lib/lp/code/model/recipebuild.py 2010-12-13 10:14:40 +0000
239@@ -0,0 +1,178 @@
240+# Copyright 2010 Canonical Ltd. This software is licensed under the
241+# GNU Affero General Public License version 3 (see the file LICENSE).
242+
243+# pylint: disable-msg=E0611,W0212
244+
245+__metaclass__ = type
246+
247+__all__ = [
248+ 'RecipeBuildRecord',
249+ 'RecipeBuildRecordSet',
250+ ]
251+
252+from collections import namedtuple
253+from datetime import datetime, timedelta
254+
255+import pytz
256+from storm.expr import (
257+ compile,
258+ EXPR,
259+ Expr,
260+ Join,
261+ Max,
262+ Select)
263+from storm import Undef
264+
265+from zope.interface import implements
266+
267+from canonical.launchpad.components.decoratedresultset import (
268+ DecoratedResultSet,
269+ )
270+from canonical.launchpad.interfaces.lpstorm import ISlaveStore
271+
272+from lp.buildmaster.enums import BuildStatus
273+from lp.buildmaster.model.buildfarmjob import BuildFarmJob
274+from lp.buildmaster.model.packagebuild import PackageBuild
275+from lp.code.interfaces.recipebuild import IRecipeBuildRecordSet
276+from lp.code.model.sourcepackagerecipebuild import SourcePackageRecipeBuild
277+from lp.code.model.sourcepackagerecipe import SourcePackageRecipe
278+from lp.registry.model.person import Person
279+from lp.registry.model.sourcepackagename import SourcePackageName
280+from lp.soyuz.model.archive import Archive
281+from lp.soyuz.model.binarypackagebuild import BinaryPackageBuild
282+from lp.soyuz.model.sourcepackagerelease import SourcePackageRelease
283+
284+
285+class RecipeBuildRecord(namedtuple(
286+ 'RecipeBuildRecord',
287+ """sourcepackagename, recipeowner, archive, recipebuild,
288+ most_recent_build_time""")):
289+ # We need to implement our own equality check since __eq__ is broken on
290+ # SourcePackageRecipe. It's broken there because __eq__ is broken,
291+ # or not supported, on storm's ReferenceSet implementation.
292+ def __eq__(self, other):
293+ return (self.sourcepackagename == other.sourcepackagename
294+ and self.recipeowner == other.recipeowner
295+ and self.recipebuild.recipe.name == other.recipebuild.recipe.name
296+ and self.archive == other.archive
297+ and self.most_recent_build_time == other.most_recent_build_time)
298+
299+ def __hash__(self):
300+ return (
301+ hash(self.sourcepackagename.name) ^
302+ hash(self.recipeowner.name) ^
303+ hash(self.recipebuild.recipe.name) ^
304+ hash(self.archive.name) ^
305+ hash(self.most_recent_build_time))
306+
307+ @property
308+ def distro_source_package(self):
309+ return self.recipebuild.distribution.getSourcePackage(
310+ self.sourcepackagename)
311+
312+
313+class RecipeBuildRecordSet:
314+ """See `IRecipeBuildRecordSet`."""
315+
316+ implements(IRecipeBuildRecordSet)
317+
318+ def findCompletedDailyBuilds(self, epoch_days=30):
319+ """See `IRecipeBuildRecordSet`."""
320+
321+ store = ISlaveStore(SourcePackageRecipe)
322+ tables = [
323+ SourcePackageRecipe,
324+ Join(Person,
325+ Person.id == SourcePackageRecipe.owner_id),
326+ Join(SourcePackageRecipeBuild,
327+ SourcePackageRecipeBuild.recipe_id ==
328+ SourcePackageRecipe.id),
329+ Join(SourcePackageRelease,
330+ SourcePackageRecipeBuild.id ==
331+ SourcePackageRelease.source_package_recipe_build_id),
332+ Join(SourcePackageName,
333+ SourcePackageRelease.sourcepackagename ==
334+ SourcePackageName.id),
335+ Join(BinaryPackageBuild,
336+ BinaryPackageBuild.source_package_release_id ==
337+ SourcePackageRelease.id),
338+ Join(PackageBuild,
339+ PackageBuild.id ==
340+ BinaryPackageBuild.package_build_id),
341+ Join(Archive,
342+ Archive.id ==
343+ PackageBuild.archive_id),
344+ Join(BuildFarmJob,
345+ BuildFarmJob.id ==
346+ PackageBuild.build_farm_job_id),
347+ ]
348+
349+ where = [BuildFarmJob.status == BuildStatus.FULLYBUILT,
350+ SourcePackageRecipe.build_daily]
351+ if epoch_days is not None:
352+ epoch = datetime.now(pytz.UTC) - timedelta(days=epoch_days)
353+ where.append(BuildFarmJob.date_finished >= epoch)
354+
355+ result_set = store.using(*tables).find(
356+ (SourcePackageName,
357+ Person,
358+ SourcePackageRecipeBuild,
359+ Archive,
360+ Max(BuildFarmJob.date_finished),
361+ ),
362+ *where
363+ ).group_by(
364+ SourcePackageName,
365+ Person,
366+ SourcePackageRecipeBuild,
367+ Archive,
368+ ).order_by(
369+ SourcePackageName.name,
370+ Person.name,
371+ Archive.name,
372+ )
373+
374+ def _makeRecipeBuildRecord(values):
375+ (sourcepackagename, recipeowner, recipebuild, archive,
376+ date_finished) = values
377+ return RecipeBuildRecord(
378+ sourcepackagename, recipeowner,
379+ archive, recipebuild,
380+ date_finished)
381+
382+ return RecipeBuildRecordResultSet(
383+ result_set, _makeRecipeBuildRecord)
384+
385+
386+# XXX: wallyworld 2010-11-26 bug=675377: storm's Count() implementation is
387+# broken for distinct with > 1 column
388+class CountDistinct(Expr):
389+
390+ __slots__ = ("columns")
391+
392+ def __init__(self, columns):
393+ self.columns = columns
394+
395+
396+@compile.when(CountDistinct)
397+def compile_countdistinct(compile, countselect, state):
398+ state.push("context", EXPR)
399+ col = compile(countselect.columns)
400+ state.pop()
401+ return "count(distinct(%s))" % col
402+
403+
404+class RecipeBuildRecordResultSet(DecoratedResultSet):
405+ """A ResultSet which can count() queries with group by."""
406+
407+ def count(self, expr=Undef, distinct=True):
408+ """This count() knows how to handle result sets with group by."""
409+
410+ # We don't support distinct=False for this result set
411+ select = Select(
412+ columns=CountDistinct(self.result_set._group_by),
413+ tables = self.result_set._tables,
414+ where = self.result_set._where,
415+ )
416+ result = self.result_set._store.execute(select)
417+ return result.get_one()[0]
418
419=== modified file 'lib/lp/code/stories/branches/xx-bazaar-home.txt'
420--- lib/lp/code/stories/branches/xx-bazaar-home.txt 2010-09-06 21:19:30 +0000
421+++ lib/lp/code/stories/branches/xx-bazaar-home.txt 2010-12-13 10:14:40 +0000
422@@ -77,14 +77,13 @@
423 >>> links.renderContents()
424 '...1...&rarr;...6...of...28...results...'
425
426- >>> browser.goBack()
427-
428
429 == Recently changed ==
430
431 Recently changed branches are ordered with the branches with the most
432 recent commits first.
433
434+ >>> browser.open('http://code.launchpad.dev/')
435 >>> changed = find_tag_by_id(browser.contents, 'recently-changed')
436 >>> print changed.fetch('a')[-1]['href']
437 /+recently-changed-branches
438@@ -103,7 +102,6 @@
439 Last Modified
440 Last Commit
441
442- >>> browser.goBack()
443
444
445 == Recently imported ==
446@@ -111,6 +109,7 @@
447 Recently imported branches are ordered by recent commits only in
448 imported branches.
449
450+ >>> browser.open('http://code.launchpad.dev/')
451 >>> imported = find_tag_by_id(browser.contents, 'recent-imports')
452 >>> print imported.fetch('a')[-1]['href']
453 /+recently-imported-branches
454@@ -132,3 +131,25 @@
455 Project
456 Last Modified
457 Last Commit
458+
459+
460+== Recently built recipes ==
461+
462+Source package recipes have their own section on the home page.
463+
464+ >>> browser.open('http://code.launchpad.dev/')
465+ >>> changed = find_tag_by_id(browser.contents, 'build-recipes')
466+ >>> print changed.fetch('a')[0]['href']
467+ /+daily-builds
468+ >>> print changed.fetch('a')[-1]['href']
469+ https://help.launchpad.net/Packaging/SourceBuilds
470+
471+ >>> browser.getLink(url='daily-builds').click()
472+ >>> print browser.title
473+ Most Recently Completed Daily Recipe Builds
474+
475+ >>> text = extract_text(browser.contents)
476+ >>> print text
477+ Most Recently Completed Daily Recipe Builds ...
478+ No recently completed daily builds found. ...
479+
480
481=== modified file 'lib/lp/code/templates/bazaar-index.pt'
482--- lib/lp/code/templates/bazaar-index.pt 2010-09-06 21:19:30 +0000
483+++ lib/lp/code/templates/bazaar-index.pt 2010-12-13 10:14:40 +0000
484@@ -52,6 +52,12 @@
485 so you can use Bazaar with those too.
486 (<a href="https://help.launchpad.net/Code">Read our guide</a>)
487 </p>
488+ <p id="build-recipes" class="application-summary" style="padding-top: 0.25em;">
489+ Launchpad can build Ubuntu packages directly from branches using recipes.
490+ We currently build over <a href="/+daily-builds">100 different packages</a>
491+ into PPAs automatically using this method.
492+ (<a href="https://help.launchpad.net/Packaging/SourceBuilds">Learn more about recipes</a>)
493+ </p>
494
495 <div id="project-cloud-preview" tal:content="cache:public,6 hours">
496 <h2>Most active projects in the last month</h2>
497
498=== added file 'lib/lp/code/templates/daily-builds-listing.pt'
499--- lib/lp/code/templates/daily-builds-listing.pt 1970-01-01 00:00:00 +0000
500+++ lib/lp/code/templates/daily-builds-listing.pt 2010-12-13 10:14:40 +0000
501@@ -0,0 +1,93 @@
502+<html
503+ xmlns="http://www.w3.org/1999/xhtml"
504+ xmlns:tal="http://xml.zope.org/namespaces/tal"
505+ xmlns:metal="http://xml.zope.org/namespaces/metal"
506+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
507+ metal:use-macro="view/macro:page/main_only"
508+ i18n:domain="launchpad">
509+
510+ <body>
511+
512+ <metal:heading fill-slot="heading">
513+ <h1 tal:content="view/page_title" />
514+ </metal:heading>
515+
516+ <div metal:fill-slot="main"
517+ tal:define="dailybuilds view/batchnav/currentBatch">
518+
519+ <tal:block tal:condition="not:dailybuilds">
520+ <p id="no-builds">No recently completed daily builds found.</p>
521+ </tal:block>
522+
523+ <tal:block tal:condition="dailybuilds">
524+
525+ <tal:needs-batch condition="view/batchnav/has_multiple_pages">
526+ <div class="lesser" tal:content="structure view/batchnav/@@+navigation-links-upper"/>
527+ </tal:needs-batch>
528+
529+ <table tal:attributes="class view/batchnav/table_class" id="daily-build-listing">
530+ <thead>
531+ <tr>
532+ <th>
533+ Source Package
534+ </th>
535+ <th>
536+ Recipe
537+ </th>
538+ <th>
539+ Recipe Owner
540+ </th>
541+ <th>
542+ Archive
543+ </th>
544+ <th>
545+ Most Recent Build Time
546+ </th>
547+ </tr>
548+ </thead>
549+ <tbody>
550+ <tr tal:repeat="dailybuild dailybuilds">
551+ <td>
552+ <a tal:replace="structure dailybuild/recipebuild/distribution/fmt:link">distribution</a>
553+ <a tal:attributes="href dailybuild/distro_source_package/fmt:url"
554+ tal:content="dailybuild/sourcepackagename/name">source package name</a>
555+ </td>
556+
557+ <td>
558+ <a href="recipe" tal:attributes="href dailybuild/recipebuild/recipe/fmt:url">
559+ <span tal:replace="dailybuild/recipebuild/recipe/name">
560+ recipe
561+ </span>
562+ </a>
563+ </td>
564+
565+ <td>
566+ <tal:recipeowner replace="structure dailybuild/recipeowner/fmt:link">
567+ recipe owner
568+ </tal:recipeowner>
569+ </td>
570+
571+ <td>
572+ <tal:archivelink tal:condition="dailybuild/archive/is_ppa"
573+ replace="structure dailybuild/archive/fmt:link">
574+ archive link
575+ </tal:archivelink>
576+ <tal:archive tal:condition="not:dailybuild/archive/is_ppa"
577+ replace="structure dailybuild/archive/displayname">
578+ archive name
579+ </tal:archive>
580+ </td>
581+
582+ <td tal:content="dailybuild/most_recent_build_time/fmt:datetime">
583+ a date
584+ </td>
585+
586+ </tr>
587+ </tbody>
588+ </table>
589+ <div class="lesser" tal:content="structure view/batchnav/@@+navigation-links-lower" />
590+ </tal:block>
591+ </div>
592+
593+ </body>
594+</html>
595
596=== modified file 'lib/lp/testing/__init__.py'
597--- lib/lp/testing/__init__.py 2010-12-03 16:33:03 +0000
598+++ lib/lp/testing/__init__.py 2010-12-13 10:14:40 +0000
599@@ -702,9 +702,10 @@
600 super(BrowserTestCase, self).setUp()
601 self.user = self.factory.makePerson(password='test')
602
603- def getViewBrowser(self, context, view_name=None, no_login=False):
604+ def getViewBrowser(self, context, view_name=None, no_login=False,
605+ rootsite=None):
606 login(ANONYMOUS)
607- url = canonical_url(context, view_name=view_name)
608+ url = canonical_url(context, view_name=view_name ,rootsite=rootsite)
609 logout()
610 if no_login:
611 from canonical.launchpad.testing.pages import setupBrowser
612@@ -714,11 +715,13 @@
613 else:
614 return self.getUserBrowser(url, self.user)
615
616- def getMainText(self, context, view_name=None):
617+ def getMainText(
618+ self, context, view_name=None, rootsite=None, no_login=False):
619 """Return the main text of a context's page."""
620 from canonical.launchpad.testing.pages import (
621 extract_text, find_main_content)
622- browser = self.getViewBrowser(context, view_name)
623+ browser = self.getViewBrowser(
624+ context, view_name, rootsite=rootsite, no_login=no_login)
625 return extract_text(find_main_content(browser.contents))
626
627
628
629=== modified file 'lib/lp/testing/factory.py'
630--- lib/lp/testing/factory.py 2010-12-09 11:03:53 +0000
631+++ lib/lp/testing/factory.py 2010-12-13 10:14:40 +0000
632@@ -7,6 +7,7 @@
633
634 This module should not contain tests (but it should be tested).
635 """
636+from lp.code.model.recipebuild import RecipeBuildRecord
637
638 __metaclass__ = type
639 __all__ = [
640@@ -36,10 +37,12 @@
641 isSequenceType,
642 )
643 import os
644+from pytz import UTC
645 from random import randint
646 from StringIO import StringIO
647 from textwrap import dedent
648 from threading import local
649+import transaction
650 from types import InstanceType
651 import warnings
652
653@@ -1283,6 +1286,7 @@
654 source_branch = self.makeBranch()
655 else:
656 source_branch = merge_proposal.source_branch
657+
658 def make_revision(parent=None):
659 sequence = source_branch.revision_history.count() + 1
660 if parent is None:
661@@ -2284,6 +2288,80 @@
662 store.add(bq)
663 return bq
664
665+ def makeRecipeBuildRecords(self, num_records=1,
666+ num_records_outside_epoch=0, epoch_days=30):
667+ """Create some recipe build records.
668+
669+ A RecipeBuildRecord is a named tuple. Some records will be created
670+ with archive of type ArchivePurpose.PRIMARY, others with type
671+ ArchivePurpose.PPA.
672+ :param num_records: the number of records within the specified time
673+ window.
674+ :param num_records_outside_epoch: the number of records outside the
675+ specified time window.
676+ :param epoch_days: the time window to use when creating records.
677+ """
678+
679+ distroseries = self.makeDistroRelease()
680+ sourcepackagename = self.makeSourcePackageName()
681+ sourcepackage = self.makeSourcePackage(
682+ sourcepackagename=sourcepackagename,
683+ distroseries=distroseries)
684+ recipeowner = self.makePerson()
685+ recipe = self.makeSourcePackageRecipe(
686+ build_daily=True,
687+ owner=recipeowner,
688+ name="Recipe_"+sourcepackagename.name,
689+ distroseries=distroseries)
690+
691+ result = []
692+ for x in range(num_records+num_records_outside_epoch):
693+ # Ensure we have both ppa and primary archives
694+ if x%2 == 0:
695+ purpose = ArchivePurpose.PPA
696+ else:
697+ purpose = ArchivePurpose.PRIMARY
698+ archive = self.makeArchive(purpose=purpose)
699+ sprb = self.makeSourcePackageRecipeBuild(
700+ requester=recipeowner,
701+ recipe=recipe,
702+ archive=archive,
703+ sourcepackage=sourcepackage,
704+ distroseries=distroseries)
705+ spr = self.makeSourcePackageRelease(
706+ source_package_recipe_build=sprb,
707+ archive=archive,
708+ sourcepackagename=sourcepackagename,
709+ distroseries=distroseries)
710+ binary_build = self.makeBinaryPackageBuild(
711+ source_package_release=spr)
712+ naked_build = removeSecurityProxy(binary_build)
713+ naked_build.queueBuild()
714+ naked_build.status = BuildStatus.FULLYBUILT
715+
716+ from random import randrange
717+ offset = randrange(0, epoch_days)
718+ now = datetime.now(UTC)
719+ if x >= num_records:
720+ offset = epoch_days + 1 + offset
721+ naked_build.date_finished = (
722+ now - timedelta(days=offset))
723+ naked_build.date_started = (
724+ naked_build.date_finished - timedelta(minutes=5))
725+ rbr = RecipeBuildRecord(
726+ removeSecurityProxy(sourcepackagename),
727+ removeSecurityProxy(recipeowner),
728+ removeSecurityProxy(archive),
729+ removeSecurityProxy(sprb),
730+ naked_build.date_finished.replace(tzinfo=None))
731+
732+ if x < num_records:
733+ result.append(rbr)
734+ # We need to explicitly commit because if don't, the records don't
735+ # appear in the slave datastore.
736+ transaction.commit()
737+ return result
738+
739 def makeDscFile(self, tempdir_path=None):
740 """Make a DscFile.
741