The change is basically fine, and clearly a good idea. I have a number of code clarity comments though, see below. > === modified file 'lib/canonical/launchpad/webapp/batching.py' > --- lib/canonical/launchpad/webapp/batching.py 2010-09-06 18:52:45 +0000 > +++ lib/canonical/launchpad/webapp/batching.py 2010-09-19 22:21:05 +0000 > @@ -77,6 +77,24 @@ > return self.batch.total() > self.batch.size > > > +class ActiveBatchNavigator(BatchNavigator): > + """A paginator for active items. > + > + Used when a view needs to display more than one BatchNavigator of items. > + """ > + start_variable_name = 'active_start' > + batch_variable_name = 'active_batch' > + > + > +class InactiveBatchNavigator(BatchNavigator): > + """A paginator for inactive items. > + > + Used when a view needs to display more than one BatchNavigator of items. > + """ > + start_variable_name = 'inactive_start' > + batch_variable_name = 'inactive_batch' > + > + These are fine and clear enough -- are they generic enough to go in a generic enough location though? Later: oh, I see you moved them from other code. It's a shame you have to subclass to change these. > class TableBatchNavigator(BatchNavigator): > """See canonical.launchpad.interfaces.ITableBatchNavigator.""" > implements(ITableBatchNavigator) > === modified file 'lib/lp/blueprints/browser/tests/test_specificationtarget.py' > --- lib/lp/blueprints/browser/tests/test_specificationtarget.py 2010-09-17 14:04:29 +0000 > +++ lib/lp/blueprints/browser/tests/test_specificationtarget.py 2010-09-19 22:21:05 +0000 > @@ -3,11 +3,9 @@ > > __metaclass__ = type > > -import unittest > > from zope.security.proxy import removeSecurityProxy > > -from canonical.launchpad.webapp.batching import BatchNavigator > from canonical.launchpad.testing.pages import find_tag_by_id > from canonical.testing.layers import DatabaseFunctionalLayer > from lp.blueprints.interfaces.specificationtarget import ( > @@ -21,6 +19,7 @@ > login_person, > TestCaseWithFactory, > ) > +from lp.testing.matchers import IsConfiguredBatchNavigator > from lp.testing.views import ( > create_view, > create_initialized_view, > @@ -108,22 +107,11 @@ > # subclass. > person = self.factory.makePerson() > view = create_initialized_view(person, name='+assignments') Just up from here there's a comment: # Some pages turn up in very large contexts and patch. E.g. # Distro:+assignments which uses SpecificationAssignmentsView, a # subclass. Is patch a typo for batch here? > - self.assertIsInstance(view.specs_batched, BatchNavigator) > - > - def test_batch_headings(self): > - person = self.factory.makePerson() > - view = create_initialized_view(person, name='+assignments') > - navigator = view.specs_batched > - self.assertEqual('specification', navigator._singular_heading) > - self.assertEqual('specifications', navigator._plural_heading) > - > - def test_batch_size(self): > # Because +assignments is meant to provide an overview, we default to > # 500 as the default batch size. > - person = self.factory.makePerson() > - view = create_initialized_view(person, name='+assignments') > - navigator = view.specs_batched > - self.assertEqual(500, view.specs_batched.default_size) > + matcher = IsConfiguredBatchNavigator( > + 'specification', 'specifications', batch_size=500) > + self.assertThat(view.specs_batched, matcher) I think this reads more clearly if you construct the matcher inline. > > class TestAssignments(TestCaseWithFactory): > @@ -243,13 +231,3 @@ > login_person(project_group.owner) > view = create_initialized_view(project_group, '+specs') > self.assertEqual(False, view.can_configure_blueprints) > - > - > -def test_suite(): > - suite = unittest.TestSuite() > - suite.addTest(unittest.TestLoader().loadTestsFromName(__name__)) > - return suite > - > - > -if __name__ == '__main__': > - unittest.TextTestRunner().run(test_suite()) > === modified file 'lib/lp/bugs/browser/bugtracker.py' > --- lib/lp/bugs/browser/bugtracker.py 2010-09-03 15:02:39 +0000 > +++ lib/lp/bugs/browser/bugtracker.py 2010-09-19 22:21:05 +0000 > @@ -54,7 +54,11 @@ > structured, > ) > from canonical.launchpad.webapp.authorization import check_permission > -from canonical.launchpad.webapp.batching import BatchNavigator > +from canonical.launchpad.webapp.batching import ( > + ActiveBatchNavigator, > + BatchNavigator, > + InactiveBatchNavigator, > + ) > from canonical.launchpad.webapp.breadcrumb import Breadcrumb > from canonical.launchpad.webapp.menu import NavigationMenu > from canonical.lazr.utils import smartquote > @@ -154,21 +158,30 @@ > pillar_limit = 3 > > def initialize(self): > - # Sort the bug trackers into active and inactive lists so that > - # we can display them separately. > - all_bug_trackers = list(self.context) > - self.active_bug_trackers = [ > - bug_tracker for bug_tracker in all_bug_trackers > - if bug_tracker.active] > - self.inactive_bug_trackers = [ > - bug_tracker for bug_tracker in all_bug_trackers > - if not bug_tracker.active] > - > - bugtrackerset = getUtility(IBugTrackerSet) > - # The caching of bugtracker pillars here avoids us hitting the > - # database multiple times for each bugtracker. > - self._pillar_cache = bugtrackerset.getPillarsForBugtrackers( > - all_bug_trackers) > + # eager load related pillars. In future we should do this for > + # just the rendered trackers, and also use group by to get Trailing whitespace here. > + # bug watch counts per tracker. However the batching makes > + # the inefficiency tolerable for now. Robert Collins 20100919. > + self._pillar_cache = self.context.getPillarsForBugtrackers( > + list(self.context.trackers())) > + > + @property > + def inactive_tracker_count(self): > + return self.inactive_trackers.currentBatch().listlength > + > + @cachedproperty > + def active_trackers(self): > + results = self.context.trackers(active=True) > + navigator = ActiveBatchNavigator(results, self.request) > + navigator.setHeadings('tracker', 'trackers') > + return navigator > + > + @cachedproperty > + def inactive_trackers(self): > + results = self.context.trackers(active=False) > + navigator = InactiveBatchNavigator(results, self.request) > + navigator.setHeadings('tracker', 'trackers') > + return navigator > > def getPillarData(self, bugtracker): > """Return dict of pillars and booleans indicating ellipsis. > === modified file 'lib/lp/bugs/browser/tests/test_bugtracker_views.py' > --- lib/lp/bugs/browser/tests/test_bugtracker_views.py 2010-08-26 20:08:43 +0000 > +++ lib/lp/bugs/browser/tests/test_bugtracker_views.py 2010-09-19 22:21:05 +0000 > @@ -3,27 +3,61 @@ > > """Tests for BugTracker views.""" > > +from __future__ import with_statement > + > __metaclass__ = type > > -import unittest > - > -from datetime import datetime, timedelta > -from pytz import utc > - > from zope.component import getUtility > -from zope.security.interfaces import Unauthorized > > -from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities > +from canonical.launchpad.testing.pages import find_tag_by_id > +from canonical.launchpad.webapp import canonical_url > from canonical.testing.layers import DatabaseFunctionalLayer > - > -from lp.bugs.browser.bugtracker import ( > - BugTrackerEditView) > +from lp.bugs.interfaces.bugtracker import IBugTrackerSet > from lp.registry.interfaces.person import IPersonSet > -from lp.testing import login, TestCaseWithFactory > -from lp.testing.sampledata import ADMIN_EMAIL, NO_PRIVILEGE_EMAIL > +from lp.testing import ( > + login, > + TestCaseWithFactory, > + ) > from lp.testing.views import create_initialized_view > - > - > - > -def test_suite(): > - return unittest.TestLoader().loadTestsFromName(__name__) > +from lp.testing.matchers import IsConfiguredBatchNavigator > +from lp.testing import ( > + person_logged_in, > + TestCaseWithFactory, > + ) > +from lp.testing.sampledata import ADMIN_EMAIL > + > + > +class TestBugTrackerSetView(TestCaseWithFactory): > + > + layer = DatabaseFunctionalLayer > + > + def test_trackers_are_batch_navigators(self): > + trackers = getUtility(IBugTrackerSet) > + view = create_initialized_view(trackers, name='+index') > + matcher = IsConfiguredBatchNavigator('tracker', 'trackers') > + self.assertThat(view.active_trackers, matcher) > + self.assertThat(view.inactive_trackers, matcher) > + > + def test_page_is_batched(self): > + active_tracker1 = self.factory.makeBugTracker() > + active_tracker2 = self.factory.makeBugTracker() > + inactive_tracker1 = self.factory.makeBugTracker() > + inactive_tracker2 = self.factory.makeBugTracker() > + admin = getUtility(IPersonSet).find(ADMIN_EMAIL).any() > + with person_logged_in(admin): > + inactive_tracker1.active = False > + inactive_tracker2.active = False > + trackers = getUtility(IBugTrackerSet) > + url = (canonical_url(trackers) + Trailing whitespace here. > + "/+index?active_batch=1&inactive_batch=1") > + browser = self.getUserBrowser(url) > + content = browser.contents > + # XXX RobertCollns 20100919 bug=642504. The support for multiple batches > + # isn't complete and the id for the nav links gets duplicated. > + #self.assertEqual('next', > + # find_tag_by_id(content, 'upper-batch-nav-batchnav-next')['class']) > + #self.assertEqual('next', > + # find_tag_by_id(content, 'lower-batch-nav-batchnav-next')['class']) > + # Instead we check the string appears. > + self.assertTrue('upper-batch-nav-batchnav-next' in content) > + > === modified file 'lib/lp/bugs/interfaces/bugtracker.py' > --- lib/lp/bugs/interfaces/bugtracker.py 2010-08-26 20:08:43 +0000 > +++ lib/lp/bugs/interfaces/bugtracker.py 2010-09-19 22:21:05 +0000 > @@ -432,6 +432,13 @@ > def getPillarsForBugtrackers(bug_trackers): > """Return dict mapping bugtrackers to lists of pillars.""" > > + def trackers(active=None): > + """Return a ResultSet of bugtrackers. > + > + :param active: If True, only active trackers are returned, if False > + only inactive trackers are returned. > + """ I think you should also say "by default, all trackers are returned". > > class IBugTrackerAlias(Interface): > """Another URL for a remote bug system. > === modified file 'lib/lp/bugs/model/bugtracker.py' > --- lib/lp/bugs/model/bugtracker.py 2010-08-26 20:08:43 +0000 > +++ lib/lp/bugs/model/bugtracker.py 2010-09-19 22:21:05 +0000 > @@ -8,7 +8,8 @@ > 'BugTracker', > 'BugTrackerAlias', > 'BugTrackerAliasSet', > - 'BugTrackerSet'] > + 'BugTrackerSet', > + ] > > > from datetime import datetime > @@ -66,6 +67,8 @@ > IBugTrackerSet, > SINGLE_PRODUCT_BUGTRACKERTYPES, > ) > +from canonical.launchpad.webapp.interfaces import ( > + DEFAULT_FLAVOR, IStoreSelector, MAIN_STORE) > from lp.bugs.interfaces.bugtrackerperson import BugTrackerPersonAlreadyExists > from lp.bugs.model.bug import Bug > from lp.bugs.model.bugmessage import BugMessage > @@ -577,6 +580,17 @@ > """See `IBugTrackerSet`.""" > return BugTracker.select() > > + def trackers(self, active=None): > + # Without context, cannot tell what store flavour is desirable. I'm not sure this comment adds anything and suggest you remove it. > + store = getUtility(IStoreSelector).get(MAIN_STORE, DEFAULT_FLAVOR) > + if active is not None: > + clauses = [BugTracker.active==active] > + else: > + clauses = [] > + results = store.find(BugTracker, *clauses) > + results.order_by(BugTracker.name) > + return results > + > def ensureBugTracker(self, baseurl, owner, bugtrackertype, > title=None, summary=None, contactdetails=None, name=None): > """See `IBugTrackerSet`.""" > === modified file 'lib/lp/bugs/templates/bugtrackers-index.pt' > --- lib/lp/bugs/templates/bugtrackers-index.pt 2009-09-01 16:24:52 +0000 > +++ lib/lp/bugs/templates/bugtrackers-index.pt 2010-09-19 22:21:05 +0000 > @@ -13,6 +13,7 @@ > > > > + > > > > @@ -24,7 +25,7 @@ > > > > - > + > > @@ -53,6 +54,7 @@ > > >
> >
> + >
>
> > @@ -70,7 +72,7 @@ > the external status changes. >

