Merge lp:~lifeless/launchpad/milestones into lp:launchpad

Proposed by Robert Collins
Status: Merged
Approved by: Curtis Hovey
Approved revision: no longer in the source branch.
Merged at revision: 11409
Proposed branch: lp:~lifeless/launchpad/milestones
Merge into: lp:launchpad
Diff against target: 1014 lines (+349/-154)
20 files modified
lib/canonical/launchpad/interfaces/validation.py (+1/-1)
lib/canonical/launchpad/security.py (+5/-32)
lib/canonical/launchpad/utilities/personroles.py (+4/-0)
lib/lp/bugs/browser/bugtarget.py (+0/-5)
lib/lp/bugs/browser/bugtask.py (+2/-19)
lib/lp/bugs/browser/tests/bugtask-target-link-titles.txt (+1/-1)
lib/lp/bugs/browser/tests/test_bugtarget_filebug.py (+1/-1)
lib/lp/bugs/interfaces/bugtask.py (+8/-0)
lib/lp/bugs/model/bug.py (+28/-5)
lib/lp/bugs/model/bugtarget.py (+1/-7)
lib/lp/bugs/model/bugtask.py (+191/-72)
lib/lp/bugs/stories/bug-privacy/40-unsubscribe-from-private-bug.txt (+2/-2)
lib/lp/bugs/tests/test_bugtask.py (+24/-1)
lib/lp/registry/browser/tests/test_milestone.py (+72/-4)
lib/lp/registry/browser/tests/test_views.py (+1/-0)
lib/lp/registry/doc/distribution.txt (+1/-1)
lib/lp/registry/doc/sourcepackage.txt (+1/-1)
lib/lp/registry/model/distroseries.py (+3/-0)
lib/lp/registry/model/sourcepackage.py (+2/-1)
lib/lp/testing/factory.py (+1/-1)
To merge this branch: bzr merge lp:~lifeless/launchpad/milestones
Reviewer Review Type Date Requested Status
Curtis Hovey (community) code Approve
Deryck Hodge (community) code Approve
Graham Binns (community) code Approve
Review via email: mp+32855@code.launchpad.net

Commit message

Convert BugTaskSet.search to return a storm resultset and cache the user queried with as a viewer on returned Bug objects.

Description of the change

Hopefully fix the current instantiation of https://bugs.edge.launchpad.net/launchpad-registry/+bug/447418.

Private bug access checking is rather slow: https://bugs.edge.launchpad.net/malone/+bug/619039.

Solution - a cached attribute recording people that we've calculated should have view access, prepopulated by BugTaskSet.search().

Added tests for the low level behaviour and that it fixes the observed scaling problem in a simple milestone view.

\o/

To post a comment you must log in.
Revision history for this message
Robert Collins (lifeless) wrote :

Deryck, Curtis, would appreciate sanity checks from you :)

Revision history for this message
Graham Binns (gmb) wrote :
Download full text (5.1 KiB)

Hi Rob,

Couple of nitpicks, but otherwise okay. Marking it needs fixing because
of the over-long test (see below) but if we can't split that up I'm
happy for it to land as-is.

> === modified file 'lib/lp/bugs/tests/test_bugtask.py'
> --- lib/lp/bugs/tests/test_bugtask.py 2010-07-14 14:11:15 +0000
> +++ lib/lp/bugs/tests/test_bugtask.py 2010-08-17 09:41:19 +0000
> @@ -792,6 +796,28 @@
> result = target.searchTasks(None, modified_since=date)
> self.assertEqual([task2], list(result))
>
> + def test_private_bug_view_permissions_cached(self):
> + """private bugs from a search know the user can see the bugs."""

Captialise at the start of the sentence ;).

> + target = self.makeBugTarget()
> + person = self.login()
> + self.factory.makeBug(product=target, private=True, owner=person)
> + self.factory.makeBug(product=target, private=True, owner=person)
> + # Search style and parameters taken from the milestone index view where
> + # the issue was discovered.
> + login_person(person)
> + tasks =target.searchTasks(BugTaskSearchParams(person, omit_dupes=True,

Needs a space after the =.

> + orderby=['status', '-importance', 'id']))

> === modified file 'lib/lp/registry/browser/tests/test_milestone.py'
> --- lib/lp/registry/browser/tests/test_milestone.py 2010-08-16 18:39:11 +0000
> +++ lib/lp/registry/browser/tests/test_milestone.py 2010-08-17 09:41:19 +0000
> @@ -105,5 +116,61 @@
> self.assertEqual(0, product.development_focus.all_bugtasks.count())
>
>
> -def test_suite():
> - return unittest.TestLoader().loadTestsFromName(__name__)
> +class TestMilestoneIndex(TestCaseWithFactory):
> +
> + layer = DatabaseFunctionalLayer
> +
> + def test_more_private_bugs_query_count_is_constant(self):

The test should have a comment at the start of it to save me having to
parse the method name.

> + product = self.factory.makeProduct()
> + login_person(product.owner)
> + milestone = self.factory.makeMilestone(
> + productseries=product.development_focus)
> + bug1 = self.factory.makeBug(product=product, private=True,
> + owner=product.owner)
> + bug1.bugtasks[0].transitionToMilestone(milestone, product.owner)
> + # We look at the page as someone who is a member of a team and the team
> + # is subscribed to the bugs, so that we don't get trivial shortcuts
> + # avoiding queries : test the worst case.
> + subscribed_team = self.factory.makeTeam()
> + viewer = self.factory.makePerson(password="test")
> + with person_logged_in(subscribed_team.teamowner):
> + subscribed_team.addMember(viewer, subscribed_team.teamowner)
> + bug1.subscribe(subscribed_team, product.owner)
> + bug1_url = canonical_url(bug1)
> + milestone_url = canonical_url(milestone)
> + browser = self.getUserBrowser(user=viewer)
> + # Seed the cookie cache and any other cross-request state we may gain
> + # in future. See canonical.launchpad.webapp.serssion: _get_secret.
> + browser.open(milestone_url)
> + collector = QueryC...

Read more...

review: Needs Fixing (code)
Revision history for this message
Robert Collins (lifeless) wrote :

I'll do the tweaks; I don't see a good way (for now) of shrinking the
test; two separate tests would require a magic constant to be shared
between them rather than being derived on the fly; that would be more
fragile.

-Rob

Revision history for this message
Graham Binns (gmb) wrote :

On 17 August 2010 11:48, Robert Collins <email address hidden> wrote:
> I'll do the tweaks; I don't see a good way (for now) of shrinking the
> test; two separate tests would require a magic constant to be shared
> between them rather than being derived on the fly; that would be more
> fragile.
>

Right, that's pretty much what I figured.

Revision history for this message
Graham Binns (gmb) :
review: Approve (code)
Revision history for this message
Curtis Hovey (sinzui) wrote :

MilestoneView._bugtasks uses precache_permission_for_objects() on the bugtasks and the conjoined bugtasks. Does this change allow us to remove precache_permission_for_objects()? Will your test fail if I remove precache_permission_for_objects() from the view?

