Merge lp:~wgrant/launchpad/bug-1075767 into lp:launchpad

Proposed by William Grant
Status: Merged
Approved by: Steve Kowalik
Approved revision: no longer in the source branch.
Merged at revision: 16320
Proposed branch: lp:~wgrant/launchpad/bug-1075767
Merge into: lp:launchpad
Diff against target: 426 lines (+62/-217)
2 files modified
lib/lp/registry/model/person.py (+31/-39)
lib/lp/registry/vocabularies.py (+31/-178)
To merge this branch: bzr merge lp:~wgrant/launchpad/bug-1075767
Reviewer Review Type Date Requested Status
Steve Kowalik (community) code Approve
Review via email: mp+136572@code.launchpad.net

Commit message

Optimise PersonSet person visibility checks, and simplify person vocab queries.

Description of the change

This branch fixes and cleans various bits and pieces around person search, particularly with private teams.

I went in to fix bug #1075767 by changing PersonSet's search access checks to use Person.id IN ([... subquery for user's participated teams]), which is hashable and much faster than the existing TeamParticipation join. This was a resounding success. But then I opted to see if the benefits would extend to ValidPersonOrTeamVocabulary, and so the trouble started.

ValidPersonOrTeamVocabulary and its derivatives used an alternate technique to avoid the same problem: find public and private objects in separate queries, and union them together. This ends up pretty hideous, and slightly slower than the much cleaner and shorter technique I used in PersonSet. So I went about reworking the vocabs to use the new method, and even share the privacy filter code with PersonSet.

In doing this I discovered that PersonSet searches didn't respect the admins-and-commercial-admins-take-all rule, so I implemented that in what is now the single implementation of person privacy querying. (Commercial) admins should now be able to find private teams on /people even if they're not a member.

With the vocabs' hideousness reduced, the branch ended up around -150.