> > + trackers view/active_trackers"> > > >

@@ -81,11 +83,11 @@ >

> > > -
> +
>
>

Inactive bug trackers

> > + trackers view/inactive_trackers"> > > >
Can I just say that this template is a massive hack? I don't think you've made it any worse though. > === modified file 'lib/lp/bugs/tests/test_bugtracker.py' > --- lib/lp/bugs/tests/test_bugtracker.py 2010-08-26 20:08:43 +0000 > +++ lib/lp/bugs/tests/test_bugtracker.py 2010-09-19 22:21:05 +0000 > @@ -28,7 +28,10 @@ > > from canonical.launchpad.ftests import login_person > from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities > -from canonical.testing import LaunchpadFunctionalLayer > +from canonical.testing import ( > + DatabaseFunctionalLayer, > + LaunchpadFunctionalLayer, > + ) > from lp.bugs.externalbugtracker import ( > BugTrackerConnectError, > Mantis, > @@ -38,12 +41,36 @@ > BugTrackerType, > IBugTracker, > ) > +from lp.bugs.model.bugtracker import BugTrackerSet > from lp.bugs.tests.externalbugtracker import Urlib2TransportTestHandler > from lp.registry.interfaces.person import IPersonSet > from lp.testing import login, login_person, TestCaseWithFactory > from lp.testing.sampledata import ADMIN_EMAIL > > > +class TestBugTrackerSet(TestCaseWithFactory): > + > + layer = DatabaseFunctionalLayer > + > + def test_trackers(self): > + tracker = self.factory.makeBugTracker() > + trackers = BugTrackerSet() > + # Active trackers are in all trackers, > + self.assertTrue(tracker in trackers.trackers()) > + # and active, > + self.assertTrue(tracker in trackers.trackers(active=True)) > + # But not inactive. > + self.assertFalse(tracker in trackers.trackers(active=False)) > + login(ADMIN_EMAIL) > + tracker.active = False > + # Inactive trackers are in all trackers > + self.assertTrue(tracker in trackers.trackers()) > + # and inactive, > + self.assertTrue(tracker in trackers.trackers(active=False)) > + # but not in active. > + self.assertFalse(tracker in trackers.trackers(active=True)) I kind of hate tests with lots of assertions like this, and it's a shame that makeBugTracker doesn't take an active= argument, but I don't think I'm going to insist you change anything here... > class BugTrackerTestCase(TestCaseWithFactory): > """Unit tests for the `BugTracker` class.""" > > === modified file 'lib/lp/testing/matchers.py' > --- lib/lp/testing/matchers.py 2010-08-25 20:04:40 +0000 > +++ lib/lp/testing/matchers.py 2010-09-19 22:21:05 +0000 > @@ -17,9 +17,12 @@ > from testtools.content import Content > from testtools.content_type import UTF8_TEXT > from testtools.matchers import ( > + Equals, > Matcher, > Mismatch, > + MismatchesAll, > ) > +from testtools import matchers > from zope.interface.exceptions import ( > BrokenImplementation, > BrokenMethodImplementation, > @@ -31,6 +34,8 @@ > Proxy, > ) > > +from canonical.launchpad.webapp.batching import BatchNavigator > + > > class DoesNotProvide(Mismatch): > """An object does not provide an interface.""" > @@ -220,3 +225,44 @@ > if not matchee.startswith(self.expected): > return DoesNotStartWith(matchee, self.expected) > return None > + > + > +class IsConfiguredBatchNavigator(Matcher): > + """Check that an object is a batch navigator.""" > + > + def __init__(self, singular, plural, batch_size=None): > + """Create a ConfiguredBatchNavigator. > + > + :param singular: The singular header the batch should be using. > + :param plural: The plural header the batch should be using. Can you document the batch_size parameter? > + """ > + self._single = Equals(singular) > + self._plural = Equals(plural) > + self._batch = None > + if batch_size: > + self._batch = Equals(batch_size) > + self.matchers = dict( > + _singular_heading=self._single, _plural_heading=self._plural) > + if self._batch: > + self.matchers['default_size'] = self._batch > + > + def __str__(self): > + if self._batch: > + batch = ", %r" % self._batch.expected > + else: > + batch = '' > + return "ConfiguredBatchNavigator(%r, %r%s)" % ( > + self._single.expected, self._plural.expected, batch) > + > + def match(self, matchee): > + if not isinstance(matchee, BatchNavigator): > + # Testtools doesn't have an IsInstanceMismatch yet. > + return matchers._BinaryMismatch( > + BatchNavigator, 'isinstance', matchee) > + mismatches = [] > + for attrname, matcher in self.matchers.items(): > + mismatches.append(matcher.match(getattr(matchee, attrname))) > + if not mismatches[-1]: > + mismatches.pop(-1) On the theory that positive conditions are easier to understand, can you please rewrite this as: for attrname, matcher in self.matchers.items(): mismatch = matcher.match(getattr(matchee, attrname)) if mismatch is not None: mismatches.append(mismatch) > + if mismatches: > + return MismatchesAll(mismatches) This is a nice matcher though, the tests that you changed to use it have become much nicer :-)