MilestoneView._bugtasks calls getConjoinedMaster() with a prepared list (1 query) of expected ids. I think we need to keep behaviour; this branch just lowers the access cost of getConjoinedMaster()

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

On Wed, Aug 18, 2010 at 2:18 AM, Curtis Hovey
<email address hidden> wrote:
> Review: Needs Information
> MilestoneView._bugtasks uses precache_permission_for_objects() on the bugtasks and the conjoined bugtasks. Does this change allow us to remove precache_permission_for_objects()? Will your test fail if I remove precache_permission_for_objects() from the view?

For objects queries via that search path, the precaching be removed. I
do not know if getConjoinedMaster accesses the related objects via
that code path - and I suspect in fact that it does not.

> MilestoneView._bugtasks calls getConjoinedMaster() with a prepared list (1 query) of expected ids. I think we need to keep behaviour; this branch just lowers the access cost of getConjoinedMaster()

We can refactor the conjoined master stuff too, but its a separate issue IMO.

-Rob

Revision history for this message
Curtis Hovey (sinzui) wrote :

I do not understand the first part of your answer. I see this in MilestoneView._bugtasks:
        ...
        for task in tasks:
            if task.getConjoinedMaster(bugs_and_tasks[task.bug]) is None:
                non_conjoined_slaves.append(task)
        # Checking bug permissions is expensive. We know from the query that
        # the user has at least launchpad.View on the bugtasks and their bugs.
        precache_permission_for_objects(
            self.request, 'launchpad.View', non_conjoined_slaves)
        precache_permission_for_objects(
            self.request, 'launchpad.View',
            [task.bug for task in non_conjoined_slaves])
        return non_conjoined_slaves

Since you modified the permission rules on bugtask, I wonder if the first precache_permission_for_objects() is not needed. I wonder if permission checking is fast enough.

We stopped adding enahncements to the milestone page when the preformance problems started, and we cannot entertain allowing users to edit the bugtasks unless we know the user has launchpad.Edit. I wonder if this permission change allows us to consider enhancements.

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

I certainly think you can make enchancements, and yes we should be
able to remove the precache permissions for nonconjoinedslaves.

I'd like to do that separately from this branch, as a mitigation : I
know what I've researched works, I'm not sure that there aren't other
gremlins in there.

Revision history for this message
Curtis Hovey (sinzui) wrote :

Yes. I agree that the removal of precache_permissions is a separate branch. Thanks for answering my questions.

review: Approve (code)
Revision history for this message
Deryck Hodge (deryck) wrote :

Looks good to me, Robert. Thanks for doing this work!

Cheers,
deryck

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

I hate to ask for a rereview, but I'll ask anyhow. This branch ran into incompatibilities with SQLObjectResultSet so it now converts buildQuery and thing things on it to be less SQLObject and more Storm. This involved finding addressing a bunch of differences; it still has literal SQL and so on, but at least at the surface its now sensible. I'm running it through EC2 test now.

Revision history for this message
Curtis Hovey (sinzui) wrote :

Hi Robert.

Thanks for this refactoring.

Can you place a comment before ``if False and True:`` in BugtaskSet.search() to explain the dagnostic output? I had to think about the old and new guard.

I think you can remove the naked taskset in BugTaskSet.searchBugIds(). I do not think it is being used.

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

Thank you curtis; I've deleted the naked taskset, and deleted the
print statements I had accidentally left in which had that confusing
if statement.

This is the only failing test ec2 told me about

FAIL: productseries-views_txt
----------------------------------------------------------------------
Text attachment: traceback
------------
Traceback (most recent call last):
  File "/usr/lib/python2.6/unittest.py", line 279, in run
    testMethod()
  File "/usr/lib/python2.6/doctest.py", line 2152, in runTest
    raise self.failureException(self.format_failure(new.getvalue()))
AssertionError: Failed doctest test for productseries-views.txt
  File "lib/lp/registry/browser/tests/productseries-views.txt", line 0

----------------------------------------------------------------------
File "lib/lp/registry/browser/tests/productseries-views.txt", line 80,
in productseries-views.txt
Failed example:
    script = find_tag_by_id(view.render(), 'milestone-script')
Differences (ndiff with -expected +actual):
    + WARNING:root:Memcache set failed for
pt:testrunner:lp/bugs/templates/bugtarget-portlet-latestbugs.pt,10721:16:1,0:1,http_//127.0.0.1?6RKVP3n9fovtv8CxVi8ydF
----------------------------------------------------------------------
File "lib/lp/registry/browser/tests/productseries-views.txt", line
102, in productseries-views.txt
Failed example:
    content = view.render()
Differences (ndiff with -expected +actual):
    + WARNING:root:Memcache set failed for
pt:testrunner:lp/bugs/templates/bugtarget-portlet-latestbugs.pt,10721:243656:1,0:1,http_//127.0.0.1?4Ophjjtmt5aXGqxEwNgYvP
----------------------------------------------------------------------
File "lib/lp/registry/browser/tests/productseries-views.txt", line
117, in productseries-views.txt
Failed example:
    table = find_tag_by_id(view.render(), 'series-simple')
Differences (ndiff with -expected +actual):
    + WARNING:root:Memcache set failed for
pt:testrunner:lp/bugs/templates/bugtarget-portlet-latestbugs.pt,10721:243652:1,0:1,http_//127.0.0.1?IXiDEgHZPmgHvZKrJmNvh

If you have any ideas about it, I'd love to know.

Revision history for this message
Curtis Hovey (sinzui) wrote :

productseries-views.txt is now rendering a template that uses the memcache tales directive. We need to run it on a layer that has the memcached server up.