To post a comment you must log in.
Revision history for this message
Steve Kowalik (stevenk) :
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/registry/model/person.py'
2--- lib/lp/registry/model/person.py 2012-11-26 08:33:03 +0000
3+++ lib/lp/registry/model/person.py 2012-11-28 06:36:19 +0000
4@@ -6,6 +6,7 @@
5 __metaclass__ = type
6 __all__ = [
7 'AlreadyConvertedException',
8+ 'get_person_visibility_terms',
9 'get_recipients',
10 'generate_nick',
11 'IrcID',
12@@ -394,6 +395,32 @@
13 return value
14
15
16+def get_person_visibility_terms(user):
17+ """Generate the query needed for person privacy filtering."""
18+ public_filter = (Person.visibility == PersonVisibility.PUBLIC)
19+
20+ # Anonymous users can only see public people.
21+ if user is None:
22+ return public_filter
23+
24+ # Admins and commercial admins can see everyone.
25+ roles = IPersonRoles(user)
26+ if roles.in_admin or roles.in_commercial_admin:
27+ return True
28+
29+ # Otherwise only public people and private teams of which the user
30+ # is a member are visible.
31+ return Or(
32+ public_filter,
33+ And(
34+ Person.id.is_in(
35+ Select(
36+ TeamParticipation.teamID, tables=[TeamParticipation],
37+ where=(TeamParticipation.person == user))),
38+ Person.teamowner != None,
39+ Person.visibility != PersonVisibility.PUBLIC))
40+
41+
42 _person_sort_re = re.compile("(?:[^\w\s]|[\d_])", re.U)
43
44
45@@ -3535,42 +3562,11 @@
46 """See `IPersonSet`."""
47 return getUtility(ILaunchpadStatisticSet).value('teams_count')
48
49- def _teamPrivacyQuery(self):
50- """Generate the query needed for privacy filtering.
51-
52- If the visibility is not PUBLIC ensure the logged in user is a member
53- of the team.
54- """
55- logged_in_user = getUtility(ILaunchBag).user
56- if logged_in_user is not None:
57- private_query = SQL("""
58- TeamParticipation.person = ?
59- AND Person.teamowner IS NOT NULL
60- AND Person.visibility != ?
61- """, (logged_in_user.id, PersonVisibility.PUBLIC.value))
62- else:
63- private_query = None
64-
65- base_query = SQL("Person.visibility = ?",
66- (PersonVisibility.PUBLIC.value, ),
67- tables=['Person'])
68-
69- if private_query is None:
70- query = base_query
71- else:
72- query = Or(base_query, private_query)
73-
74- return query
75-
76 def _teamEmailQuery(self, text):
77 """Product the query for team email addresses."""
78- privacy_query = self._teamPrivacyQuery()
79- # XXX: BradCrittenden 2009-06-08 bug=244768: Use Not(Bar.foo == None)
80- # instead of Bar.foo != None.
81 team_email_query = And(
82- privacy_query,
83- TeamParticipation.team == Person.id,
84- Not(Person.teamowner == None),
85+ get_person_visibility_terms(getUtility(ILaunchBag).user),
86+ Person.teamowner != None,
87 Person.merged == None,
88 EmailAddress.person == Person.id,
89 EmailAddress.email.lower().startswith(ensure_unicode(text)))
90@@ -3578,13 +3574,9 @@
91
92 def _teamNameQuery(self, text):
93 """Produce the query for team names."""
94- privacy_query = self._teamPrivacyQuery()
95- # XXX: BradCrittenden 2009-06-08 bug=244768: Use Not(Bar.foo == None)
96- # instead of Bar.foo != None.
97 team_name_query = And(
98- privacy_query,
99- TeamParticipation.team == Person.id,
100- Not(Person.teamowner == None),
101+ get_person_visibility_terms(getUtility(ILaunchBag).user),
102+ Person.teamowner != None,
103 Person.merged == None,
104 SQL("Person.fti @@ ftq(?)", (text, )))
105 return team_name_query
106
107=== modified file 'lib/lp/registry/vocabularies.py'
108--- lib/lp/registry/vocabularies.py 2012-11-24 17:49:30 +0000
109+++ lib/lp/registry/vocabularies.py 2012-11-28 06:36:19 +0000
110@@ -69,12 +69,10 @@
111 OR,
112 )
113 from storm.expr import (
114- Alias,
115 And,
116 Desc,
117 Join,
118 LeftJoin,
119- Not,
120 Or,
121 Select,
122 SQL,
123@@ -133,7 +131,6 @@
124 )
125 from lp.registry.interfaces.productseries import IProductSeries
126 from lp.registry.interfaces.projectgroup import IProjectGroup
127-from lp.registry.interfaces.role import IPersonRoles
128 from lp.registry.interfaces.sourcepackage import ISourcePackage
129 from lp.registry.model.distribution import Distribution
130 from lp.registry.model.distroseries import DistroSeries
131@@ -144,6 +141,7 @@
132 from lp.registry.model.mailinglist import MailingList
133 from lp.registry.model.milestone import Milestone
134 from lp.registry.model.person import (
135+ get_person_visibility_terms,
136 IrcID,
137 Person,
138 )
139@@ -567,37 +565,8 @@
140 return None
141 return '%s = %d' % (karma_context_column, context.id)
142
143- def _privateTeamQueryAndTables(self):
144- """Return query tables for private teams.
145-
146- The teams are based on membership by the user.
147- Returns a tuple of (query, tables).
148- """
149- tables = []
150- logged_in_user = getUtility(ILaunchBag).user
151- if logged_in_user is not None:
152- roles = IPersonRoles(logged_in_user)
153- if roles.in_admin or roles.in_commercial_admin:
154- # If the user is a LP admin or commercial admin we allow
155- # all private teams to be visible.
156- private_query = AND(
157- Not(Person.teamowner == None),
158- Person.visibility == PersonVisibility.PRIVATE)
159- else:
160- private_query = AND(
161- TeamParticipation.person == logged_in_user.id,
162- Not(Person.teamowner == None),
163- Person.visibility == PersonVisibility.PRIVATE)
164- tables = [Join(TeamParticipation,
165- TeamParticipation.teamID == Person.id)]
166- else:
167- private_query = False
168- return (private_query, tables)
169-
170 def _doSearch(self, text="", vocab_filter=None):
171 """Return the people/teams whose fti or email address match :text:"""
172-
173- private_query, private_tables = self._privateTeamQueryAndTables()
174 extra_clauses = [self.extra_clause]
175 if vocab_filter:
176 extra_clauses.extend(vocab_filter.filter_terms)
177@@ -610,33 +579,17 @@
178 Join(self.cache_table_name,
179 SQL("%s.id = Person.id" % self.cache_table_name)),
180 ]
181- tables.extend(private_tables)
182 tables.extend(self.extra_tables)
183 result = self.store.using(*tables).find(
184 Person,
185- And(
186- Or(Person.visibility == PersonVisibility.PUBLIC,
187- private_query,
188- ),
189- Person.merged == None,
190- *extra_clauses
191- )
192- )
193- result.config(distinct=True)
194+ get_person_visibility_terms(getUtility(ILaunchBag).user),
195+ Person.merged == None,
196+ *extra_clauses)
197 result.order_by(Person.displayname, Person.name)
198 else:
199 # Do a full search based on the text given.
200
201- # The queries are broken up into several steps for efficiency.
202- # The public person and team searches do not need to join with the
203- # TeamParticipation table, which is very expensive. The search
204- # for private teams does need that table but the number of private
205- # teams is very small so the cost is not great. However, if the
206- # person is a logged in administrator, we don't need to join to
207- # the TeamParticipation table and can construct a more efficient
208- # query (since in this case we are searching all private teams).
209-
210- # Create a query that will match public persons and teams that
211+ # Create a query that will match persons and teams that
212 # have the search text in the fti, at the start of their email
213 # address, as their full IRC nickname, or at the start of their
214 # displayname.
215@@ -651,7 +604,7 @@
216 # This is the SQL that will give us the IDs of the people we want
217 # in the result.
218 matching_person_sql = SQL("""
219- SELECT id, MAX(rank) AS rank, false as is_private_team
220+ SELECT id, MAX(rank) AS rank
221 FROM (
222 SELECT Person.id,
223 (case
224@@ -677,84 +630,39 @@
225 AND LOWER(EmailAddress.email) LIKE lower(?) || '%%'
226 AND status IN (?, ?)
227 ) AS person_match
228- GROUP BY id, is_private_team
229+ GROUP BY id
230 """, (text, text, text, text, text, text, text, text, text,
231 EmailAddressStatus.VALIDATED.value,
232 EmailAddressStatus.PREFERRED.value))
233
234- # Do we need to search for private teams.
235- if private_tables:
236- private_tables = [Person] + private_tables
237- private_ranking_sql = SQL("""
238- (case
239- when person.name=lower(?) then 100
240- when person.name like lower(?) || '%%' then 0.6
241- when lower(person.displayname) like lower(?)
242- || '%%' then 0.5
243- else rank(fti, ftq(?))
244- end) as rank
245- """, (text, text, text, text))
246-
247- # Searching for private teams that match can be easier since
248- # we are only interested in teams. Teams can have email
249- # addresses but we're electing to ignore them here.
250- private_result_select = Select(
251- tables=private_tables,
252- columns=(Person.id, private_ranking_sql,
253- SQL("true as is_private_team")),
254- where=And(
255- SQL("""
256- Person.name LIKE lower(?) || '%%'
257- OR lower(Person.displayname) LIKE lower(?) || '%%'
258- OR Person.fti @@ ftq(?)
259- """, [text, text, text]),
260- private_query))
261- matching_person_sql = Union(matching_person_sql,
262- private_result_select, all=True)
263-
264- # The tables for public persons and teams that match the text.
265- public_tables = [
266+ # The tables for persons and teams that match the text.
267+ tables = [
268 SQL("MatchingPerson"),
269 Person,
270 LeftJoin(EmailAddress, EmailAddress.person == Person.id),
271 LeftJoin(Account, Account.id == Person.accountID),
272 ]
273- public_tables.extend(self.extra_tables)
274-
275- # If private_tables is empty, we are searching for all private
276- # teams. We can simply append the private query component to the
277- # public query. Otherwise, for efficiency as stated earlier, we
278- # need to do a separate query to join to the TeamParticipation
279- # table.
280- private_teams_query = private_query
281- if private_tables:
282- private_teams_query = SQL("is_private_team")
283-
284+ tables.extend(self.extra_tables)
285+
286+ # Find all the matching unmerged persons and teams. Persons
287+ # must additionally have an active account and a preferred
288+ # email address.
289 # We just select the required ids since we will use
290 # IPersonSet.getPrecachedPersonsFromIDs to load the results
291 matching_with = With("MatchingPerson", matching_person_sql)
292 result = self.store.with_(
293- matching_with).using(*public_tables).find(
294+ matching_with).using(*tables).find(
295 Person,
296- And(
297- SQL("Person.id = MatchingPerson.id"),
298- Or(
299+ SQL("Person.id = MatchingPerson.id"),
300+ Person.merged == None,
301+ Or(
302+ And(
303 Account.status == AccountStatus.ACTIVE,
304- Person.teamowner != None),
305- Or(
306- And( # A public person or team
307- Person.visibility == PersonVisibility.PUBLIC,
308- Person.merged == None,
309- Or( # A valid person-or-team is either a team...
310- # Note: 'Not' due to Bug 244768.
311- Not(Person.teamowner == None),
312- # Or a person who has preferred email address.
313- EmailAddress.status ==
314- EmailAddressStatus.PREFERRED)),
315- # Or a private team
316- private_teams_query),
317- *extra_clauses),
318- )
319+ EmailAddress.status ==
320+ EmailAddressStatus.PREFERRED),
321+ Person.teamowner != None),
322+ get_person_visibility_terms(getUtility(ILaunchBag).user),
323+ *extra_clauses)
324 # Better ranked matches go first.
325 if self._karma_context_constraint:
326 rank_order = SQL("""
327@@ -772,13 +680,10 @@
328 # We will be displaying the person's irc nick(s) and emails in the
329 # description so we need to bulk load them for performance, otherwise
330 # we get one query per person per attribute.
331- def pre_iter_hook(rows):
332- persons = set(obj for obj in rows)
333- # The emails.
334- emails = bulk.load_referencing(
335- EmailAddress, persons, ['personID'])
336- email_by_person = dict((email.personID, email)
337- for email in emails
338+ def pre_iter_hook(persons):
339+ emails = bulk.load_referencing(EmailAddress, persons, ['personID'])
340+ email_by_person = dict(
341+ (email.personID, email) for email in emails
342 if email.status == EmailAddressStatus.PREFERRED)
343
344 for person in persons:
345@@ -786,9 +691,7 @@
346 cache.preferredemail = email_by_person.get(person.id, None)
347 cache.ircnicknames = []
348
349- # The irc nicks.
350- nicks = bulk.load_referencing(IrcID, persons, ['personID'])
351- for nick in nicks:
352+ for nick in bulk.load_referencing(IrcID, persons, ['personID']):
353 get_property_cache(nick.person).ircnicknames.append(nick)
354
355 return DecoratedResultSet(result, pre_iter_hook=pre_iter_hook)
356@@ -822,60 +725,10 @@
357 # Because the base class does almost everything we need, we just need to
358 # restrict the search results to those Persons who have a non-NULL
359 # teamowner, i.e. a valid team.
360- extra_clause = Not(Person.teamowner == None)
361+ extra_clause = Person.teamowner != None
362 # Search with empty string returns all teams.
363 allow_null_search = True
364
365- def _doSearch(self, text="", vocab_filter=None):
366- """Return the teams whose fti, IRC, or email address match :text:"""
367-
368- private_query, private_tables = self._privateTeamQueryAndTables()
369- base_query = And(
370- Or(
371- Person.visibility == PersonVisibility.PUBLIC,
372- private_query,
373- ),
374- Person.merged == None
375- )
376-
377- tables = [Person] + private_tables + list(self.extra_tables)
378-
379- if not text:
380- query = And(base_query,
381- Person.merged == None,
382- self.extra_clause)
383- result = self.store.using(*tables).find(Person, query)
384- else:
385- name_match_query = SQL("""
386- Person.name LIKE lower(?) || '%%'
387- OR lower(Person.displayname) LIKE lower(?) || '%%'
388- OR Person.fti @@ ftq(?)
389- """, [text, text, text]),
390-
391- email_storm_query = self.store.find(
392- EmailAddress.personID,
393- EmailAddress.status.is_in(VALID_EMAIL_STATUSES),
394- EmailAddress.email.lower().startswith(text))
395- email_subquery = Alias(email_storm_query._get_select(),
396- 'EmailAddress')
397- tables += [
398- LeftJoin(email_subquery, EmailAddress.person == Person.id),
399- ]
400-
401- result = self.store.using(*tables).find(
402- Person,
403- And(base_query,
404- self.extra_clause,
405- Or(name_match_query,
406- EmailAddress.person != None)))
407-
408- # To get the correct results we need to do distinct first, then order
409- # by, then limit.
410- result.config(distinct=True)
411- result.order_by(Person.displayname, Person.name)
412- result.config(limit=self.LIMIT)
413- return result
414-
415 def supportedFilters(self):
416 return []
417
418@@ -885,7 +738,7 @@
419 displayname = 'Select a Person'
420 # The extra_clause for a valid person is that it not be a team, so
421 # teamowner IS NULL.
422- extra_clause = 'Person.teamowner IS NULL'
423+ extra_clause = Person.teamowner == None
424 # Search with empty string returns all valid people.
425 allow_null_search = True
426 # Cache table to use for checking validity.