Merge lp:~wgrant/launchpad/bugsummary-v2-apg-app into lp:launchpad

Proposed by William Grant
Status: Merged
Approved by: Curtis Hovey
Approved revision: no longer in the source branch.
Merged at revision: 15691
Proposed branch: lp:~wgrant/launchpad/bugsummary-v2-apg-app
Merge into: lp:launchpad
Diff against target: 572 lines (+202/-138)
5 files modified
lib/lp/bugs/doc/bugsummary.txt (+107/-78)
lib/lp/bugs/model/bug.py (+13/-21)
lib/lp/bugs/model/bugsummary.py (+61/-3)
lib/lp/bugs/model/bugtask.py (+11/-21)
lib/lp/bugs/model/tests/test_bugsummary.py (+10/-15)
To merge this branch: bzr merge lp:~wgrant/launchpad/bugsummary-v2-apg-app
Reviewer Review Type Date Requested Status
Curtis Hovey (community) code Approve
Review via email: mp+116594@code.launchpad.net

Commit message

Teach model and test code about BugSummary.access_policy.

Description of the change

BugSummary privacy is a little special. Public bugs show up in a single row with viewed_by=None, private bugs in several rows with viewed_by=subscriber, one for each subscriber. This obviously isn't quite compatible with the new sharing model, which also grants visibility through access policies, not requiring a direct subscription. So BugSummary has grown a new column, access_policy. For public bugs there'll still be just a single row with viewed_by=None,access_policy=None, but private bugs will expand to a one row for each subscriber, plus one for each access policy.

This branch teaches the application code about the new column, removing the last blocker for lp:~wgrant/launchpad/bugsummary-v2-apg-db, which alters the triggers to start setting it. The two production queries and one test query that filter on viewed_by have been adjusted to use a factored out filter method, which knows about access policies. I've verified that they still all perform excellently.

Tests are a bit of an issue, as it can't really be tested until the DB changes are deployed as well. But the query is factored out and well tested.

I also reworked bugsummary.txt a bit. The new column makes the printed tables a bit wide, so I made privacy display optional.

To post a comment you must log in.
Revision history for this message
Curtis Hovey (sinzui) wrote :