Add this to special_test_layer in lp/registry/browser/tests/test_views.py
    'productseries-views.txt': LaunchpadFunctionalLayer,

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/interfaces/validation.py'
2--- lib/canonical/launchpad/interfaces/validation.py 2010-08-20 20:31:18 +0000
3+++ lib/canonical/launchpad/interfaces/validation.py 2010-08-22 18:34:46 +0000
4@@ -312,7 +312,7 @@
5 errors = []
6 user = getUtility(ILaunchBag).user
7 params = BugTaskSearchParams(user, bug=bug)
8- if product.searchTasks(params):
9+ if not product.searchTasks(params).is_empty():
10 errors.append(LaunchpadValidationError(_(
11 'A fix for this bug has already been requested for ${product}',
12 mapping={'product': product.displayname})))
13
14=== modified file 'lib/canonical/launchpad/security.py'
15--- lib/canonical/launchpad/security.py 2010-08-20 20:31:18 +0000
16+++ lib/canonical/launchpad/security.py 2010-08-22 18:34:46 +0000
17@@ -988,24 +988,8 @@
18 usedfor = IHasBug
19
20 def checkAuthenticated(self, user):
21-
22- if user.in_admin:
23- # Admins can always edit bugtasks, whether they're reported on a
24- # private bug or not.
25- return True
26-
27- if not self.obj.bug.private:
28- # This is a public bug, so anyone can edit it.
29- return True
30- else:
31- # This is a private bug, and we know the user isn't an admin, so
32- # we'll only allow editing if the user is explicitly subscribed to
33- # this bug.
34- for subscription in self.obj.bug.subscriptions:
35- if user.inTeam(subscription.person):
36- return True
37-
38- return False
39+ # Delegated entirely to the bug.
40+ return self.obj.bug.userCanView(user)
41
42
43 class PublicToAllOrPrivateToExplicitSubscribersForBugTask(AuthorizationBase):
44@@ -1027,21 +1011,10 @@
45
46 def checkAuthenticated(self, user):
47 """Allow any logged in user to edit a public bug, and only
48- explicit subscribers to edit private bugs.
49+ explicit subscribers to edit private bugs. Any bug that can be seen can
50+ be edited.
51 """
52- if not self.obj.private:
53- # This is a public bug.
54- return True
55- elif user.in_admin:
56- # Admins can edit all bugs.
57- return True
58- else:
59- # This is a private bug. Only explicit subscribers may edit it.
60- for subscription in self.obj.subscriptions:
61- if user.inTeam(subscription.person):
62- return True
63-
64- return False
65+ return self.obj.userCanView(user)
66
67 def checkUnauthenticated(self):
68 """Never allow unauthenticated users to edit a bug."""
69
70=== modified file 'lib/canonical/launchpad/utilities/personroles.py'
71--- lib/canonical/launchpad/utilities/personroles.py 2010-08-20 20:31:18 +0000
72+++ lib/canonical/launchpad/utilities/personroles.py 2010-08-22 18:34:46 +0000
73@@ -41,6 +41,10 @@
74 except AttributeError:
75 raise AttributeError(errortext)
76
77+ @property
78+ def id(self):
79+ return self.person.id
80+
81 def isOwner(self, obj):
82 """See IPersonRoles."""
83 return self.person.inTeam(obj.owner)
84
85=== modified file 'lib/lp/bugs/browser/bugtarget.py'
86--- lib/lp/bugs/browser/bugtarget.py 2010-08-20 20:31:18 +0000
87+++ lib/lp/bugs/browser/bugtarget.py 2010-08-22 18:34:46 +0000
88@@ -966,13 +966,8 @@
89
90 matching_bugtasks = getUtility(IBugTaskSet).findSimilar(
91 self.user, title, **context_params)
92- # Remove all the prejoins, since we won't use them and they slow
93- # down the query significantly.
94- matching_bugtasks = matching_bugtasks.prejoin([])
95-
96 matching_bugs = getUtility(IBugSet).getDistinctBugsForBugTasks(
97 matching_bugtasks, self.user, self._MATCHING_BUGS_LIMIT)
98-
99 return matching_bugs
100
101 @property
102
103=== modified file 'lib/lp/bugs/browser/bugtask.py'
104--- lib/lp/bugs/browser/bugtask.py 2010-08-20 20:31:18 +0000
105+++ lib/lp/bugs/browser/bugtask.py 2010-08-22 18:34:46 +0000
106@@ -3197,25 +3197,8 @@
107 else:
108 raise AssertionError('Uknown context type: %s' % self.context)
109
110- # XXX flacoste 2008/04/25 bug=221947 This should be moved to an
111- # IBugTaskSet.findBugIds(search_params) method.
112- # buildQuery() is part of the internal API.
113- taskset = removeSecurityProxy(getUtility(IBugTaskSet))
114- query, clauseTables, orderBy = taskset.buildQuery(search_params)
115-
116- # Convert list of SQLObject order by spec into SQL.
117- order_by_clause = []
118- for field in orderBy:
119- if field[0] == '-':
120- field = "%s DESC" % field[1:]
121- order_by_clause.append(field)
122-
123- sql = 'SELECT BugTask.bug FROM %s WHERE %s ORDER BY %s' % (
124- ', '.join(clauseTables), query, ', '.join(order_by_clause))
125-
126- cur = cursor()
127- cur.execute(sql)
128- return u"".join("%d\n" % row[0] for row in cur.fetchall())
129+ return u"".join("%d\n" % bug_id for bug_id in
130+ getUtility(IBugTaskSet).searchBugIds(search_params))
131
132
133 def _by_targetname(bugtask):
134
135=== modified file 'lib/lp/bugs/browser/tests/bugtask-target-link-titles.txt'
136--- lib/lp/bugs/browser/tests/bugtask-target-link-titles.txt 2009-06-12 16:36:02 +0000
137+++ lib/lp/bugs/browser/tests/bugtask-target-link-titles.txt 2010-08-22 18:34:46 +0000
138@@ -73,7 +73,7 @@
139 >>> published_distro_series = factory.makeDistroRelease(
140 ... published_distro, name='published-distro-series')
141 >>> publisher.setUpDefaultDistroSeries(published_distro_series)
142- <DistroSeries at ...>
143+ <DistroSeries u'published-distro-series'>
144 >>> print publisher.person.unique_displayname
145 Foo Bar (name16)
146 >>> no_priv = getUtility(IPersonSet).getByName('no-priv')
147
148=== modified file 'lib/lp/bugs/browser/tests/test_bugtarget_filebug.py'
149--- lib/lp/bugs/browser/tests/test_bugtarget_filebug.py 2010-08-20 20:31:18 +0000
150+++ lib/lp/bugs/browser/tests/test_bugtarget_filebug.py 2010-08-22 18:34:46 +0000
151@@ -212,7 +212,7 @@
152
153 def test_getAcknowledgementMessage_dsp_custom_distro_message(self):
154 # If a distribution has a customized conformatom message, it
155- # is used for bugs filed on DsitributionSourcePackages.
156+ # is used for bugs filed on DistributionSourcePackages.
157 dsp = self.factory.makeDistributionSourcePackage()
158 dsp.distribution.bug_reported_acknowledgement = (
159 u"Thank you for filing a bug in our distribution")
160
161=== modified file 'lib/lp/bugs/interfaces/bugtask.py'
162--- lib/lp/bugs/interfaces/bugtask.py 2010-08-20 20:31:18 +0000
163+++ lib/lp/bugs/interfaces/bugtask.py 2010-08-22 18:34:46 +0000
164@@ -1398,6 +1398,14 @@
165 orderby specified in the first BugTaskSearchParams object.
166 """
167
168+ def searchBugIds(params):
169+ """Search bug ids.
170+
171+ This is a variation on IBugTaskSet.search that returns only the bug ids.
172+
173+ :param params: the BugTaskSearchParams to search on.
174+ """
175+
176 def getAssignedMilestonesFromSearch(search_results):
177 """Returns distinct milestones for the given tasks.
178
179
180=== modified file 'lib/lp/bugs/model/bug.py'
181--- lib/lp/bugs/model/bug.py 2010-08-20 20:31:18 +0000
182+++ lib/lp/bugs/model/bug.py 2010-08-22 18:34:46 +0000
183@@ -70,6 +70,10 @@
184 providedBy,
185 )
186
187+from canonical.cachedproperty import (
188+ cachedproperty,
189+ clear_property,
190+ )
191 from canonical.config import config
192 from canonical.database.constants import UTC_NOW
193 from canonical.database.datetimecol import UtcDateTimeCol
194@@ -85,7 +89,10 @@
195 MessageSet,
196 )
197 from canonical.launchpad.helpers import shortlist
198-from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
199+from canonical.launchpad.interfaces.launchpad import (
200+ ILaunchpadCelebrities,
201+ IPersonRoles,
202+ )
203 from canonical.launchpad.interfaces.librarian import ILibraryFileAliasSet
204 from canonical.launchpad.interfaces.lpstorm import IStore
205 from canonical.launchpad.interfaces.message import (
206@@ -626,6 +633,7 @@
207 # disabled see the change.
208 store.flush()
209 self.updateHeat()
210+ clear_property(self, '_cached_viewers')
211 return
212
213 def unsubscribeFromDupes(self, person, unsubscribed_by):
214@@ -1618,21 +1626,31 @@
215 self, self.messages[comment_number])
216 bug_message.visible = visible
217
218+ @cachedproperty('_cached_viewers')
219+ def _known_viewers(self):
220+ """A dict of of known persons able to view this bug."""
221+ return set()
222+
223 def userCanView(self, user):
224- """See `IBug`."""
225- admins = getUtility(ILaunchpadCelebrities).admin
226+ """See `IBug`.
227+
228+ Note that Editing is also controlled by this check,
229+ because we permit editing of any bug one can see.
230+ """
231+ if user.id in self._known_viewers:
232+ return True
233 if not self.private:
234 # This is a public bug.
235 return True
236- elif user.inTeam(admins):
237+ elif IPersonRoles(user).in_admin:
238 # Admins can view all bugs.
239 return True
240 else:
241 # This is a private bug. Only explicit subscribers may view it.
242 for subscription in self.subscriptions:
243 if user.inTeam(subscription.person):
244+ self._known_viewers.add(user.id)
245 return True
246-
247 return False
248
249 def linkHWSubmission(self, submission):
250@@ -1706,6 +1724,7 @@
251
252 self.heat = SQL("calculate_bug_heat(%s)" % sqlvalues(self))
253 self.heat_last_updated = UTC_NOW
254+ store.flush()
255
256 @property
257 def attachments(self):
258@@ -1942,6 +1961,10 @@
259 # Transaction.iterSelect() will try to listify the results.
260 # This can be fixed by selecting from Bugs directly, but
261 # that's non-trivial.
262+ # ---: Robert Collins 20100818: if bug_tasks implements IResultSset
263+ # then it should be very possible to improve on it, though
264+ # DecoratedResultSets would need careful handling (e.g. type
265+ # driven callbacks on columns)
266 # We select more than :limit: since if a bug affects more than
267 # one source package, it will be returned more than one time. 4
268 # is an arbitrary number that should be large enough.
269
270=== modified file 'lib/lp/bugs/model/bugtarget.py'
271--- lib/lp/bugs/model/bugtarget.py 2010-08-20 20:31:18 +0000
272+++ lib/lp/bugs/model/bugtarget.py 2010-08-22 18:34:46 +0000
273@@ -207,13 +207,7 @@
274 @property
275 def has_bugtasks(self):
276 """See `IHasBugs`."""
277- # Check efficiently if any bugtasks exist. We should avoid
278- # expensive calls like all_bugtasks.count(). all_bugtasks
279- # returns a storm.SQLObjectResultSet instance, and this
280- # class does not provide methods like is_empty(). But we can
281- # indirectly call SQLObjectResultSet._result_set.is_empty()
282- # by converting all_bugtasks into a boolean object.
283- return bool(self.all_bugtasks)
284+ return not self.all_bugtasks.is_empty()
285
286 def getBugCounts(self, user, statuses=None):
287 """See `IHasBugs`."""
288
289=== modified file 'lib/lp/bugs/model/bugtask.py'
290--- lib/lp/bugs/model/bugtask.py 2010-08-20 20:31:18 +0000
291+++ lib/lp/bugs/model/bugtask.py 2010-08-22 18:34:46 +0000
292@@ -21,7 +21,7 @@
293
294
295 import datetime
296-from operator import attrgetter
297+from operator import attrgetter, itemgetter
298
299 from lazr.enum import DBItem
300 import pytz
301@@ -35,6 +35,7 @@
302 Alias,
303 And,
304 AutoTables,
305+ Desc,
306 In,
307 Join,
308 LeftJoin,
309@@ -58,6 +59,7 @@
310 removeSecurityProxy,
311 )
312
313+from canonical.cachedproperty import cache_property
314 from canonical.config import config
315 from canonical.database.constants import UTC_NOW
316 from canonical.database.datetimecol import UtcDateTimeCol
317@@ -72,6 +74,7 @@
318 SQLBase,
319 sqlvalues,
320 )
321+from canonical.launchpad.components.decoratedresultset import DecoratedResultSet
322 from canonical.launchpad.helpers import shortlist
323 from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
324 from canonical.launchpad.interfaces.lpstorm import IStore
325@@ -118,6 +121,7 @@
326 UserCannotEditBugTaskMilestone,
327 UserCannotEditBugTaskStatus,
328 )
329+from lp.bugs.model.bugnomination import BugNomination
330 from lp.bugs.model.bugsubscription import BugSubscription
331 from lp.registry.interfaces.distribution import (
332 IDistribution,
333@@ -148,7 +152,10 @@
334 from lp.registry.interfaces.sourcepackage import ISourcePackage
335 from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet
336 from lp.registry.model.pillar import pillar_sort_key
337+from lp.registry.model.sourcepackagename import SourcePackageName
338 from lp.soyuz.interfaces.publishing import PackagePublishingStatus
339+from lp.soyuz.model.publishing import SourcePackagePublishingHistory
340+from lp.soyuz.model.sourcepackagerelease import SourcePackageRelease
341
342
343 debbugsseveritymap = {None: BugTaskImportance.UNDECIDED,
344@@ -673,9 +680,6 @@
345
346 matching_bugtasks = getUtility(IBugTaskSet).findSimilar(
347 user, self.bug.title, **context_params)
348- # Remove all the prejoins, since we won't use them and they slow
349- # down the query significantly.
350- matching_bugtasks = matching_bugtasks.prejoin([])
351
352 # Make sure to exclude the current BugTask from the list of
353 # matching tasks. We use 4*limit as an arbitrary value here to
354@@ -1302,24 +1306,54 @@
355
356 def get_bug_privacy_filter(user):
357 """An SQL filter for search results that adds privacy-awareness."""
358+ return get_bug_privacy_filter_with_decorator(user)[0]
359+
360+
361+def _nocache_bug_decorator(obj):
362+ """A pass through decorator for consistency.
363+
364+ :seealso: get_bug_privacy_filter_with_decorator
365+ """
366+ return obj
367+
368+
369+def _make_cache_user_can_view_bug(user):
370+ """Curry a decorator for bugtask queries to cache permissions.
371+
372+ :seealso: get_bug_privacy_filter_with_decorator
373+ """
374+ userid = user.id
375+ def cache_user_can_view_bug(bugtask):
376+ cache_property(bugtask.bug, '_cached_viewers', set([userid]))
377+ return bugtask
378+ return cache_user_can_view_bug
379+
380+
381+def get_bug_privacy_filter_with_decorator(user):
382+ """Return a SQL filter to limit returned bug tasks.
383+
384+ :return: A SQL filter, a decorator to cache visibility in a resultset that
385+ returns BugTask objects.
386+ """
387 if user is None:
388- return "Bug.private = FALSE"
389+ return "Bug.private = FALSE", _nocache_bug_decorator
390 admin_team = getUtility(ILaunchpadCelebrities).admin
391 if user.inTeam(admin_team):
392- return ""
393+ return "", _nocache_bug_decorator
394 # A subselect is used here because joining through
395 # TeamParticipation is only relevant to the "user-aware"
396 # part of the WHERE condition (i.e. the bit below.) The
397 # other half of this condition (see code above) does not
398 # use TeamParticipation at all.
399- return """
400+ return ("""
401 (Bug.private = FALSE OR EXISTS (
402 SELECT BugSubscription.bug
403 FROM BugSubscription, TeamParticipation
404 WHERE TeamParticipation.person = %(personid)s AND
405 BugSubscription.person = TeamParticipation.team AND
406 BugSubscription.bug = Bug.id))
407- """ % sqlvalues(personid=user.id)
408+ """ % sqlvalues(personid=user.id),
409+ _make_cache_user_can_view_bug(user))
410
411
412 def build_tag_set_query(joiner, tags):
413@@ -1408,24 +1442,7 @@
414 """See `IBugTaskSet`."""
415 implements(IBugTaskSet)
416
417- _ORDERBY_COLUMN = {
418- "id": "BugTask.bug",
419- "importance": "BugTask.importance",
420- "assignee": "BugTask.assignee",
421- "targetname": "BugTask.targetnamecache",
422- "status": "BugTask.status",
423- "title": "Bug.title",
424- "milestone": "BugTask.milestone",
425- "dateassigned": "BugTask.dateassigned",
426- "datecreated": "BugTask.datecreated",
427- "date_last_updated": "Bug.date_last_updated",
428- "date_closed": "BugTask.date_closed",
429- "number_of_duplicates": "Bug.number_of_duplicates",
430- "message_count": "Bug.message_count",
431- "users_affected_count": "Bug.users_affected_count",
432- "heat": "Bug.heat",
433- "latest_patch_uploaded": "Bug.latest_patch_uploaded",
434- }
435+ _ORDERBY_COLUMN = None
436
437 _open_resolved_upstream = """
438 EXISTS (
439@@ -1544,7 +1561,7 @@
440
441 search_params.fast_searchtext = nl_phrase_search(
442 summary, Bug, ' AND '.join(constraint_clauses), ['BugTask'])
443- return self.search(search_params)
444+ return self.search(search_params, _noprejoins=True)
445
446 def _buildStatusClause(self, status):
447 """Return the SQL query fragment for search by status.
448@@ -1591,11 +1608,15 @@
449 """Build and return an SQL query with the given parameters.
450
451 Also return the clauseTables and orderBy for the generated query.
452+
453+ :return: A query, the tables to query, ordering expression and a
454+ decorator to call on each returned row.
455 """
456 assert isinstance(params, BugTaskSearchParams)
457-
458+ from lp.bugs.model.bug import Bug
459 extra_clauses = ['Bug.id = BugTask.bug']
460- clauseTables = ['BugTask', 'Bug']
461+ clauseTables = [BugTask, Bug]
462+ decorators = []
463
464 # These arguments can be processed in a loop without any other
465 # special handling.
466@@ -1651,7 +1672,9 @@
467 extra_clauses.append("BugTask.milestone %s" % where_cond)
468
469 if params.project:
470- clauseTables.append("Product")
471+ # Circular.
472+ from lp.registry.model.product import Product
473+ clauseTables.append(Product)
474 extra_clauses.append("BugTask.product = Product.id")
475 if isinstance(params.project, any):
476 extra_clauses.append("Product.project IN (%s)" % ",".join(
477@@ -1694,7 +1717,7 @@
478 extra_clauses.append(self._buildFastSearchTextClause(params))
479
480 if params.subscriber is not None:
481- clauseTables.append('BugSubscription')
482+ clauseTables.append(BugSubscription)
483 extra_clauses.append("""Bug.id = BugSubscription.bug AND
484 BugSubscription.person = %(personid)s""" %
485 sqlvalues(personid=params.subscriber.id))
486@@ -1742,8 +1765,8 @@
487 extra_clauses.append(structural_subscriber_clause)
488
489 if params.component:
490- clauseTables += ["SourcePackagePublishingHistory",
491- "SourcePackageRelease"]
492+ clauseTables += [SourcePackagePublishingHistory,
493+ SourcePackageRelease]
494 distroseries = None
495 if params.distribution:
496 distroseries = params.distribution.currentseries
497@@ -1866,11 +1889,12 @@
498 BugNomination.status = %(nomination_status)s
499 """ % mappings
500 extra_clauses.append(nominated_for_clause)
501- clauseTables.append('BugNomination')
502+ clauseTables.append(BugNomination)
503
504- clause = get_bug_privacy_filter(params.user)
505+ clause, decorator = get_bug_privacy_filter_with_decorator(params.user)
506 if clause:
507 extra_clauses.append(clause)
508+ decorators.append(decorator)
509
510 hw_clause = self._buildHardwareRelatedClause(params)
511 if hw_clause is not None:
512@@ -1899,7 +1923,15 @@
513 orderby_arg = self._processOrderBy(params)
514
515 query = " AND ".join(extra_clauses)
516- return query, clauseTables, orderby_arg
517+
518+ if not decorators:
519+ decorator = lambda x:x
520+ else:
521+ def decorator(obj):
522+ for decor in decorators:
523+ obj = decor(obj)
524+ return obj
525+ return query, clauseTables, orderby_arg, decorator
526
527 def _buildUpstreamClause(self, params):
528 """Return an clause for returning upstream data if the data exists.
529@@ -2127,34 +2159,79 @@
530 ', '.join(tables), ' AND '.join(clauses))
531 return clause
532
533- def search(self, params, *args):
534- """See `IBugTaskSet`."""
535- store_selector = getUtility(IStoreSelector)
536- store = store_selector.get(MAIN_STORE, SLAVE_FLAVOR)
537- query, clauseTables, orderby = self.buildQuery(params)
538+ def search(self, params, *args, **kwargs):
539+ """See `IBugTaskSet`.
540+
541+ :param _noprejoins: Private internal parameter to BugTaskSet which
542+ disables all use of prejoins : consolidated from code paths that
543+ claim they were inefficient and unwanted.
544+ """
545+ # Circular.
546+ from lp.registry.model.product import Product
547+ from lp.bugs.model.bug import Bug
548+ _noprejoins = kwargs.get('_noprejoins', False)
549+ store = IStore(BugTask)
550+ query, clauseTables, orderby, bugtask_decorator = self.buildQuery(params)
551 if len(args) == 0:
552- # Do normal prejoins, if we don't have to do any UNION
553- # queries. Prejoins don't work well with UNION, and the way
554- # we prefetch objects without prejoins cause problems with
555- # COUNT(*) queries, which get inefficient.
556- return BugTask.select(
557- query, clauseTables=clauseTables, orderBy=orderby,
558- prejoins=['product', 'sourcepackagename'],
559- prejoinClauseTables=['Bug'])
560+ if _noprejoins:
561+ resultset = store.find(BugTask,
562+ AutoTables(SQL("1=1"), clauseTables),
563+ query)
564+ decorator = bugtask_decorator
565+ else:
566+ tables = clauseTables + [Product, SourcePackageName]
567+ origin = [
568+ BugTask,
569+ LeftJoin(Bug, BugTask.bug == Bug.id),
570+ LeftJoin(Product, BugTask.product == Product.id),
571+ LeftJoin(
572+ SourcePackageName,
573+ BugTask.sourcepackagename == SourcePackageName.id),
574+ ]
575+ # NB: these may work with AutoTables, but its hard to tell,
576+ # this way is known to work.
577+ if BugNomination in tables:
578+ # The relation is already in query.
579+ origin.append(BugNomination)
580+ if BugSubscription in tables:
581+ # The relation is already in query.
582+ origin.append(BugSubscription)
583+ if SourcePackageRelease in tables:
584+ origin.append(SourcePackageRelease)
585+ if SourcePackagePublishingHistory in tables:
586+ origin.append(SourcePackagePublishingHistory)
587+ resultset = store.using(*origin).find(
588+ (BugTask, Product, SourcePackageName, Bug),
589+ AutoTables(SQL("1=1"), tables),
590+ query)
591+ decorator=lambda row:bugtask_decorator(row[0])
592+ resultset.order_by(orderby)
593+ return DecoratedResultSet(resultset, result_decorator=decorator)
594
595 bugtask_fti = SQL('BugTask.fti')
596 result = store.find((BugTask, bugtask_fti), query,
597 AutoTables(SQL("1=1"), clauseTables))
598+ decorators = [bugtask_decorator]
599 for arg in args:
600- query, clauseTables, dummy = self.buildQuery(arg)
601+ query, clauseTables, dummy, decorator = self.buildQuery(arg)
602 result = result.union(
603 store.find((BugTask, bugtask_fti), query,
604 AutoTables(SQL("1=1"), clauseTables)))
605+ # NB: assumes the decorators are all compatible.
606+ # This may need revisiting if e.g. searches on behalf of different
607+ # users are combined.
608+ decorators.append(decorator)
609+ def decorator(row):
610+ bugtask = row[0]
611+ for decorator in decorators:
612+ bugtask = decorator(bugtask)
613+ return bugtask
614
615- # Build up the joins
616- from lp.bugs.model.bug import Bug
617- from lp.registry.model.product import Product
618- from lp.registry.model.sourcepackagename import SourcePackageName
619+ # Build up the joins.
620+ # TODO: implement _noprejoins for this code path: as of 20100818 it has
621+ # been silently disabled because clients of the API were setting
622+ # prejoins=[] which had no effect; this TODO simply notes the reality
623+ # already existing when it was added.
624 joins = Alias(result._get_select(), "BugTask")
625 joins = Join(joins, Bug, BugTask.bug == Bug.id)
626 joins = LeftJoin(joins, Product, BugTask.product == Product.id)
627@@ -2163,9 +2240,18 @@
628
629 result = store.using(joins).find(
630 (BugTask, Bug, Product, SourcePackageName))
631- bugtasks = SQLObjectResultSet(BugTask, orderBy=orderby,
632- prepared_result_set=result)
633- return bugtasks
634+ result.order_by(orderby)
635+ return DecoratedResultSet(result, result_decorator=decorator)
636+
637+ def searchBugIds(self, params):
638+ """See `IBugTaskSet`."""
639+ query, clauseTables, orderby, decorator = self.buildQuery(
640+ params)
641+ store = IStore(BugTask)
642+ resultset = store.find(BugTask.bugID,
643+ AutoTables(SQL("1=1"), clauseTables), query)
644+ resultset.order_by(orderby)
645+ return resultset
646
647 def getAssignedMilestonesFromSearch(self, search_results):
648 """See `IBugTaskSet`."""
649@@ -2193,7 +2279,14 @@
650 # Import here because of cyclic references.
651 from lp.registry.model.milestone import (
652 Milestone, milestone_sort_key)
653- milestones = search_results._store.find(
654+ # We need the store that was used, we have no objects to key off of
655+ # other than the search result, and Store.of() doesn't currently
656+ # work on result sets. Additionally it may be a DecoratedResultSet.
657+ if zope_isinstance(search_results, DecoratedResultSet):
658+ store = removeSecurityProxy(search_results).result_set._store
659+ else:
660+ store = search_results._store
661+ milestones = store.find(
662 Milestone, In(Milestone.id, milestone_ids))
663 return sorted(milestones, key=milestone_sort_key, reverse=True)
664
665@@ -2514,14 +2607,40 @@
666
667 def getOrderByColumnDBName(self, col_name):
668 """See `IBugTaskSet`."""
669- return self._ORDERBY_COLUMN[col_name]
670+ if BugTaskSet._ORDERBY_COLUMN is None:
671+ # Local import of Bug to avoid import loop.
672+ from lp.bugs.model.bug import Bug
673+ BugTaskSet._ORDERBY_COLUMN = {
674+ "id": BugTask.bugID,
675+ "importance": BugTask.importance,
676+ # TODO: sort by their name?
677+ "assignee": BugTask.assigneeID,
678+ "targetname": BugTask.targetnamecache,
679+ "status": BugTask.status,
680+ "title": Bug.title,
681+ "milestone": BugTask.milestoneID,
682+ "dateassigned": BugTask.date_assigned,
683+ "datecreated": BugTask.datecreated,
684+ "date_last_updated": Bug.date_last_updated,
685+ "date_closed": BugTask.date_closed,
686+ "number_of_duplicates": Bug.number_of_duplicates,
687+ "message_count": Bug.message_count,
688+ "users_affected_count": Bug.users_affected_count,
689+ "heat": Bug.heat,
690+ "latest_patch_uploaded": Bug.latest_patch_uploaded,
691+ }
692+ return BugTaskSet._ORDERBY_COLUMN[col_name]
693
694 def _processOrderBy(self, params):
695 """Process the orderby parameter supplied to search().
696
697 This method ensures the sort order will be stable, and converting
698 the string supplied to actual column names.
699+
700+ :return: A Storm order_by tuple.
701 """
702+ # Local import of Bug to avoid import loop.
703+ from lp.bugs.model.bug import Bug
704 orderby = params.orderby
705 if orderby is None:
706 orderby = []
707@@ -2536,10 +2655,10 @@
708 # columns to make the sort consistent over runs -- which is good
709 # for the user and essential for the test suite.
710 unambiguous_cols = set([
711- "BugTask.dateassigned",
712- "BugTask.datecreated",
713- "Bug.datecreated",
714- "Bug.date_last_updated"])
715+ BugTask.date_assigned,
716+ BugTask.datecreated,
717+ Bug.datecreated,
718+ Bug.date_last_updated])
719 # Bug ID is unique within bugs on a product or source package.
720 if (params.product or
721 (params.distribution and params.sourcepackagename) or
722@@ -2549,7 +2668,7 @@
723 in_unique_context = False
724
725 if in_unique_context:
726- unambiguous_cols.add("BugTask.bug")
727+ unambiguous_cols.add(BugTask.bug)
728
729 # Translate orderby keys into corresponding Table.attribute
730 # strings.
731@@ -2559,22 +2678,22 @@
732 orderby_arg.append(orderby_col)
733 continue
734 if orderby_col.startswith("-"):
735- col_name = self.getOrderByColumnDBName(orderby_col[1:])
736- order_clause = "-" + col_name
737+ col = self.getOrderByColumnDBName(orderby_col[1:])
738+ order_clause = Desc(col)
739 else:
740- col_name = self.getOrderByColumnDBName(orderby_col)
741- order_clause = col_name
742- if col_name in unambiguous_cols:
743+ col = self.getOrderByColumnDBName(orderby_col)
744+ order_clause = col
745+ if col in unambiguous_cols:
746 ambiguous = False
747 orderby_arg.append(order_clause)
748
749 if ambiguous:
750 if in_unique_context:
751- orderby_arg.append('BugTask.bug')
752+ orderby_arg.append(BugTask.bugID)
753 else:
754- orderby_arg.append('BugTask.id')
755+ orderby_arg.append(BugTask.id)
756
757- return orderby_arg
758+ return tuple(orderby_arg)
759
760 def dangerousGetAllTasks(self):
761 """DO NOT USE THIS METHOD. For details, see `IBugTaskSet`"""
762
763=== modified file 'lib/lp/bugs/stories/bug-privacy/40-unsubscribe-from-private-bug.txt'
764--- lib/lp/bugs/stories/bug-privacy/40-unsubscribe-from-private-bug.txt 2009-08-31 15:41:15 +0000
765+++ lib/lp/bugs/stories/bug-privacy/40-unsubscribe-from-private-bug.txt 2010-08-22 18:34:46 +0000
766@@ -11,10 +11,10 @@
767 >>> launchbag = getUtility(ILaunchBag)
768 >>> evo = getUtility(ISourcePackageNameSet).queryByName("evolution")
769 >>> params = BugTaskSearchParams(user=launchbag.user,
770- ... sourcepackagename=evo, orderby="id")
771+ ... sourcepackagename=evo, orderby="-id")
772
773 >>> ubuntu = getUtility(IDistributionSet).getByName("ubuntu")
774- >>> latest_evo_task = ubuntu.searchTasks(params)[-1]
775+ >>> latest_evo_task = ubuntu.searchTasks(params)[0]
776 >>> latest_evo_bug = latest_evo_task.bug.id
777 >>> logout()
778
779
780=== modified file 'lib/lp/bugs/tests/test_bugtask.py'
781--- lib/lp/bugs/tests/test_bugtask.py 2010-08-20 20:31:18 +0000
782+++ lib/lp/bugs/tests/test_bugtask.py 2010-08-22 18:34:46 +0000
783@@ -25,6 +25,7 @@
784 BugTaskImportance,
785 BugTaskSearchParams,
786 BugTaskStatus,
787+ IBugTaskSet,
788 )
789 from lp.bugs.model.bugtask import build_tag_search_clause
790 from lp.hardwaredb.interfaces.hwdb import (
791@@ -32,7 +33,7 @@
792 IHWDeviceSet,
793 )
794 from lp.registry.interfaces.distribution import IDistributionSet
795-from lp.registry.interfaces.person import IPersonSet
796+from lp.registry.interfaces.person import IPerson, IPersonSet
797 from lp.testing import (
798 ANONYMOUS,
799 login,
800@@ -807,6 +808,28 @@
801 result = target.searchTasks(None, modified_since=date)
802 self.assertEqual([task2], list(result))
803
804+ def test_private_bug_view_permissions_cached(self):
805+ """Private bugs from a search know the user can see the bugs."""
806+ target = self.makeBugTarget()
807+ person = self.login()
808+ self.factory.makeBug(product=target, private=True, owner=person)
809+ self.factory.makeBug(product=target, private=True, owner=person)
810+ # Search style and parameters taken from the milestone index view where
811+ # the issue was discovered.
812+ login_person(person)
813+ tasks = target.searchTasks(BugTaskSearchParams(person, omit_dupes=True,
814+ orderby=['status', '-importance', 'id']))
815+ # We must be finding the bugs.
816+ self.assertEqual(2, tasks.count())
817+ # Cache in the storm cache the account->person lookup so its not
818+ # distorting what we're testing.
819+ _ = IPerson(person.account, None)
820+ # One query and only one should be issued to get the tasks, bugs and
821+ # allow access to getConjoinedMaster attribute - an attribute that
822+ # triggers a permission check (nb: id does not trigger such a check)
823+ self.assertStatementCount(1,
824+ lambda:[task.getConjoinedMaster for task in tasks])
825+
826
827 def test_suite():
828 suite = unittest.TestSuite()
829
830=== modified file 'lib/lp/registry/browser/tests/test_milestone.py'
831--- lib/lp/registry/browser/tests/test_milestone.py 2010-08-20 20:31:18 +0000
832+++ lib/lp/registry/browser/tests/test_milestone.py 2010-08-22 18:34:46 +0000
833@@ -1,24 +1,30 @@
834 # Copyright 2010 Canonical Ltd. This software is licensed under the
835 # GNU Affero General Public License version 3 (see the file LICENSE).
836
837+from __future__ import with_statement
838+
839 """Test milestone views."""
840
841 __metaclass__ = type
842
843-import unittest
844-
845+from testtools.matchers import LessThan
846 from zope.component import getUtility
847
848+from canonical.launchpad.webapp import canonical_url
849 from canonical.testing.layers import DatabaseFunctionalLayer
850 from lp.bugs.interfaces.bugtask import IBugTaskSet
851 from lp.testing import (
852 ANONYMOUS,
853 login,
854 login_person,
855+ logout,
856+ person_logged_in,
857 TestCaseWithFactory,
858 )
859+from lp.testing.matchers import HasQueryCount
860 from lp.testing.memcache import MemcacheTestCase
861 from lp.testing.views import create_initialized_view
862+from lp.testing._webservice import QueryCollector
863
864
865 class TestMilestoneViews(TestCaseWithFactory):
866@@ -158,5 +164,67 @@
867 self.assertEqual(0, product.development_focus.all_bugtasks.count())
868
869
870-def test_suite():
871- return unittest.TestLoader().loadTestsFromName(__name__)
872+class TestMilestoneIndex(TestCaseWithFactory):
873+
874+ layer = DatabaseFunctionalLayer
875+
876+ def test_more_private_bugs_query_count_is_constant(self):
877+ # This test tests that as we add more private bugs to a milestone index
878+ # page, the number of queries issued by the page does not change.
879+ # It also sets a cap on the queries for this page: if the baseline
880+ # were to increase, the test would fail. As the baseline is very large
881+ # already, if the test fails due to such a change, please cut some more
882+ # of the existing fat out of it rather than increasing the cap.
883+ product = self.factory.makeProduct()
884+ login_person(product.owner)
885+ milestone = self.factory.makeMilestone(
886+ productseries=product.development_focus)
887+ bug1 = self.factory.makeBug(product=product, private=True,
888+ owner=product.owner)
889+ bug1.bugtasks[0].transitionToMilestone(milestone, product.owner)
890+ # We look at the page as someone who is a member of a team and the team
891+ # is subscribed to the bugs, so that we don't get trivial shortcuts
892+ # avoiding queries : test the worst case.
893+ subscribed_team = self.factory.makeTeam()
894+ viewer = self.factory.makePerson(password="test")
895+ with person_logged_in(subscribed_team.teamowner):
896+ subscribed_team.addMember(viewer, subscribed_team.teamowner)
897+ bug1.subscribe(subscribed_team, product.owner)
898+ bug1_url = canonical_url(bug1)
899+ milestone_url = canonical_url(milestone)
900+ browser = self.getUserBrowser(user=viewer)
901+ # Seed the cookie cache and any other cross-request state we may gain
902+ # in future. See canonical.launchpad.webapp.serssion: _get_secret.
903+ browser.open(milestone_url)
904+ collector = QueryCollector()
905+ collector.register()
906+ self.addCleanup(collector.unregister)
907+ # This page is rather high in queries : 46 as a baseline. 20100817
908+ page_query_limit = 46
909+ browser.open(milestone_url)
910+ # Check that the test found the bug
911+ self.assertTrue(bug1_url in browser.contents)
912+ self.assertThat(collector, HasQueryCount(LessThan(page_query_limit)))
913+ with_1_private_bug = collector.count
914+ with_1_queries = ["%s: %s" % (pos, stmt[3]) for (pos, stmt) in
915+ enumerate(collector.queries)]
916+ login_person(product.owner)
917+ bug2 = self.factory.makeBug(product=product, private=True,
918+ owner=product.owner)
919+ bug2.bugtasks[0].transitionToMilestone(milestone, product.owner)
920+ bug2.subscribe(subscribed_team, product.owner)
921+ bug2_url = canonical_url(bug2)
922+ bug3 = self.factory.makeBug(product=product, private=True,
923+ owner=product.owner)
924+ bug3.bugtasks[0].transitionToMilestone(milestone, product.owner)
925+ bug3.subscribe(subscribed_team, product.owner)
926+ logout()
927+ browser.open(milestone_url)
928+ self.assertTrue(bug2_url in browser.contents)
929+ self.assertThat(collector, HasQueryCount(LessThan(page_query_limit)))
930+ with_3_private_bugs = collector.count
931+ with_3_queries = ["%s: %s" % (pos, stmt[3]) for (pos, stmt) in
932+ enumerate(collector.queries)]
933+ self.assertEqual(with_1_private_bug, with_3_private_bugs,
934+ "different query count: \n%s\n******************\n%s\n" % (
935+ '\n'.join(with_1_queries), '\n'.join(with_3_queries)))
936
937=== modified file 'lib/lp/registry/browser/tests/test_views.py'
938--- lib/lp/registry/browser/tests/test_views.py 2010-08-20 20:31:18 +0000
939+++ lib/lp/registry/browser/tests/test_views.py 2010-08-22 18:34:46 +0000
940@@ -35,6 +35,7 @@
941 'product-edit-people-view.txt': LaunchpadFunctionalLayer,
942 'product-files-views.txt': LaunchpadFunctionalLayer,
943 'product-views.txt': LaunchpadFunctionalLayer,
944+ 'productseries-views.txt': LaunchpadFunctionalLayer,
945 'projectgroup-views.txt': LaunchpadFunctionalLayer,
946 'user-to-user-views.txt': LaunchpadFunctionalLayer,
947 }
948
949=== modified file 'lib/lp/registry/doc/distribution.txt'
950--- lib/lp/registry/doc/distribution.txt 2010-08-06 14:49:35 +0000
951+++ lib/lp/registry/doc/distribution.txt 2010-08-22 18:34:46 +0000
952@@ -585,7 +585,7 @@
953 ... name='impossible', dateexpected=datetime(2028, 10, 1))
954 Traceback (most recent call last):
955 ...
956- Unauthorized: (<DistroSeries at ...>, 'newMilestone', 'launchpad.Edit')
957+ Unauthorized: (<DistroSeries u'woody'>, 'newMilestone', 'launchpad.Edit')
958 >>> login('mark@example.com')
959 >>> debian_milestone = woody.newMilestone(
960 ... name='woody-rc1', dateexpected=datetime(2028, 10, 1))
961
962=== modified file 'lib/lp/registry/doc/sourcepackage.txt'
963--- lib/lp/registry/doc/sourcepackage.txt 2010-07-02 20:32:58 +0000
964+++ lib/lp/registry/doc/sourcepackage.txt 2010-08-22 18:34:46 +0000
965@@ -71,7 +71,7 @@
966 >>> firefox_warty_package = SourcePackage(sourcepackagename=firefox,
967 ... distroseries=ubuntu_warty)
968 >>> firefox_warty_package
969- <SourcePackage ubuntu/warty/mozilla-firefox>
970+ <SourcePackage ...'Ubuntu'...'warty'...'mozilla-firefox'...>
971
972 An instance is commonly retrieved from a distroseries.
973
974
975=== modified file 'lib/lp/registry/model/distroseries.py'
976--- lib/lp/registry/model/distroseries.py 2010-08-20 20:31:18 +0000
977+++ lib/lp/registry/model/distroseries.py 2010-08-22 18:34:46 +0000
978@@ -270,6 +270,9 @@
979 """See `IDistroSeries`."""
980 return self.getDistroArchSeries(archtag)
981
982+ def __repr__(self):
983+ return '<%s %r>' % (self.__class__.__name__, self.name)
984+
985 def __str__(self):
986 return '%s %s' % (self.distribution.name, self.name)
987
988
989=== modified file 'lib/lp/registry/model/sourcepackage.py'
990--- lib/lp/registry/model/sourcepackage.py 2010-08-20 20:31:18 +0000
991+++ lib/lp/registry/model/sourcepackage.py 2010-08-22 18:34:46 +0000
992@@ -213,7 +213,8 @@
993 return cls(sourcepackagename, distroseries)
994
995 def __repr__(self):
996- return '<%s %s>' % (self.__class__.__name__, self.path)
997+ return '<%s %r %r %r>' % (self.__class__.__name__,
998+ self.distribution, self.distroseries, self.sourcepackagename)
999
1000 def _get_ubuntu(self):
1001 # XXX: kiko 2006-03-20: Ideally, it would be possible to just do
1002
1003=== modified file 'lib/lp/testing/factory.py'
1004--- lib/lp/testing/factory.py 2010-08-20 20:31:18 +0000
1005+++ lib/lp/testing/factory.py 2010-08-22 18:34:46 +0000
1006@@ -7,7 +7,7 @@
1007
1008 """Testing infrastructure for the Launchpad application.
1009
1010-This module should not have any actual tests.
1011+This module should not contain tests (but it should be tested).
1012 """
1013
1014 __metaclass__ = type