Merge lp:~allenap/launchpad/subscribe-to-tag-bug-151129-5 into lp:launchpad

Proposed by Gavin Panella
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: 12152
Proposed branch: lp:~allenap/launchpad/subscribe-to-tag-bug-151129-5
Merge into: lp:launchpad
Prerequisite: lp:~allenap/launchpad/subscribe-to-tag-bug-151129-4
Diff against target: 1173 lines (+790/-118)
12 files modified
lib/lp/bugs/browser/bugsubscription.py (+1/-2)
lib/lp/bugs/doc/bugzilla-import.txt (+4/-2)
lib/lp/bugs/doc/initial-bug-contacts.txt (+4/-2)
lib/lp/bugs/doc/security-teams.txt (+4/-2)
lib/lp/bugs/model/bug.py (+214/-30)
lib/lp/bugs/model/bugtask.py (+4/-2)
lib/lp/bugs/model/tests/test_bugsubscriptioninfo.py (+446/-0)
lib/lp/bugs/stories/initial-bug-contacts/20-file-upstream-bug.txt (+4/-2)
lib/lp/bugs/stories/initial-bug-contacts/25-file-distribution-bug.txt (+4/-3)
lib/lp/bugs/tests/test_bugnotification.py (+10/-9)
lib/lp/registry/model/person.py (+13/-1)
lib/lp/registry/tests/test_person_sort_key.py (+82/-63)
To merge this branch: bzr merge lp:~allenap/launchpad/subscribe-to-tag-bug-151129-5
Reviewer Review Type Date Requested Status
Edwin Grubbs (community) code Approve
Review via email: mp+43101@code.launchpad.net

Commit message

[r=edwin-grubbs][ui=none][bug=681326][incr] New class BugSubscriptionInfo which begins to encapsulate all subscription information about a bug.

Description of the change

New class BugSubscriptionInfo that aims to make the murky world of bug
subscriptions a little more understandable, and perform better.

This branch is quite long, but a lot of it is tweaks to tests to
adjust them to work with tuples instead of lists and stuff like
that. Along with another change, this branch can be mentally segmented
into three:

- Provide an in-app twin of the person_sort_key stored procedure (166
  lines of diff).

    lib/lp/registry/model/person.py
    lib/lp/registry/tests/test_person_sort_key.py

- New BugSubscriptionInfo class (728 lines of diff).

    lib/lp/bugs/model/bug.py
    lib/lp/bugs/model/tests/test_bugsubscriptioninfo.py

  Only a couple of Bug.get*Subscri*s methods have been switched over
  to use the new BugSubscriptionInfo class for now.

- Test updates and a few minor code changes (192 lines of diff).

To post a comment you must log in.
Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :

Hi Gavin,

This is an impressive undertaking. Unfortunately, I'm having difficulty understanding the intent that led to some of the design decisions.

The BugSubscriptionSet seems to be intended just to eager load the subscribers and their ValidPersonCache value, if the list of subscriptions is iterated over, but to not make that extra query any sooner than necessary. I'm wondering why you wouldn't just use the pre_iter_hook as used in Bug._indexed_messages().

Perhaps someone with more knowledge of the prerequisite branches would be better suited for reviewing this branch, but if not, I will be back at 15:00UTC, and I think talking on IRC will make this easier.

-Edwin

Revision history for this message
Gavin Panella (allenap) wrote :

Edwin, yes please, let's have a chat this afternoon. I could do with another opinion.

Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :
Download full text (11.9 KiB)

Hi Gavin,

Thanks for explaining the motives behind the BugSubscriptionSet. Since it is unlikely that you will need to batch the results, BugSubscriptionSet provides all the advantages of a DecoratedResultSet pre_iter_hook with a little less complexity and potentially more efficiency.

I have a few other comments below.

-Edwin

>=== modified file 'lib/lp/bugs/model/bug.py'
>--- lib/lp/bugs/model/bug.py 2010-11-05 09:16:14 +0000
>+++ lib/lp/bugs/model/bug.py 2010-12-08 21:12:34 +0000
>@@ -1954,6 +1936,193 @@
> operator.itemgetter(0))
>
>
>+def load_people(*where):
>+ """Get subscribers from subscriptions.
>+
>+ Also preloads `ValidPersonCache` records if they exist.
>+
>+ :param people: An iterable sequence of `Person` IDs.
>+ :return: A `DecoratedResultSet` of `Person` objects. The corresponding
>+ `ValidPersonCache` records are loaded simultaneously.
>+ """
>+ # Don't order the results; they will be used in set operations.
>+ join = LeftJoin(
>+ Person, ValidPersonCache, Person.id == ValidPersonCache.id)
>+ return imap(
>+ operator.itemgetter(0), IStore(Person).using(join).find(
>+ (Person, ValidPersonCache), *where).order_by())

I just landed a branch that adds functions just like load_people().
You can either use:
    getUtility(IPersonSet).getPrecachedPersonsFromIDs(
        ids, need_validity=True)
or if you want to specify the conditions:
    PersonSet()._getPrecachedPersons(
        joins, conditions, need_validity=True)

>+class BugSubscriberSet(frozenset):
>+
>+ @cachedproperty
>+ def sorted(self):
>+ return tuple(sorted(self, key=person_sort_key))
>+
>+
>+class BugSubscriptionSet(frozenset):
>+ @cachedproperty
>+ def sorted(self):
>+ self.subscribers # Pre-load subscribers.
>+ sort_key = lambda sub: person_sort_key(sub.person)
>+ return tuple(sorted(self, key=sort_key))
>+
>+ @cachedproperty
>+ def subscribers(self):
>+ if len(self) == 0:
>+ return BugSubscriberSet()
>+ else:
>+ condition = Person.id.is_in(
>+ removeSecurityProxy(subscription).person_id
>+ for subscription in self)
>+ return BugSubscriberSet(load_people(condition))
>+
>+
>+class StructuralSubscriptionSet(frozenset):
>+
>+ @cachedproperty
>+ def sorted(self):
>+ self.subscribers # Pre-load subscribers.
>+ sort_key = lambda sub: person_sort_key(sub.subscriber)
>+ return tuple(sorted(self, key=sort_key))
>+
>+ @cachedproperty
>+ def subscribers(self):
>+ if len(self) == 0:
>+ return BugSubscriberSet()
>+ else:
>+ condition = Person.id.is_in(
>+ removeSecurityProxy(subscription).subscriberID
>+ for subscription in self)
>+ return BugSubscriberSet(load_people(condition))

These three classes and their properties would benefit from docstrings.

>+
>+
>+# XXX; GavinPanella 2010-12-08: Subclasses of frozenset don't appear to be

If you are planning to remove this XXX before you land it, can you add a
note to BRANCH.TODO?

>+# granted those permissions given to froz...

review: Approve (code)
Revision history for this message
Gavin Panella (allenap) wrote :

Thanks for the review!

In summary I've:

- used PersonSet()._getPrecachedPersons() -- it's awesome :)

- added docstrings to the *Set classes.

- added a note about the XXX to BRANCH.TODO. Still not sure what I'm
  going to do about it.

- removed the XXX about the bug supervisor. The bug supervisor gets
  enough mail as it is.

- given test_person_sort_key a big overhaul, and your layer suggestion
  makes it run much faster, thanks.

If you're interested, there's a diff of the changes at:

  http://paste.ubuntu.com/546982/