Thank you.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/doc/bugsummary.txt'
2--- lib/lp/bugs/doc/bugsummary.txt 2012-07-11 22:31:52 +0000
3+++ lib/lp/bugs/doc/bugsummary.txt 2012-07-25 08:05:24 +0000
4@@ -19,9 +19,9 @@
5 First we should setup some helpers to use in the examples. These will
6 let us dump the BugSummary table in a readable format.
7
8- ---------------------------------------------------------------
9- prod ps dist ds spn tag mile status import pa vis #
10- ---------------------------------------------------------------
11+ ----------------------------------------------------------
12+ prod ps dist ds spn tag mile status import pa #
13+ ----------------------------------------------------------
14
15 The columns are product, productseries, distribution, distroseries,
16 sourcepackagename, tag, milestone, status, importance, has_patch,
17@@ -40,7 +40,18 @@
18 ... return 'x'
19 ... return object_or_none.name
20
21- >>> def print_result(bugsummary_resultset):
22+ >>> def ap_desc(policy_or_none):
23+ ... if policy_or_none is None:
24+ ... return 'x'
25+ ... type_names = {
26+ ... InformationType.PRIVATESECURITY: 'se',
27+ ... InformationType.USERDATA: 'pr',
28+ ... InformationType.PROPRIETARY: 'pp',
29+ ... }
30+ ... return '%-4s/%-2s' % (
31+ ... policy_or_none.pillar.name, type_names[policy_or_none.type])
32+
33+ >>> def print_result(bugsummary_resultset, include_privacy=False):
34 ... # First, flush and invalidate the cache so we see the effects
35 ... # of the underlying database triggers. Normally you don't want
36 ... # to bother with this as you are only interested in counts of
37@@ -59,18 +70,28 @@
38 ... BugSummary.sourcepackagename_id, BugSummary.tag,
39 ... BugSummary.milestone_id, BugSummary.status,
40 ... BugSummary.importance, BugSummary.has_patch,
41- ... BugSummary.viewed_by_id, BugSummary.id)
42+ ... BugSummary.viewed_by_id, BugSummary.access_policy_id,
43+ ... BugSummary.id)
44 ... fmt = (
45 ... "%-4s %-4s %-4s %-4s %-5s %-3s %-4s "
46- ... "%-6s %-6s %-2s %-4s %3s")
47- ... header = fmt % (
48+ ... "%-6s %-6s %-2s")
49+ ... titles = (
50 ... 'prod', 'ps', 'dist', 'ds', 'spn', 'tag', 'mile',
51- ... 'status', 'import', 'pa', 'vis', '#')
52+ ... 'status', 'import', 'pa')
53+ ... if include_privacy:
54+ ... fmt += ' %-4s %-7s'
55+ ... titles += ('gra', 'pol')
56+ ... fmt += ' %3s'
57+ ... titles += ('#',)
58+ ... header = fmt % titles
59 ... print "-" * len(header)
60 ... print header
61 ... print "-" * len(header)
62 ... for bugsummary in ordered_results:
63- ... print fmt % (
64+ ... if not include_privacy:
65+ ... assert bugsummary.viewed_by is None
66+ ... assert bugsummary.access_policy is None
67+ ... data = (
68 ... name(bugsummary.product),
69 ... name(bugsummary.productseries),
70 ... name(bugsummary.distribution),
71@@ -80,9 +101,13 @@
72 ... name(bugsummary.milestone),
73 ... str(bugsummary.status)[:6],
74 ... str(bugsummary.importance)[:6],
75- ... str(bugsummary.has_patch)[:1],
76- ... name(bugsummary.viewed_by),
77- ... bugsummary.count)
78+ ... str(bugsummary.has_patch)[:1])
79+ ... if include_privacy:
80+ ... data += (
81+ ... name(bugsummary.viewed_by),
82+ ... ap_desc(bugsummary.access_policy),
83+ ... )
84+ ... print fmt % (data + (bugsummary.count,))
85 ... print " " * (len(header) - 4),
86 ... print "==="
87 ... sum = bugsummary_resultset.sum(BugSummary.count)
88@@ -90,8 +115,9 @@
89 ... print "%3s" % sum
90
91 >>> def print_find(*bs_query_args, **bs_query_kw):
92+ ... include_privacy = bs_query_kw.pop('include_privacy', False)
93 ... resultset = store.find(BugSummary, *bs_query_args, **bs_query_kw)
94- ... print_result(resultset)
95+ ... print_result(resultset, include_privacy=include_privacy)
96
97
98 /!\ A Note About Privacy in These Examples
99@@ -119,12 +145,12 @@
100 ... BugSummary.tag == None)
101
102 >>> print_result(bug_summaries)
103- ------------------------------------------------------------
104- prod ps dist ds spn tag mile status import pa vis #
105- ------------------------------------------------------------
106- pr-a x x x x x x New Undeci F x 1
107- ===
108- 1
109+ -------------------------------------------------------
110+ prod ps dist ds spn tag mile status import pa #
111+ -------------------------------------------------------
112+ pr-a x x x x x x New Undeci F 1
113+ ===
114+ 1
115
116 There is one row per tag per combination of product, status and milestone.
117 If we are interested in all bugs targeted to a product regardless of how
118@@ -145,13 +171,13 @@
119 ... BugSummary.product == prod_a,
120 ... BugSummary.tag == None,
121 ... BugSummary.viewed_by == None)
122- ------------------------------------------------------------
123- prod ps dist ds spn tag mile status import pa vis #
124- ------------------------------------------------------------
125- pr-a x x x x x x New Undeci F x 2
126- pr-a x x x x x x Confir Undeci F x 2
127- ===
128- 4
129+ -------------------------------------------------------
130+ prod ps dist ds spn tag mile status import pa #
131+ -------------------------------------------------------
132+ pr-a x x x x x x New Undeci F 2
133+ pr-a x x x x x x Confir Undeci F 2
134+ ===
135+ 4
136
137 Here are the rows associated with the 't-a' tag. There is 1 Confirmed
138 bug task targetted to the pr-a product who's bug is tagged 't-a'.:
139@@ -160,12 +186,12 @@
140 ... BugSummary.product == prod_a,
141 ... BugSummary.tag == u't-a',
142 ... BugSummary.viewed_by == None)
143- ------------------------------------------------------------
144- prod ps dist ds spn tag mile status import pa vis #
145- ------------------------------------------------------------
146- pr-a x x x x t-a x Confir Undeci F x 1
147- ===
148- 1
149+ -------------------------------------------------------
150+ prod ps dist ds spn tag mile status import pa #
151+ -------------------------------------------------------
152+ pr-a x x x x t-a x Confir Undeci F 1
153+ ===
154+ 1
155
156 You will normally want to get the total count counted in the database
157 rather than waste transmission time to calculate the rows client side.
158@@ -205,17 +231,17 @@
159 >>> print_find(
160 ... BugSummary.product == prod_a,
161 ... BugSummary.viewed_by == None)
162- ------------------------------------------------------------
163- prod ps dist ds spn tag mile status import pa vis #
164- ------------------------------------------------------------
165- pr-a x x x x t-a x Confir Undeci F x 1
166- pr-a x x x x t-b ms-a New Undeci F x 1
167- pr-a x x x x t-c ms-a New Undeci F x 1
168- pr-a x x x x x ms-a New Undeci F x 1
169- pr-a x x x x x x New Undeci F x 2
170- pr-a x x x x x x Confir Undeci F x 2
171- ===
172- 8
173+ -------------------------------------------------------
174+ prod ps dist ds spn tag mile status import pa #
175+ -------------------------------------------------------
176+ pr-a x x x x t-a x Confir Undeci F 1
177+ pr-a x x x x t-b ms-a New Undeci F 1
178+ pr-a x x x x t-c ms-a New Undeci F 1
179+ pr-a x x x x x ms-a New Undeci F 1
180+ pr-a x x x x x x New Undeci F 2
181+ pr-a x x x x x x Confir Undeci F 2
182+ ===
183+ 8
184
185 Number of New bugs not targeted to a milestone. Note the difference
186 between selecting records where tag is None, and where milestone is None:
187@@ -269,13 +295,13 @@
188 ... BugSummary.productseries == productseries_b,
189 ... BugSummary.product == prod_b),
190 ... BugSummary.viewed_by == None)
191- ------------------------------------------------------------
192- prod ps dist ds spn tag mile status import pa vis #
193- ------------------------------------------------------------
194- pr-b x x x x x x New Undeci F x 1
195- x ps-b x x x x x New Undeci F x 1
196- ===
197- 2
198+ -------------------------------------------------------
199+ prod ps dist ds spn tag mile status import pa #
200+ -------------------------------------------------------
201+ pr-b x x x x x x New Undeci F 1
202+ x ps-b x x x x x New Undeci F 1
203+ ===
204+ 2
205
206 Distribution Bug Counts
207 -----------------------
208@@ -297,14 +323,14 @@
209 >>> print_find(
210 ... BugSummary.distribution == distribution,
211 ... BugSummary.viewed_by == None)
212- ------------------------------------------------------------
213- prod ps dist ds spn tag mile status import pa vis #
214- ------------------------------------------------------------
215- x x di-a x sp-a x x New Undeci F x 1
216- x x di-a x x x x New Undeci F x 1
217- x x di-a x x x x Confir Undeci F x 1
218- ===
219- 3
220+ -------------------------------------------------------
221+ prod ps dist ds spn tag mile status import pa #
222+ -------------------------------------------------------
223+ x x di-a x sp-a x x New Undeci F 1
224+ x x di-a x x x x New Undeci F 1
225+ x x di-a x x x x Confir Undeci F 1
226+ ===
227+ 3
228
229 How many bugs targeted to a distribution?
230
231@@ -369,12 +395,12 @@
232 >>> print_find(
233 ... BugSummary.distroseries == series_c,
234 ... BugSummary.viewed_by == None)
235- ------------------------------------------------------------
236- prod ps dist ds spn tag mile status import pa vis #
237- ------------------------------------------------------------
238- x x x ds-c x x x New Undeci F x 1
239- ===
240- 1
241+ -------------------------------------------------------
242+ prod ps dist ds spn tag mile status import pa #
243+ -------------------------------------------------------
244+ x x x ds-c x x x New Undeci F 1
245+ ===
246+ 1
247
248
249 Privacy
250@@ -440,19 +466,19 @@
251 >>> distro_or_series = Or(
252 ... BugSummary.distribution == distro_p,
253 ... BugSummary.distroseries == series_p)
254- >>> print_find(distro_or_series)
255- ------------------------------------------------------------
256- prod ps dist ds spn tag mile status import pa vis #
257- ------------------------------------------------------------
258- x x di-p x x x x New Undeci F p-b 1
259- x x di-p x x x x New Undeci F own 3
260- x x di-p x x x x New Undeci F t-a 1
261- x x di-p x x x x New Undeci F t-c 1
262- x x di-p x x x x New Undeci F x 1
263- x x x ds-p x x x New Undeci F own 1
264- x x x ds-p x x x New Undeci F t-c 1
265- ===
266- 9
267+ >>> print_find(distro_or_series, include_privacy=True)
268+ --------------------------------------------------------------------
269+ prod ps dist ds spn tag mile status import pa gra pol #
270+ --------------------------------------------------------------------
271+ x x di-p x x x x New Undeci F p-b x 1
272+ x x di-p x x x x New Undeci F own x 3
273+ x x di-p x x x x New Undeci F t-a x 1
274+ x x di-p x x x x New Undeci F t-c x 1
275+ x x di-p x x x x New Undeci F x x 1
276+ x x x ds-p x x x New Undeci F own x 1
277+ x x x ds-p x x x New Undeci F t-c x 1
278+ ===
279+ 9
280
281 So how many public bugs are there on the distro?
282
283@@ -460,12 +486,14 @@
284 ... BugSummary,
285 ... BugSummary.distribution == distro_p,
286 ... BugSummary.viewed_by == None, # Public bugs only
287+ ... BugSummary.access_policy == None, # Public bugs only
288 ... BugSummary.sourcepackagename == None,
289 ... BugSummary.tag == None).sum(BugSummary.count) or 0
290 1
291
292 But how many can the owner see?
293
294+ >>> from storm.expr import And
295 >>> join = LeftJoin(
296 ... BugSummary, TeamParticipation,
297 ... BugSummary.viewed_by_id == TeamParticipation.teamID)
298@@ -473,7 +501,8 @@
299 ... BugSummary,
300 ... BugSummary.distribution == distro_p,
301 ... Or(
302- ... BugSummary.viewed_by == None,
303+ ... And(BugSummary.viewed_by == None,
304+ ... BugSummary.access_policy == None),
305 ... TeamParticipation.person == owner),
306 ... BugSummary.sourcepackagename == None,
307 ... BugSummary.tag == None).sum(BugSummary.count) or 0
308
309=== modified file 'lib/lp/bugs/model/bug.py'
310--- lib/lp/bugs/model/bug.py 2012-07-19 04:40:03 +0000
311+++ lib/lp/bugs/model/bug.py 2012-07-25 08:05:24 +0000
312@@ -227,12 +227,7 @@
313 get_property_cache,
314 )
315 from lp.services.webapp.authorization import check_permission
316-from lp.services.webapp.interfaces import (
317- DEFAULT_FLAVOR,
318- ILaunchBag,
319- IStoreSelector,
320- MAIN_STORE,
321- )
322+from lp.services.webapp.interfaces import ILaunchBag
323
324
325 _bug_tag_query_template = """
326@@ -291,29 +286,26 @@
327 (and {} returned).
328 """
329 # Circular fail.
330- from lp.bugs.model.bugsummary import BugSummary
331+ from lp.bugs.model.bugsummary import (
332+ BugSummary,
333+ get_bugsummary_filter_for_user,
334+ )
335 tags = {}
336 if include_tags:
337 tags = dict((tag, 0) for tag in include_tags)
338- store = getUtility(IStoreSelector).get(MAIN_STORE, DEFAULT_FLAVOR)
339- admin_team = getUtility(ILaunchpadCelebrities).admin
340- if user is not None and not user.inTeam(admin_team):
341- store = store.with_(SQL(
342- "teams AS ("
343- "SELECT team from TeamParticipation WHERE person=?)", (user.id,)))
344 where_conditions = [
345 BugSummary.status.is_in(UNRESOLVED_BUGTASK_STATUSES),
346 BugSummary.tag != None,
347 context_condition,
348 ]
349- if user is None:
350- where_conditions.append(BugSummary.viewed_by_id == None)
351- elif not user.inTeam(admin_team):
352- where_conditions.append(
353- Or(
354- BugSummary.viewed_by_id == None,
355- BugSummary.viewed_by_id.is_in(SQL("SELECT team FROM teams"))
356- ))
357+
358+ # Apply the privacy filter.
359+ store = IStore(BugSummary)
360+ user_with, user_where = get_bugsummary_filter_for_user(user)
361+ if user_with:
362+ store = store.with_(user_with)
363+ where_conditions.extend(user_where)
364+
365 sum_count = Sum(BugSummary.count)
366 tag_count_columns = (BugSummary.tag, sum_count)
367
368
369=== modified file 'lib/lp/bugs/model/bugsummary.py'
370--- lib/lp/bugs/model/bugsummary.py 2012-06-25 09:45:56 +0000
371+++ lib/lp/bugs/model/bugsummary.py 2012-07-25 08:05:24 +0000
372@@ -7,16 +7,23 @@
373 __all__ = [
374 'BugSummary',
375 'CombineBugSummaryConstraint',
376+ 'get_bugsummary_filter_for_user',
377 ]
378
379-from storm.locals import (
380+from storm.base import Storm
381+from storm.expr import (
382 And,
383+ Or,
384+ Select,
385+ SQL,
386+ With,
387+ )
388+from storm.properties import (
389 Bool,
390 Int,
391- Reference,
392- Storm,
393 Unicode,
394 )
395+from storm.references import Reference
396 from zope.interface import implements
397 from zope.security.proxy import removeSecurityProxy
398
399@@ -29,6 +36,11 @@
400 BugTaskStatus,
401 BugTaskStatusSearch,
402 )
403+from lp.registry.interfaces.role import IPersonRoles
404+from lp.registry.model.accesspolicy import (
405+ AccessPolicy,
406+ AccessPolicyGrant,
407+ )
408 from lp.registry.model.distribution import Distribution
409 from lp.registry.model.distroseries import DistroSeries
410 from lp.registry.model.milestone import Milestone
411@@ -36,6 +48,7 @@
412 from lp.registry.model.product import Product
413 from lp.registry.model.productseries import ProductSeries
414 from lp.registry.model.sourcepackagename import SourcePackageName
415+from lp.registry.model.teammembership import TeamParticipation
416 from lp.services.database.enumcol import EnumCol
417
418
419@@ -76,6 +89,8 @@
420
421 viewed_by_id = Int(name='viewed_by')
422 viewed_by = Reference(viewed_by_id, Person.id)
423+ access_policy_id = Int(name='access_policy')
424+ access_policy = Reference(access_policy_id, AccessPolicy.id)
425
426 has_patch = Bool()
427
428@@ -99,3 +114,46 @@
429 def getBugSummaryContextWhereClause(self):
430 """See `IBugSummaryDimension`."""
431 return And(*self.dimensions)
432+
433+
434+def get_bugsummary_filter_for_user(user):
435+ """Build a Storm expression to filter BugSummary by visibility.
436+
437+ :param user: The user for which visible rows should be calculated.
438+ :return: (with_clauses, where_clauses)
439+ """
440+ # Admins get to see every bug, everyone else only sees bugs
441+ # viewable by them-or-their-teams.
442+ # Note that because admins can see every bug regardless of
443+ # subscription they will see rather inflated counts. Admins get to
444+ # deal.
445+ public_filter = And(
446+ BugSummary.viewed_by_id == None,
447+ BugSummary.access_policy_id == None)
448+ if user is None:
449+ return [], [public_filter]
450+ elif IPersonRoles(user).in_admin:
451+ return [], []
452+ else:
453+ with_clauses = [
454+ With(
455+ 'teams',
456+ Select(
457+ TeamParticipation.teamID, tables=[TeamParticipation],
458+ where=(TeamParticipation.personID == user.id))),
459+ With(
460+ 'policies',
461+ Select(
462+ AccessPolicyGrant.policy_id,
463+ tables=[AccessPolicyGrant],
464+ where=(
465+ AccessPolicyGrant.grantee_id.is_in(
466+ SQL("SELECT team FROM teams"))))),
467+ ]
468+ where_clauses = [Or(
469+ public_filter,
470+ BugSummary.viewed_by_id.is_in(
471+ SQL("SELECT team FROM teams")),
472+ BugSummary.access_policy_id.is_in(
473+ SQL("SELECT policy FROM policies")))]
474+ return with_clauses, where_clauses
475
476=== modified file 'lib/lp/bugs/model/bugtask.py'
477--- lib/lp/bugs/model/bugtask.py 2012-07-24 10:03:32 +0000
478+++ lib/lp/bugs/model/bugtask.py 2012-07-25 08:05:24 +0000
479@@ -1549,7 +1549,10 @@
480 def countBugs(self, user, contexts, group_on):
481 """See `IBugTaskSet`."""
482 # Circular fail.
483- from lp.bugs.model.bugsummary import BugSummary
484+ from lp.bugs.model.bugsummary import (
485+ BugSummary,
486+ get_bugsummary_filter_for_user,
487+ )
488 conditions = []
489 # Open bug statuses
490 conditions.append(
491@@ -1577,27 +1580,14 @@
492 conditions.append(BugSummary.tag == None)
493 else:
494 conditions.append(BugSummary.tag != None)
495+
496+ # Apply the privacy filter.
497 store = IStore(BugSummary)
498- admin_team = getUtility(ILaunchpadCelebrities).admin
499- if user is not None and not user.inTeam(admin_team):
500- # admins get to see every bug, everyone else only sees bugs
501- # viewable by them-or-their-teams.
502- store = store.with_(SQL(
503- "teams AS ("
504- "SELECT team from TeamParticipation WHERE person=?)",
505- (user.id,)))
506- # Note that because admins can see every bug regardless of
507- # subscription they will see rather inflated counts. Admins get to
508- # deal.
509- if user is None:
510- conditions.append(BugSummary.viewed_by_id == None)
511- elif not user.inTeam(admin_team):
512- conditions.append(
513- Or(
514- BugSummary.viewed_by_id == None,
515- BugSummary.viewed_by_id.is_in(
516- SQL("SELECT team FROM teams"))
517- ))
518+ user_with, user_where = get_bugsummary_filter_for_user(user)
519+ if user_with:
520+ store = store.with_(user_with)
521+ conditions.extend(user_where)
522+
523 sum_count = Sum(BugSummary.count)
524 resultset = store.find(group_on + (sum_count,), *conditions)
525 resultset.group_by(*group_on)
526
527=== modified file 'lib/lp/bugs/model/tests/test_bugsummary.py'
528--- lib/lp/bugs/model/tests/test_bugsummary.py 2012-05-24 22:37:33 +0000
529+++ lib/lp/bugs/model/tests/test_bugsummary.py 2012-07-25 08:05:24 +0000
530@@ -16,10 +16,12 @@
531 BugTaskStatus,
532 )
533 from lp.bugs.model.bug import BugTag
534-from lp.bugs.model.bugsummary import BugSummary
535+from lp.bugs.model.bugsummary import (
536+ BugSummary,
537+ get_bugsummary_filter_for_user,
538+ )
539 from lp.bugs.model.bugtask import BugTask
540 from lp.registry.enums import InformationType
541-from lp.registry.model.teammembership import TeamParticipation
542 from lp.services.database.lpstorm import IMasterStore
543 from lp.testing import TestCaseWithFactory
544 from lp.testing.dbuser import switch_dbuser
545@@ -41,21 +43,14 @@
546
547 def getCount(self, person, **kw_find_expr):
548 self._maybe_rollup()
549-
550- public_summaries = self.store.find(
551- BugSummary,
552- BugSummary.viewed_by == None,
553- **kw_find_expr)
554- private_summaries = self.store.find(
555- BugSummary,
556- BugSummary.viewed_by_id == TeamParticipation.teamID,
557- TeamParticipation.person == person,
558- **kw_find_expr)
559- all_summaries = public_summaries.union(private_summaries, all=True)
560-
561+ store = self.store
562+ user_with, user_where = get_bugsummary_filter_for_user(person)
563+ if user_with:
564+ store = store.with_(user_with)
565+ summaries = store.find(BugSummary, *user_where, **kw_find_expr)
566 # Note that if there a 0 records found, sum() returns None, but
567 # we prefer to return 0 here.
568- return all_summaries.sum(BugSummary.count) or 0
569+ return summaries.sum(BugSummary.count) or 0
570
571 def assertCount(self, count, user=None, **kw_find_expr):
572 self.assertEqual(count, self.getCount(user, **kw_find_expr))