Nothing controversial though.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/browser/bugsubscription.py'
2--- lib/lp/bugs/browser/bugsubscription.py 2010-12-01 11:26:57 +0000
3+++ lib/lp/bugs/browser/bugsubscription.py 2010-12-24 15:20:32 +0000
4@@ -15,7 +15,6 @@
5
6 from lazr.delegates import delegates
7 from simplejson import dumps
8-
9 from zope import formlib
10 from zope.app.form import CustomWidgetFactory
11 from zope.app.form.browser.itemswidgets import RadioWidget
12@@ -490,7 +489,7 @@
13 """
14 direct_subscriptions = [
15 SubscriptionAttrDecorator(subscription)
16- for subscription in self.context.getDirectSubscriptions()]
17+ for subscription in self.context.getDirectSubscriptions().sorted]
18 can_unsubscribe = []
19 cannot_unsubscribe = []
20 for subscription in direct_subscriptions:
21
22=== modified file 'lib/lp/bugs/doc/bugzilla-import.txt'
23--- lib/lp/bugs/doc/bugzilla-import.txt 2010-12-23 12:55:53 +0000
24+++ lib/lp/bugs/doc/bugzilla-import.txt 2010-12-24 15:20:32 +0000
25@@ -104,6 +104,7 @@
26 ... def getDuplicates(self):
27 ... return duplicates
28
29+ >>> from itertools import chain
30 >>> from zope.component import getUtility
31 >>> from canonical.launchpad.ftests import login
32 >>> from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
33@@ -133,8 +134,9 @@
34 ... print 'Nick: %s' % bug.name
35 ... print 'Subscribers:'
36 ... subscriber_names = sorted(
37- ... p.displayname for p in (bug.getDirectSubscribers() +
38- ... bug.getIndirectSubscribers()))
39+ ... p.displayname for p in chain(
40+ ... bug.getDirectSubscribers(),
41+ ... bug.getIndirectSubscribers()))
42 ... for subscriber_name in subscriber_names:
43 ... print ' %s' % subscriber_name
44 ... for task in bug.bugtasks:
45
46=== modified file 'lib/lp/bugs/doc/initial-bug-contacts.txt'
47--- lib/lp/bugs/doc/initial-bug-contacts.txt 2010-12-23 12:55:53 +0000
48+++ lib/lp/bugs/doc/initial-bug-contacts.txt 2010-12-24 15:20:32 +0000
49@@ -98,9 +98,11 @@
50 Foo Bar, a package subscriber to ubuntu mozilla-firefox and ubuntu
51 pmount is currently not subscribed to bug 1.
52
53+ >>> from itertools import chain
54 >>> def subscriber_names(bug):
55- ... subscribers = (
56- ... bug.getDirectSubscribers() + bug.getIndirectSubscribers())
57+ ... subscribers = chain(
58+ ... bug.getDirectSubscribers(),
59+ ... bug.getIndirectSubscribers())
60 ... return sorted(subscriber.displayname for subscriber in subscribers)
61
62 >>> subscriber_names(bug_one_in_ubuntu_firefox.bug)
63
64=== modified file 'lib/lp/bugs/doc/security-teams.txt'
65--- lib/lp/bugs/doc/security-teams.txt 2010-10-18 22:24:59 +0000
66+++ lib/lp/bugs/doc/security-teams.txt 2010-12-24 15:20:32 +0000
67@@ -4,6 +4,7 @@
68 Responsibility for security-related bugs, are modelled in Launchpad with
69 a "security contact" on a Distribution or a Product.
70
71+ >>> from itertools import chain
72 >>> from zope.component import getUtility
73 >>> from lp.bugs.interfaces.securitycontact import IHasSecurityContact
74 >>> from lp.registry.interfaces.distribution import IDistributionSet
75@@ -60,8 +61,9 @@
76 Shuttleworth are both subscribed to the bug.
77
78 >>> def subscriber_names(bug):
79- ... subscribers = (
80- ... bug.getDirectSubscribers() + bug.getIndirectSubscribers())
81+ ... subscribers = chain(
82+ ... bug.getDirectSubscribers(),
83+ ... bug.getIndirectSubscribers())
84 ... return sorted(subscriber.name for subscriber in subscribers)
85
86 >>> subscriber_names(bug)
87
88=== modified file 'lib/lp/bugs/model/bug.py'
89--- lib/lp/bugs/model/bug.py 2010-12-23 19:12:19 +0000
90+++ lib/lp/bugs/model/bug.py 2010-12-24 15:20:32 +0000
91@@ -25,6 +25,7 @@
92 timedelta,
93 )
94 from email.Utils import make_msgid
95+from functools import wraps
96 import operator
97 import re
98
99@@ -47,7 +48,6 @@
100 from storm.expr import (
101 And,
102 Count,
103- Func,
104 LeftJoin,
105 Max,
106 Not,
107@@ -175,9 +175,13 @@
108 from lp.registry.interfaces.productseries import IProductSeries
109 from lp.registry.interfaces.series import SeriesStatus
110 from lp.registry.interfaces.sourcepackage import ISourcePackage
111+from lp.registry.interfaces.structuralsubscription import (
112+ IStructuralSubscriptionTarget,
113+ )
114 from lp.registry.model.person import (
115 Person,
116- ValidPersonCache,
117+ person_sort_key,
118+ PersonSet,
119 )
120 from lp.registry.model.pillar import pillar_sort_key
121 from lp.registry.model.teammembership import TeamParticipation
122@@ -796,24 +800,8 @@
123
124 def getDirectSubscriptions(self):
125 """See `IBug`."""
126- # Cache valid persons so that <person>.is_valid_person can
127- # return from the cache. This operation was previously done at
128- # the same time as retrieving the bug subscriptions (as a left
129- # join). However, this ran slowly (far from optimal query
130- # plan), so we're doing it as two queries now.
131- valid_persons = Store.of(self).find(
132- (Person, ValidPersonCache),
133- Person.id == ValidPersonCache.id,
134- ValidPersonCache.id == BugSubscription.person_id,
135- BugSubscription.bug == self)
136- # Suck in all the records so that they're actually cached.
137- list(valid_persons)
138- # Do the main query.
139- return Store.of(self).find(
140- BugSubscription,
141- BugSubscription.person_id == Person.id,
142- BugSubscription.bug == self).order_by(
143- Func('person_sort_key', Person.displayname, Person.name))
144+ return BugSubscriptionInfo(
145+ self, BugNotificationLevel.NOTHING).direct_subscriptions
146
147 def getDirectSubscribers(self, recipients=None, level=None):
148 """See `IBug`.
149@@ -825,18 +813,11 @@
150 """
151 if level is None:
152 level = BugNotificationLevel.NOTHING
153-
154- subscribers = list(
155- Person.select("""
156- Person.id = BugSubscription.person
157- AND BugSubscription.bug = %s
158- AND BugSubscription.bug_notification_level >= %s""" %
159- sqlvalues(self.id, level),
160- orderBy="displayname", clauseTables=["BugSubscription"]))
161+ subscriptions = BugSubscriptionInfo(self, level).direct_subscriptions
162 if recipients is not None:
163- for subscriber in subscribers:
164+ for subscriber in subscriptions.subscribers:
165 recipients.addDirectSubscriber(subscriber)
166- return subscribers
167+ return subscriptions.subscribers.sorted
168
169 def getIndirectSubscribers(self, recipients=None, level=None):
170 """See `IBug`.
171@@ -1958,6 +1939,209 @@
172 operator.itemgetter(0))
173
174
175+def load_people(*where):
176+ """Get subscribers from subscriptions.
177+
178+ Also preloads `ValidPersonCache` records if they exist.
179+
180+ :param people: An iterable sequence of `Person` IDs.
181+ :return: A `DecoratedResultSet` of `Person` objects. The corresponding
182+ `ValidPersonCache` records are loaded simultaneously.
183+ """
184+ return PersonSet()._getPrecachedPersons(
185+ origin=[Person], conditions=where, need_validity=True)
186+
187+
188+class BugSubscriberSet(frozenset):
189+ """A set of bug subscribers
190+
191+ Every member should provide `IPerson`.
192+ """
193+
194+ @cachedproperty
195+ def sorted(self):
196+ """A sorted tuple of this set's members.
197+
198+ Sorted with `person_sort_key`, the default sort key for `Person`.
199+ """
200+ return tuple(sorted(self, key=person_sort_key))
201+
202+
203+class BugSubscriptionSet(frozenset):
204+ """A set of bug subscriptions."""
205+
206+ @cachedproperty
207+ def sorted(self):
208+ """A sorted tuple of this set's members.
209+
210+ Sorted with `person_sort_key` of the subscription owner.
211+ """
212+ self.subscribers # Pre-load subscribers.
213+ sort_key = lambda sub: person_sort_key(sub.person)
214+ return tuple(sorted(self, key=sort_key))
215+
216+ @cachedproperty
217+ def subscribers(self):
218+ """A `BugSubscriberSet` of the owners of this set's members."""
219+ if len(self) == 0:
220+ return BugSubscriberSet()
221+ else:
222+ condition = Person.id.is_in(
223+ removeSecurityProxy(subscription).person_id
224+ for subscription in self)
225+ return BugSubscriberSet(load_people(condition))
226+
227+
228+class StructuralSubscriptionSet(frozenset):
229+ """A set of structural subscriptions."""
230+
231+ @cachedproperty
232+ def sorted(self):
233+ """A sorted tuple of this set's members.
234+
235+ Sorted with `person_sort_key` of the subscription owner.
236+ """
237+ self.subscribers # Pre-load subscribers.
238+ sort_key = lambda sub: person_sort_key(sub.subscriber)
239+ return tuple(sorted(self, key=sort_key))
240+
241+ @cachedproperty
242+ def subscribers(self):
243+ """A `BugSubscriberSet` of the owners of this set's members."""
244+ if len(self) == 0:
245+ return BugSubscriberSet()
246+ else:
247+ condition = Person.id.is_in(
248+ removeSecurityProxy(subscription).subscriberID
249+ for subscription in self)
250+ return BugSubscriberSet(load_people(condition))
251+
252+
253+# XXX: GavinPanella 2010-12-08 bug=694057: Subclasses of frozenset don't
254+# appear to be granted those permissions given to frozenset. This would make
255+# writing ZCML tedious, so I've opted for registering custom checkers (see
256+# lp_sitecustomize for some other jiggery pokery in the same vein) while I
257+# seek a better solution.
258+from zope.security import checker
259+checker_for_frozen_set = checker.getCheckerForInstancesOf(frozenset)
260+checker_for_subscriber_set = checker.NamesChecker(["sorted"])
261+checker_for_subscription_set = checker.NamesChecker(["sorted", "subscribers"])
262+checker.BasicTypes[BugSubscriberSet] = checker.MultiChecker(
263+ (checker_for_frozen_set.get_permissions,
264+ checker_for_subscriber_set.get_permissions))
265+checker.BasicTypes[BugSubscriptionSet] = checker.MultiChecker(
266+ (checker_for_frozen_set.get_permissions,
267+ checker_for_subscription_set.get_permissions))
268+checker.BasicTypes[StructuralSubscriptionSet] = checker.MultiChecker(
269+ (checker_for_frozen_set.get_permissions,
270+ checker_for_subscription_set.get_permissions))
271+
272+
273+def freeze(factory):
274+ """Return a decorator that wraps returned values with `factory`."""
275+
276+ def decorate(func):
277+ """Decorator that wraps returned values."""
278+
279+ @wraps(func)
280+ def wrapper(*args, **kwargs):
281+ return factory(func(*args, **kwargs))
282+ return wrapper
283+
284+ return decorate
285+
286+
287+class BugSubscriptionInfo:
288+ """Represents bug subscription sets."""
289+
290+ def __init__(self, bug, level):
291+ self.bug = bug
292+ self.level = level
293+
294+ @cachedproperty
295+ @freeze(BugSubscriptionSet)
296+ def direct_subscriptions(self):
297+ """The bug's direct subscriptions."""
298+ return IStore(BugSubscription).find(
299+ BugSubscription,
300+ BugSubscription.bug_notification_level >= self.level,
301+ BugSubscription.bug == self.bug)
302+
303+ @cachedproperty
304+ @freeze(BugSubscriptionSet)
305+ def duplicate_subscriptions(self):
306+ """Subscriptions to duplicates of the bug."""
307+ if self.bug.private:
308+ return ()
309+ else:
310+ return IStore(BugSubscription).find(
311+ BugSubscription,
312+ BugSubscription.bug_notification_level >= self.level,
313+ BugSubscription.bug_id == Bug.id,
314+ Bug.duplicateof == self.bug)
315+
316+ @cachedproperty
317+ @freeze(StructuralSubscriptionSet)
318+ def structural_subscriptions(self):
319+ """Structural subscriptions to the bug's targets."""
320+ query_arguments = []
321+ for bugtask in self.bug.bugtasks:
322+ if IStructuralSubscriptionTarget.providedBy(bugtask.target):
323+ query_arguments.append((bugtask.target, bugtask))
324+ if bugtask.target.parent_subscription_target is not None:
325+ query_arguments.append(
326+ (bugtask.target.parent_subscription_target, bugtask))
327+ if ISourcePackage.providedBy(bugtask.target):
328+ # Distribution series bug tasks with a package have the source
329+ # package set as their target, so we add the distroseries
330+ # explicitly to the set of subscription targets.
331+ query_arguments.append((bugtask.distroseries, bugtask))
332+ if bugtask.milestone is not None:
333+ query_arguments.append((bugtask.milestone, bugtask))
334+ # Build the query.
335+ union = lambda left, right: (
336+ removeSecurityProxy(left).union(
337+ removeSecurityProxy(right)))
338+ queries = (
339+ target.getSubscriptionsForBugTask(bugtask, self.level)
340+ for target, bugtask in query_arguments)
341+ return reduce(union, queries)
342+
343+ @cachedproperty
344+ @freeze(BugSubscriberSet)
345+ def all_assignees(self):
346+ """Assignees of the bug's tasks."""
347+ assignees = Select(BugTask.assigneeID, BugTask.bug == self.bug)
348+ return load_people(Person.id.is_in(assignees))
349+
350+ @cachedproperty
351+ @freeze(BugSubscriberSet)
352+ def all_pillar_owners_without_bug_supervisors(self):
353+ """Owners of pillars for which no Bug supervisor is configured."""
354+ for bugtask in self.bug.bugtasks:
355+ pillar = bugtask.pillar
356+ if pillar.bug_supervisor is None:
357+ yield pillar.owner
358+
359+ @cachedproperty
360+ def also_notified_subscribers(self):
361+ """All subscribers except direct and dupe subscribers."""
362+ if self.bug.private:
363+ return BugSubscriberSet()
364+ else:
365+ return BugSubscriberSet().union(
366+ self.structural_subscriptions.subscribers,
367+ self.all_pillar_owners_without_bug_supervisors,
368+ self.all_assignees).difference(
369+ self.direct_subscriptions.subscribers)
370+
371+ @cachedproperty
372+ def indirect_subscribers(self):
373+ """All subscribers except direct subscribers."""
374+ return self.also_notified_subscribers.union(
375+ self.duplicate_subscriptions.subscribers)
376+
377+
378 class BugSet:
379 """See BugSet."""
380 implements(IBugSet)
381
382=== modified file 'lib/lp/bugs/model/bugtask.py'
383--- lib/lp/bugs/model/bugtask.py 2010-12-15 16:41:42 +0000
384+++ lib/lp/bugs/model/bugtask.py 2010-12-24 15:20:32 +0000
385@@ -22,6 +22,7 @@
386
387
388 import datetime
389+from itertools import chain
390 from operator import attrgetter
391
392 from lazr.enum import DBItem
393@@ -290,8 +291,9 @@
394 @property
395 def bug_subscribers(self):
396 """See `IBugTask`."""
397- indirect_subscribers = self.bug.getIndirectSubscribers()
398- return self.bug.getDirectSubscribers() + indirect_subscribers
399+ return tuple(
400+ chain(self.bug.getDirectSubscribers(),
401+ self.bug.getIndirectSubscribers()))
402
403 @property
404 def bugtargetdisplayname(self):
405
406=== added file 'lib/lp/bugs/model/tests/test_bugsubscriptioninfo.py'
407--- lib/lp/bugs/model/tests/test_bugsubscriptioninfo.py 1970-01-01 00:00:00 +0000
408+++ lib/lp/bugs/model/tests/test_bugsubscriptioninfo.py 2010-12-24 15:20:32 +0000
409@@ -0,0 +1,446 @@
410+# Copyright 2010 Canonical Ltd. This software is licensed under the
411+# GNU Affero General Public License version 3 (see the file LICENSE).
412+
413+"""Test `BugSubscriptionInfo`."""
414+
415+__metaclass__ = type
416+
417+from contextlib import contextmanager
418+
419+from storm.store import Store
420+from testtools.matchers import Equals
421+
422+from canonical.testing.layers import DatabaseFunctionalLayer
423+from lp.bugs.model.bug import (
424+ BugSubscriberSet,
425+ BugSubscriptionInfo,
426+ BugSubscriptionSet,
427+ load_people,
428+ StructuralSubscriptionSet,
429+ )
430+from lp.registry.enum import BugNotificationLevel
431+from lp.registry.model.person import Person
432+from lp.testing import (
433+ person_logged_in,
434+ StormStatementRecorder,
435+ TestCaseWithFactory,
436+ )
437+from lp.testing.matchers import HasQueryCount
438+
439+
440+class TestLoadPeople(TestCaseWithFactory):
441+ """Tests for `load_people`."""
442+
443+ layer = DatabaseFunctionalLayer
444+
445+ def test(self):
446+ expected = [
447+ self.factory.makePerson(),
448+ self.factory.makeTeam(),
449+ ]
450+ observed = load_people(
451+ Person.id.is_in(person.id for person in expected))
452+ self.assertEqual(set(expected), set(observed))
453+
454+
455+class TestSubscriptionRelatedSets(TestCaseWithFactory):
456+ """Tests for *Set classes related to subscriptions."""
457+
458+ layer = DatabaseFunctionalLayer
459+
460+ name_pairs = ("A", "xa"), ("C", "xd"), ("B", "xb"), ("C", "xc")
461+ name_pairs_sorted = ("A", "xa"), ("B", "xb"), ("C", "xc"), ("C", "xd")
462+
463+ def setUp(self):
464+ super(TestSubscriptionRelatedSets, self).setUp()
465+ make_person = lambda (displayname, name): (
466+ self.factory.makePerson(displayname=displayname, name=name))
467+ subscribers = dict(
468+ (name_pair, make_person(name_pair))
469+ for name_pair in self.name_pairs)
470+ self.subscribers_set = frozenset(subscribers.itervalues())
471+ self.subscribers_sorted = tuple(
472+ subscribers[name_pair] for name_pair in self.name_pairs_sorted)
473+
474+ def test_BugSubscriberSet(self):
475+ subscriber_set = BugSubscriberSet(self.subscribers_set)
476+ self.assertIsInstance(subscriber_set, frozenset)
477+ self.assertEqual(self.subscribers_set, subscriber_set)
478+ self.assertEqual(self.subscribers_sorted, subscriber_set.sorted)
479+
480+ def test_BugSubscriptionSet(self):
481+ bug = self.factory.makeBug()
482+ with person_logged_in(bug.owner):
483+ subscriptions = frozenset(
484+ bug.subscribe(subscriber, subscriber)
485+ for subscriber in self.subscribers_set)
486+ subscription_set = BugSubscriptionSet(subscriptions)
487+ self.assertIsInstance(subscription_set, frozenset)
488+ self.assertEqual(subscriptions, subscription_set)
489+ # BugSubscriptionSet.sorted returns a tuple of subscriptions ordered
490+ # by subscribers.
491+ self.assertEqual(
492+ self.subscribers_sorted, tuple(
493+ subscription.person
494+ for subscription in subscription_set.sorted))
495+ # BugSubscriptionSet.subscribers returns a BugSubscriberSet of the
496+ # subscription's subscribers.
497+ self.assertIsInstance(subscription_set.subscribers, BugSubscriberSet)
498+ self.assertEqual(self.subscribers_set, subscription_set.subscribers)
499+
500+ def test_StructuralSubscriptionSet(self):
501+ product = self.factory.makeProduct()
502+ with person_logged_in(product.owner):
503+ subscriptions = frozenset(
504+ product.addSubscription(subscriber, subscriber)
505+ for subscriber in self.subscribers_set)
506+ subscription_set = StructuralSubscriptionSet(subscriptions)
507+ self.assertIsInstance(subscription_set, frozenset)
508+ self.assertEqual(subscriptions, subscription_set)
509+ # StructuralSubscriptionSet.sorted returns a tuple of subscriptions
510+ # ordered by subscribers.
511+ self.assertEqual(
512+ self.subscribers_sorted, tuple(
513+ subscription.subscriber
514+ for subscription in subscription_set.sorted))
515+ # StructuralSubscriptionSet.subscribers returns a BugSubscriberSet of
516+ # the subscription's subscribers.
517+ self.assertIsInstance(subscription_set.subscribers, BugSubscriberSet)
518+ self.assertEqual(self.subscribers_set, subscription_set.subscribers)
519+
520+
521+class TestBugSubscriptionInfo(TestCaseWithFactory):
522+
523+ layer = DatabaseFunctionalLayer
524+
525+ def setUp(self):
526+ super(TestBugSubscriptionInfo, self).setUp()
527+ self.target = self.factory.makeProduct(
528+ bug_supervisor=self.factory.makePerson())
529+ self.bug = self.factory.makeBug(product=self.target)
530+ # Unsubscribe the bug filer to make the tests more readable.
531+ with person_logged_in(self.bug.owner):
532+ self.bug.unsubscribe(self.bug.owner, self.bug.owner)
533+
534+ def getInfo(self):
535+ return BugSubscriptionInfo(
536+ self.bug, BugNotificationLevel.NOTHING)
537+
538+ def test_direct(self):
539+ # The set of direct subscribers.
540+ subscribers = (
541+ self.factory.makePerson(),
542+ self.factory.makePerson())
543+ with person_logged_in(self.bug.owner):
544+ subscriptions = tuple(
545+ self.bug.subscribe(subscriber, subscriber)
546+ for subscriber in subscribers)
547+ found_subscriptions = self.getInfo().direct_subscriptions
548+ self.assertEqual(set(subscriptions), found_subscriptions)
549+ self.assertEqual(subscriptions, found_subscriptions.sorted)
550+ self.assertEqual(set(subscribers), found_subscriptions.subscribers)
551+ self.assertEqual(subscribers, found_subscriptions.subscribers.sorted)
552+
553+ def test_duplicate(self):
554+ # The set of subscribers from duplicate bugs.
555+ found_subscriptions = self.getInfo().duplicate_subscriptions
556+ self.assertEqual(set(), found_subscriptions)
557+ self.assertEqual((), found_subscriptions.sorted)
558+ self.assertEqual(set(), found_subscriptions.subscribers)
559+ self.assertEqual((), found_subscriptions.subscribers.sorted)
560+ duplicate_bug = self.factory.makeBug(product=self.target)
561+ with person_logged_in(duplicate_bug.owner):
562+ duplicate_bug.markAsDuplicate(self.bug)
563+ duplicate_bug_subscription = (
564+ duplicate_bug.getSubscriptionForPerson(
565+ duplicate_bug.owner))
566+ found_subscriptions = self.getInfo().duplicate_subscriptions
567+ self.assertEqual(
568+ set([duplicate_bug_subscription]),
569+ found_subscriptions)
570+ self.assertEqual(
571+ (duplicate_bug_subscription,),
572+ found_subscriptions.sorted)
573+ self.assertEqual(
574+ set([duplicate_bug.owner]),
575+ found_subscriptions.subscribers)
576+ self.assertEqual(
577+ (duplicate_bug.owner,),
578+ found_subscriptions.subscribers.sorted)
579+
580+ def test_duplicate_for_private_bug(self):
581+ # The set of subscribers from duplicate bugs is always empty when the
582+ # master bug is private.
583+ duplicate_bug = self.factory.makeBug(product=self.target)
584+ with person_logged_in(duplicate_bug.owner):
585+ duplicate_bug.markAsDuplicate(self.bug)
586+ with person_logged_in(self.bug.owner):
587+ self.bug.setPrivate(True, self.bug.owner)
588+ found_subscriptions = self.getInfo().duplicate_subscriptions
589+ self.assertEqual(set(), found_subscriptions)
590+ self.assertEqual((), found_subscriptions.sorted)
591+ self.assertEqual(set(), found_subscriptions.subscribers)
592+ self.assertEqual((), found_subscriptions.subscribers.sorted)
593+
594+ def test_structural(self):
595+ # The set of structural subscribers.
596+ subscribers = (
597+ self.factory.makePerson(),
598+ self.factory.makePerson())
599+ with person_logged_in(self.bug.owner):
600+ subscriptions = tuple(
601+ self.target.addBugSubscription(subscriber, subscriber)
602+ for subscriber in subscribers)
603+ found_subscriptions = self.getInfo().structural_subscriptions
604+ self.assertEqual(set(subscriptions), found_subscriptions)
605+ self.assertEqual(subscriptions, found_subscriptions.sorted)
606+ self.assertEqual(set(subscribers), found_subscriptions.subscribers)
607+ self.assertEqual(subscribers, found_subscriptions.subscribers.sorted)
608+
609+ def test_all_assignees(self):
610+ # The set of bugtask assignees for bugtasks that have been assigned.
611+ found_assignees = self.getInfo().all_assignees
612+ self.assertEqual(set(), found_assignees)
613+ self.assertEqual((), found_assignees.sorted)
614+ bugtask = self.bug.default_bugtask
615+ with person_logged_in(bugtask.pillar.bug_supervisor):
616+ bugtask.transitionToAssignee(self.bug.owner)
617+ found_assignees = self.getInfo().all_assignees
618+ self.assertEqual(set([self.bug.owner]), found_assignees)
619+ self.assertEqual((self.bug.owner,), found_assignees.sorted)
620+ bugtask2 = self.factory.makeBugTask(bug=self.bug)
621+ with person_logged_in(bugtask2.pillar.owner):
622+ bugtask2.transitionToAssignee(bugtask2.owner)
623+ found_assignees = self.getInfo().all_assignees
624+ self.assertEqual(
625+ set([self.bug.owner, bugtask2.owner]),
626+ found_assignees)
627+ self.assertEqual(
628+ (self.bug.owner, bugtask2.owner),
629+ found_assignees.sorted)
630+
631+ def test_all_pillar_owners_without_bug_supervisors(self):
632+ # The set of owners of pillars for which no bug supervisor is
633+ # configured.
634+ [bugtask] = self.bug.bugtasks
635+ found_owners = (
636+ self.getInfo().all_pillar_owners_without_bug_supervisors)
637+ self.assertEqual(set(), found_owners)
638+ self.assertEqual((), found_owners.sorted)
639+ # Clear the supervisor for the first bugtask's target.
640+ with person_logged_in(bugtask.target.owner):
641+ bugtask.target.setBugSupervisor(None, bugtask.owner)
642+ found_owners = (
643+ self.getInfo().all_pillar_owners_without_bug_supervisors)
644+ self.assertEqual(set([bugtask.pillar.owner]), found_owners)
645+ self.assertEqual((bugtask.pillar.owner,), found_owners.sorted)
646+ # Add another bugtask with a bug supervisor.
647+ target2 = self.factory.makeProduct(bug_supervisor=None)
648+ bugtask2 = self.factory.makeBugTask(bug=self.bug, target=target2)
649+ found_owners = (
650+ self.getInfo().all_pillar_owners_without_bug_supervisors)
651+ self.assertEqual(
652+ set([bugtask.pillar.owner, bugtask2.pillar.owner]),
653+ found_owners)
654+ self.assertEqual(
655+ (bugtask.pillar.owner, bugtask2.pillar.owner),
656+ found_owners.sorted)
657+
658+ def test_also_notified_subscribers(self):
659+ # The set of also notified subscribers.
660+ found_subscribers = self.getInfo().also_notified_subscribers
661+ self.assertEqual(set(), found_subscribers)
662+ self.assertEqual((), found_subscribers.sorted)
663+ # Add an assignee, a bug supervisor and a structural subscriber.
664+ bugtask = self.bug.default_bugtask
665+ assignee = self.factory.makePerson()
666+ with person_logged_in(bugtask.pillar.bug_supervisor):
667+ bugtask.transitionToAssignee(assignee)
668+ supervisor = self.factory.makePerson()
669+ with person_logged_in(bugtask.target.owner):
670+ bugtask.target.setBugSupervisor(supervisor, supervisor)
671+ structural_subscriber = self.factory.makePerson()
672+ with person_logged_in(structural_subscriber):
673+ bugtask.target.addSubscription(
674+ structural_subscriber, structural_subscriber)
675+ # Add a direct subscription.
676+ direct_subscriber = self.factory.makePerson()
677+ with person_logged_in(self.bug.owner):
678+ self.bug.subscribe(direct_subscriber, direct_subscriber)
679+ # The direct subscriber does not appear in the also notified set, but
680+ # the assignee, supervisor and structural subscriber do.
681+ found_subscribers = self.getInfo().also_notified_subscribers
682+ self.assertEqual(
683+ set([assignee, supervisor, structural_subscriber]),
684+ found_subscribers)
685+ self.assertEqual(
686+ (assignee, supervisor, structural_subscriber),
687+ found_subscribers.sorted)
688+
689+ def test_also_notified_subscribers_for_private_bug(self):
690+ # The set of also notified subscribers is always empty when the master
691+ # bug is private.
692+ assignee = self.factory.makePerson()
693+ bugtask = self.bug.default_bugtask
694+ with person_logged_in(bugtask.pillar.bug_supervisor):
695+ bugtask.transitionToAssignee(assignee)
696+ with person_logged_in(self.bug.owner):
697+ self.bug.setPrivate(True, self.bug.owner)
698+ found_subscribers = self.getInfo().also_notified_subscribers
699+ self.assertEqual(set(), found_subscribers)
700+ self.assertEqual((), found_subscribers.sorted)
701+
702+ def test_indirect_subscribers(self):
703+ # The set of indirect subscribers is the union of also notified
704+ # subscribers and subscribers to duplicates.
705+ assignee = self.factory.makePerson()
706+ bugtask = self.bug.default_bugtask
707+ with person_logged_in(bugtask.pillar.bug_supervisor):
708+ bugtask.transitionToAssignee(assignee)
709+ duplicate_bug = self.factory.makeBug(product=self.target)
710+ with person_logged_in(duplicate_bug.owner):
711+ duplicate_bug.markAsDuplicate(self.bug)
712+ found_subscribers = self.getInfo().indirect_subscribers
713+ self.assertEqual(
714+ set([assignee, duplicate_bug.owner]),
715+ found_subscribers)
716+ self.assertEqual(
717+ (assignee, duplicate_bug.owner),
718+ found_subscribers.sorted)
719+
720+
721+class TestBugSubscriptionInfoQueries(TestCaseWithFactory):
722+
723+ layer = DatabaseFunctionalLayer
724+
725+ def setUp(self):
726+ super(TestBugSubscriptionInfoQueries, self).setUp()
727+ self.target = self.factory.makeProduct()
728+ self.bug = self.factory.makeBug(product=self.target)
729+ self.info = BugSubscriptionInfo(
730+ self.bug, BugNotificationLevel.NOTHING)
731+ # Get the Storm cache into a known state.
732+ self.store = Store.of(self.bug)
733+ self.store.invalidate()
734+ self.store.reload(self.bug)
735+ self.bug.bugtasks
736+ self.bug.tags
737+
738+ @contextmanager
739+ def exactly_x_queries(self, count):
740+ # Assert that there are exactly `count` queries sent to the database
741+ # in this context. Flush first to ensure we don't count things that
742+ # happened before entering this context.
743+ self.store.flush()
744+ condition = HasQueryCount(Equals(count))
745+ with StormStatementRecorder() as recorder:
746+ yield recorder
747+ self.assertThat(recorder, condition)
748+
749+ def exercise_subscription_set(self, set_name):
750+ # Looking up subscriptions takes a single query.
751+ with self.exactly_x_queries(1):
752+ getattr(self.info, set_name)
753+ # Getting the subscribers results in one additional query.
754+ with self.exactly_x_queries(1):
755+ getattr(self.info, set_name).subscribers
756+ # Everything is now cached so no more queries are needed.
757+ with self.exactly_x_queries(0):
758+ getattr(self.info, set_name).subscribers
759+ getattr(self.info, set_name).subscribers.sorted
760+
761+ def exercise_subscription_set_sorted_first(self, set_name):
762+ # Looking up subscriptions takes a single query.
763+ with self.exactly_x_queries(1):
764+ getattr(self.info, set_name)
765+ # Getting the sorted subscriptions takes one additional query.
766+ with self.exactly_x_queries(1):
767+ getattr(self.info, set_name).sorted
768+ # Everything is now cached so no more queries are needed.
769+ with self.exactly_x_queries(0):
770+ getattr(self.info, set_name).subscribers
771+ getattr(self.info, set_name).subscribers.sorted
772+
773+ def test_direct_subscriptions(self):
774+ self.exercise_subscription_set(
775+ "direct_subscriptions")
776+
777+ def test_direct_subscriptions_sorted_first(self):
778+ self.exercise_subscription_set_sorted_first(
779+ "direct_subscriptions")
780+
781+ def make_duplicate_bug(self):
782+ duplicate_bug = self.factory.makeBug(product=self.target)
783+ with person_logged_in(duplicate_bug.owner):
784+ duplicate_bug.markAsDuplicate(self.bug)
785+
786+ def test_duplicate_subscriptions(self):
787+ self.make_duplicate_bug()
788+ self.exercise_subscription_set(
789+ "duplicate_subscriptions")
790+
791+ def test_duplicate_subscriptions_sorted_first(self):
792+ self.make_duplicate_bug()
793+ self.exercise_subscription_set_sorted_first(
794+ "duplicate_subscriptions")
795+
796+ def test_duplicate_subscriptions_for_private_bug(self):
797+ self.make_duplicate_bug()
798+ with person_logged_in(self.bug.owner):
799+ self.bug.setPrivate(True, self.bug.owner)
800+ with self.exactly_x_queries(0):
801+ self.info.duplicate_subscriptions
802+ with self.exactly_x_queries(0):
803+ self.info.duplicate_subscriptions.subscribers
804+
805+ def add_structural_subscriber(self):
806+ with person_logged_in(self.bug.owner):
807+ self.target.addSubscription(self.bug.owner, self.bug.owner)
808+
809+ def test_structural_subscriptions(self):
810+ self.add_structural_subscriber()
811+ self.exercise_subscription_set(
812+ "structural_subscriptions")
813+
814+ def test_structural_subscriptions_sorted_first(self):
815+ self.add_structural_subscriber()
816+ self.exercise_subscription_set_sorted_first(
817+ "structural_subscriptions")
818+
819+ def test_all_assignees(self):
820+ with self.exactly_x_queries(1):
821+ self.info.all_assignees
822+
823+ def test_all_pillar_owners_without_bug_supervisors(self):
824+ # Getting all bug supervisors and pillar owners can take several
825+ # queries. However, there are typically few tasks so the trade for
826+ # simplicity of implementation is acceptable. Only the simplest case
827+ # is tested here.
828+ with self.exactly_x_queries(2):
829+ self.info.all_pillar_owners_without_bug_supervisors
830+
831+ def test_also_notified_subscribers(self):
832+ with self.exactly_x_queries(6):
833+ self.info.also_notified_subscribers
834+
835+ def test_also_notified_subscribers_later(self):
836+ # When also_notified_subscribers is referenced after some other sets
837+ # in BugSubscriptionInfo are referenced, everything comes from cache.
838+ self.info.all_assignees
839+ self.info.all_pillar_owners_without_bug_supervisors
840+ self.info.direct_subscriptions.subscribers
841+ self.info.structural_subscriptions.subscribers
842+ with self.exactly_x_queries(0):
843+ self.info.also_notified_subscribers
844+
845+ def test_indirect_subscribers(self):
846+ with self.exactly_x_queries(7):
847+ self.info.indirect_subscribers
848+
849+ def test_indirect_subscribers_later(self):
850+ # When indirect_subscribers is referenced after some other sets in
851+ # BugSubscriptionInfo are referenced, everything comes from cache.
852+ self.info.also_notified_subscribers
853+ self.info.duplicate_subscriptions.subscribers
854+ with self.exactly_x_queries(0):
855+ self.info.indirect_subscribers
856
857=== modified file 'lib/lp/bugs/stories/initial-bug-contacts/20-file-upstream-bug.txt'
858--- lib/lp/bugs/stories/initial-bug-contacts/20-file-upstream-bug.txt 2010-10-09 16:36:22 +0000
859+++ lib/lp/bugs/stories/initial-bug-contacts/20-file-upstream-bug.txt 2010-12-24 15:20:32 +0000
860@@ -19,13 +19,15 @@
861 supervisor), Landscape Developers (another former bug supervisor) and
862 Robert Collins (the current bug supervisor) are subscribed to this bug:
863
864+ >>> from itertools import chain
865 >>> from zope.component import getUtility
866 >>> from lp.bugs.interfaces.bug import IBugSet
867 >>> from canonical.launchpad.ftests import login, logout, ANONYMOUS
868
869 >>> def subscriber_names(bug):
870- ... subscribers = (
871- ... bug.getDirectSubscribers() + bug.getIndirectSubscribers())
872+ ... subscribers = chain(
873+ ... bug.getDirectSubscribers(),
874+ ... bug.getIndirectSubscribers())
875 ... return sorted(subscriber.displayname for subscriber in subscribers)
876
877 >>> login(ANONYMOUS)
878
879=== modified file 'lib/lp/bugs/stories/initial-bug-contacts/25-file-distribution-bug.txt'
880--- lib/lp/bugs/stories/initial-bug-contacts/25-file-distribution-bug.txt 2010-10-09 16:36:22 +0000
881+++ lib/lp/bugs/stories/initial-bug-contacts/25-file-distribution-bug.txt 2010-12-24 15:20:32 +0000
882@@ -23,13 +23,15 @@
883 subscriber), mark, the distro bug supervisor kamion, and foobar, who is
884 subscribed to the distribution.
885
886+ >>> from itertools import chain
887 >>> from zope.component import getUtility
888 >>> from lp.bugs.interfaces.bug import IBugSet
889 >>> from canonical.launchpad.ftests import login, logout, ANONYMOUS
890
891 >>> def subscriber_names(bug):
892- ... subscribers = (
893- ... bug.getDirectSubscribers() + bug.getIndirectSubscribers())
894+ ... subscribers = chain(
895+ ... bug.getDirectSubscribers(),
896+ ... bug.getIndirectSubscribers())
897 ... return sorted(subscriber.name for subscriber in subscribers)
898
899 >>> login(ANONYMOUS)
900@@ -72,4 +74,3 @@
901 [u'mark', u'ubuntu-team']
902
903 >>> logout()
904-
905
906=== modified file 'lib/lp/bugs/tests/test_bugnotification.py'
907--- lib/lp/bugs/tests/test_bugnotification.py 2010-12-23 19:42:05 +0000
908+++ lib/lp/bugs/tests/test_bugnotification.py 2010-12-24 15:20:32 +0000
909@@ -5,6 +5,7 @@
910
911 __metaclass__ = type
912
913+from itertools import chain
914 import unittest
915
916 from lazr.lifecycle.event import ObjectModifiedEvent
917@@ -31,8 +32,8 @@
918 )
919 from lp.testing import TestCaseWithFactory
920 from lp.testing.factory import LaunchpadObjectFactory
921+from lp.testing.mail_helpers import pop_notifications
922 from lp.testing.matchers import Contains
923-from lp.testing.mail_helpers import pop_notifications
924
925
926 class TestNotificationRecipientsOfPrivateBugs(unittest.TestCase):
927@@ -170,8 +171,8 @@
928 self.bug = self.factory.makeBug()
929 self.dupe_bug = self.factory.makeBug()
930 self.dupe_bug.markAsDuplicate(self.bug)
931- self.dupe_subscribers = set(
932- self.dupe_bug.getDirectSubscribers() +
933+ self.dupe_subscribers = set().union(
934+ self.dupe_bug.getDirectSubscribers(),
935 self.dupe_bug.getIndirectSubscribers())
936
937 def test_comment_notifications(self):
938@@ -246,9 +247,9 @@
939 self.pillar.official_malone = True
940 [bugtask] = self.bug.bugtasks
941 all_subscribers = set(
942- [person.name for person in
943- self.bug.getDirectSubscribers() +
944- self.bug.getIndirectSubscribers()])
945+ [person.name for person in chain(
946+ self.bug.getDirectSubscribers(),
947+ self.bug.getIndirectSubscribers())])
948 bugtask_before_modification = Snapshot(
949 bugtask, providing=providedBy(bugtask))
950 bugtask.transitionToStatus(
951@@ -268,9 +269,9 @@
952 self.pillar.official_malone = False
953 [bugtask] = self.bug.bugtasks
954 all_subscribers = set(
955- [person.name for person in
956- self.bug.getDirectSubscribers() +
957- self.bug.getIndirectSubscribers()])
958+ [person.name for person in chain(
959+ self.bug.getDirectSubscribers(),
960+ self.bug.getIndirectSubscribers())])
961 bugtask_before_modification = Snapshot(
962 bugtask, providing=providedBy(bugtask))
963 bugtask.transitionToStatus(
964
965=== modified file 'lib/lp/registry/model/person.py'
966--- lib/lp/registry/model/person.py 2010-12-20 15:06:51 +0000
967+++ lib/lp/registry/model/person.py 2010-12-24 15:20:32 +0000
968@@ -15,6 +15,7 @@
969 'JoinTeamEvent',
970 'Owner',
971 'Person',
972+ 'person_sort_key',
973 'PersonLanguage',
974 'PersonSet',
975 'SSHKey',
976@@ -22,7 +23,8 @@
977 'TeamInvitationEvent',
978 'ValidPersonCache',
979 'WikiName',
980- 'WikiNameSet']
981+ 'WikiNameSet',
982+ ]
983
984 from datetime import (
985 datetime,
986@@ -339,6 +341,16 @@
987 return value
988
989
990+_person_sort_re = re.compile("(?:[^\w\s]|[\d_])", re.U)
991+
992+def person_sort_key(person):
993+ """Identical to `person_sort_key` in the database."""
994+ # Strip noise out of displayname. We do not have to bother with
995+ # name, as we know it is just plain ascii.
996+ displayname = _person_sort_re.sub(u'', person.displayname.lower())
997+ return "%s, %s" % (displayname.strip(), person.name)
998+
999+
1000 class Person(
1001 SQLBase, HasBugsBase, HasSpecificationsMixin, HasTranslationImportsMixin,
1002 HasBranchesMixin, HasMergeProposalsMixin, HasRequestedReviewsMixin):
1003
1004=== modified file 'lib/lp/registry/tests/test_person_sort_key.py'
1005--- lib/lp/registry/tests/test_person_sort_key.py 2010-10-04 19:50:45 +0000
1006+++ lib/lp/registry/tests/test_person_sort_key.py 2010-12-24 15:20:32 +0000
1007@@ -1,84 +1,103 @@
1008 # Copyright 2009 Canonical Ltd. This software is licensed under the
1009 # GNU Affero General Public License version 3 (see the file LICENSE).
1010
1011-"""Test the person_sort_key stored procedure."""
1012+"""Test the person_sort_key stored procedure and its in-app twin."""
1013
1014 __metaclass__ = type
1015
1016-import unittest
1017-
1018-from canonical.testing.layers import LaunchpadLayer
1019-
1020-
1021-class TestPersonSortKey(unittest.TestCase):
1022- layer = LaunchpadLayer
1023+from canonical.testing.layers import DatabaseLayer
1024+from lp.registry.model.person import person_sort_key
1025+from lp.testing import TestCase
1026+
1027+
1028+class TestPersonSortKeyBase:
1029+
1030+ def test_composition(self):
1031+ # person_sort_key returns the concatenation of the display name and
1032+ # the name for use in sorting.
1033+ self.assertSortKeysEqual(
1034+ u"Stuart Bishop", u"stub",
1035+ u"stuart bishop, stub")
1036+
1037+ def test_whitespace(self):
1038+ # Leading and trailing whitespace is removed.
1039+ self.assertSortKeysEqual(
1040+ u" Stuart Bishop\t", u"stub",
1041+ u"stuart bishop, stub")
1042+
1043+ def test_valid_name_is_assumed(self):
1044+ # 'name' is assumed to be lowercase and not containing anything we
1045+ # don't want. This should never happen as the valid_name database
1046+ # constraint should prevent it.
1047+ self.assertSortKeysEqual(
1048+ u"Stuart Bishop", u" stub42!!!",
1049+ u"stuart bishop, stub42!!!")
1050+
1051+ def test_strip_all_but_letters_and_whitespace(self):
1052+ # Everything except for letters and whitespace is stripped.
1053+ self.assertSortKeysEqual(
1054+ u"-= Mass1v3 T0SSA =-", u"tossa",
1055+ u"massv tssa, tossa")
1056+
1057+ def test_non_ascii_allowed(self):
1058+ # Non ASCII letters are currently allowed. Eventually they should
1059+ # become transliterated to ASCII but we don't do this yet.
1060+ self.assertSortKeysEqual(
1061+ u"Bj\N{LATIN SMALL LETTER O WITH DIAERESIS}rn", "bjorn",
1062+ u"bj\xf6rn, bjorn")
1063+
1064+ def test_unicode_case_conversion(self):
1065+ # Case conversion is handled correctly using Unicode.
1066+ self.assertSortKeysEqual(
1067+ u"Bj\N{LATIN CAPITAL LETTER O WITH DIAERESIS}rn", "bjorn",
1068+ u"bj\xf6rn, bjorn") # Lower case o with diaeresis
1069+
1070+
1071+class TestPersonSortKeyInDatabase(TestPersonSortKeyBase, TestCase):
1072+
1073+ layer = DatabaseLayer
1074
1075 def setUp(self):
1076+ super(TestPersonSortKeyInDatabase, self).setUp()
1077 self.con = self.layer.connect()
1078 self.cur = self.con.cursor()
1079
1080 def tearDown(self):
1081+ super(TestPersonSortKeyInDatabase, self).tearDown()
1082 self.con.close()
1083
1084- def person_sort_key(self, displayname, name):
1085- '''Calls the person_sort_key stored procedure
1086+ def get_person_sort_key(self, displayname, name):
1087+ '''Calls the `person_sort_key` stored procedure.
1088
1089 Note that although the stored procedure returns a UTF-8 encoded
1090 string, our database driver converts that to Unicode for us.
1091 '''
1092+ # Note that as we are testing a PostgreSQL stored procedure, we should
1093+ # pass it UTF-8 encoded strings to match our database encoding.
1094 self.cur.execute(
1095- "SELECT person_sort_key(%(displayname)s, %(name)s)", vars()
1096- )
1097+ "SELECT person_sort_key(%s, %s)", (
1098+ displayname.encode("UTF-8"), name.encode("UTF-8")))
1099 return self.cur.fetchone()[0]
1100
1101- def test_person_sort_key(self):
1102-
1103- # person_sort_key returns the concatenation of the display name
1104- # and the name for use in sorting.
1105- self.failUnlessEqual(
1106- self.person_sort_key("Stuart Bishop", "stub"),
1107- "stuart bishop, stub"
1108- )
1109-
1110- # Leading and trailing whitespace is removed
1111- self.failUnlessEqual(
1112- self.person_sort_key(" Stuart Bishop\t", "stub"),
1113- "stuart bishop, stub"
1114- )
1115-
1116- # 'name' is assumed to be lowercase and not containing anything
1117- # we don't want. This should never happen as the valid_name database
1118- # constraint should prevent it.
1119- self.failUnlessEqual(
1120- self.person_sort_key("Stuart Bishop", " stub42!!!"),
1121- "stuart bishop, stub42!!!"
1122- )
1123-
1124- # Everything except for letters and whitespace is stripped.
1125- self.failUnlessEqual(
1126- self.person_sort_key("-= Mass1v3 T0SSA =-", "tossa"),
1127- "massv tssa, tossa"
1128- )
1129-
1130- # Non ASCII letters are currently allowed. Eventually they should
1131- # become transliterated to ASCII but we don't do this yet.
1132- # Note that as we are testing a PostgreSQL stored procedure, we
1133- # should pass it UTF-8 encoded strings to match our database encoding.
1134- self.failUnlessEqual(
1135- self.person_sort_key(
1136- u"Bj\N{LATIN SMALL LETTER O WITH DIAERESIS}rn".encode(
1137- "UTF-8"), "bjorn"),
1138- u"bj\xf6rn, bjorn"
1139- )
1140-
1141- # Case conversion is handled correctly using Unicode
1142- self.failUnlessEqual(
1143- self.person_sort_key(
1144- u"Bj\N{LATIN CAPITAL LETTER O WITH DIAERESIS}rn".encode(
1145- "UTF-8"), "bjorn"),
1146- u"bj\xf6rn, bjorn" # Lower case o with diaeresis
1147- )
1148-
1149-
1150-def test_suite():
1151- return unittest.TestLoader().loadTestsFromName(__name__)
1152+ def assertSortKeysEqual(self, displayname, name, expected):
1153+ # The sort key from the database matches the expected sort key.
1154+ self.assertEqual(
1155+ expected, self.get_person_sort_key(
1156+ displayname, name))
1157+
1158+
1159+class PersonNames:
1160+ """A fake with enough information for `person_sort_key`."""
1161+
1162+ def __init__(self, displayname, name):
1163+ self.displayname = displayname
1164+ self.name = name
1165+
1166+
1167+class TestPersonSortKeyInProcess(TestPersonSortKeyBase, TestCase):
1168+
1169+ def assertSortKeysEqual(self, displayname, name, expected):
1170+ # The sort key calculated in-process matches the expected sort key.
1171+ self.assertEqual(
1172+ expected, person_sort_key(
1173+ PersonNames(displayname, name)))