~launchpad-pqm/launchpad/devel

« back to all changes in this revision

Viewing changes to lib/lp/registry/vocabularies.py

[r=stevenk][bug=720239] Ensure person picker results are ordered
        according to the relevance of the matches to the search term.

Show diffs side-by-side

added added

removed removed

Lines of Context:
75
75
    Or,
76
76
    Select,
77
77
    SQL,
 
78
    Union,
 
79
    With,
78
80
    )
79
81
from storm.info import ClassAlias
80
82
from zope.component import getUtility
169
171
from lp.registry.model.karma import KarmaCategory
170
172
from lp.registry.model.mailinglist import MailingList
171
173
from lp.registry.model.milestone import Milestone
172
 
from lp.registry.model.person import Person, IrcID
 
174
from lp.registry.model.person import (
 
175
    IrcID,
 
176
    Person,
 
177
    )
173
178
from lp.registry.model.pillar import PillarName
174
179
from lp.registry.model.product import Product
175
180
from lp.registry.model.productrelease import ProductRelease
179
184
from lp.registry.model.teammembership import TeamParticipation
180
185
from lp.services.database import bulk
181
186
from lp.services.features import getFeatureFlag
182
 
from lp.services.propertycache import cachedproperty
 
187
from lp.services.propertycache import (
 
188
    cachedproperty,
 
189
    get_property_cache
 
190
    )
183
191
 
184
192
 
185
193
class BasePersonVocabulary:
517
525
 
518
526
    def _doSearch(self, text=""):
519
527
        """Return the people/teams whose fti or email address match :text:"""
 
528
        if self.enhanced_picker_enabled:
 
529
            return self._doSearchWithImprovedSorting(text)
 
530
        else:
 
531
            return self._doSearchWithOriginalSorting(text)
520
532
 
 
533
    def _doSearchWithOriginalSorting(self, text=""):
521
534
        private_query, private_tables = self._privateTeamQueryAndTables()
522
535
        exact_match = None
523
536
 
656
669
        else:
657
670
            result.order_by(Person.displayname, Person.name)
658
671
        result.config(limit=self.LIMIT)
659
 
 
660
 
        # We will be displaying the person's irc nic(s) in the description
661
 
        # so we need to bulk load them in one query for performance.
 
672
        return result
 
673
 
 
674
    def _doSearchWithImprovedSorting(self, text=""):
 
675
        """Return the people/teams whose fti or email address match :text:"""
 
676
 
 
677
        private_query, private_tables = self._privateTeamQueryAndTables()
 
678
 
 
679
        # Short circuit if there is no search text - all valid people and
 
680
        # teams have been requested.
 
681
        if not text:
 
682
            tables = [
 
683
                Person,
 
684
                Join(self.cache_table_name,
 
685
                     SQL("%s.id = Person.id" % self.cache_table_name)),
 
686
                ]
 
687
            tables.extend(private_tables)
 
688
            result = self.store.using(*tables).find(
 
689
                Person,
 
690
                And(
 
691
                    Or(Person.visibility == PersonVisibility.PUBLIC,
 
692
                       private_query,
 
693
                       ),
 
694
                    Person.merged == None,
 
695
                    self.extra_clause
 
696
                    )
 
697
                )
 
698
            result.config(distinct=True)
 
699
            result.order_by(Person.displayname, Person.name)
 
700
        else:
 
701
            # Do a full search based on the text given.
 
702
 
 
703
            # The queries are broken up into several steps for efficiency.
 
704
            # The public person and team searches do not need to join with the
 
705
            # TeamParticipation table, which is very expensive.  The search
 
706
            # for private teams does need that table but the number of private
 
707
            # teams is very small so the cost is not great. However, if the
 
708
            # person is a logged in administrator, we don't need to join to
 
709
            # the TeamParticipation table and can construct a more efficient
 
710
            # query (since in this case we are searching all private teams).
 
711
 
 
712
            # Create a query that will match public persons and teams that
 
713
            # have the search text in the fti, at the start of their email
 
714
            # address, as their full IRC nickname, or at the start of their
 
715
            # displayname.
 
716
            # Since we may be eliminating results with the limit to improve
 
717
            # performance, we sort by the rank, so that we will always get
 
718
            # the best results. The fti rank will be between 0 and 1.
 
719
            # Note we use lower() instead of the non-standard ILIKE because
 
720
            # ILIKE doesn't hit the indexes.
 
721
            # The '%%' is necessary because storm variable substitution
 
722
            # converts it to '%'.
 
723
 
 
724
            # This is the SQL that will give us the IDs of the people we want
 
725
            # in the result.
 
726
            matching_person_sql = SQL("""
 
727
                SELECT id, MAX(rank) AS rank, false as is_private_team
 
728
                FROM (
 
729
                    SELECT Person.id,
 
730
                    (case
 
731
                        when person.name=? then 100
 
732
                        when lower(person.name) like ? || '%%' then 75
 
733
                        when lower(person.displayname) like ? || '%%' then 50
 
734
                        else rank(fti, ftq(?))
 
735
                    end) as rank
 
736
                    FROM Person
 
737
                    WHERE lower(Person.name) LIKE ? || '%%'
 
738
                    or lower(Person.displayname) LIKE ? || '%%'
 
739
                    or Person.fti @@ ftq(?)
 
740
                    UNION ALL
 
741
                    SELECT Person.id, 25 AS rank
 
742
                    FROM Person, IrcID
 
743
                    WHERE Person.id = IrcID.person
 
744
                        AND IrcID.nickname = ?
 
745
                    UNION ALL
 
746
                    SELECT Person.id, 10 AS rank
 
747
                    FROM Person, EmailAddress
 
748
                    WHERE Person.id = EmailAddress.person
 
749
                        AND LOWER(EmailAddress.email) LIKE ? || '%%'
 
750
                        AND status IN (?, ?)
 
751
                ) AS person_match
 
752
                GROUP BY id, is_private_team
 
753
            """, (text, text, text, text, text, text, text, text, text,
 
754
                  EmailAddressStatus.VALIDATED.value,
 
755
                  EmailAddressStatus.PREFERRED.value))
 
756
 
 
757
            # Do we need to search for private teams.
 
758
            if private_tables:
 
759
                private_tables = [Person] + private_tables
 
760
                private_ranking_sql = SQL("""
 
761
                    (case
 
762
                        when person.name=? then 100
 
763
                        when lower(person.name) like ? || '%%' then 75
 
764
                        when lower(person.displayname) like ? || '%%' then 50
 
765
                        else rank(fti, ftq(?))
 
766
                    end) as rank
 
767
                """, (text, text, text, text))
 
768
 
 
769
                # Searching for private teams that match can be easier since
 
770
                # we are only interested in teams.  Teams can have email
 
771
                # addresses but we're electing to ignore them here.
 
772
                private_result_select = Select(
 
773
                    tables=private_tables,
 
774
                    columns=(Person.id, private_ranking_sql,
 
775
                                SQL("true as is_private_team")),
 
776
                    where=And(
 
777
                        SQL("""
 
778
                            lower(Person.name) LIKE ? || '%%'
 
779
                            OR lower(Person.displayname) LIKE ? || '%%'
 
780
                            OR Person.fti @@ ftq(?)
 
781
                            """, [text, text, text]),
 
782
                        private_query))
 
783
                matching_person_sql = Union(matching_person_sql,
 
784
                          private_result_select, all=True)
 
785
 
 
786
            # The tables for public persons and teams that match the text.
 
787
            public_tables = [
 
788
                SQL("MatchingPerson"),
 
789
                Person,
 
790
                LeftJoin(EmailAddress, EmailAddress.person == Person.id),
 
791
                ]
 
792
 
 
793
            # If private_tables is empty, we are searching for all private
 
794
            # teams. We can simply append the private query component to the
 
795
            # public query. Otherwise, for efficiency as stated earlier, we
 
796
            # need to do a separate query to join to the TeamParticipation
 
797
            # table.
 
798
            private_teams_query = private_query
 
799
            if private_tables:
 
800
                private_teams_query = SQL("is_private_team")
 
801
 
 
802
            # We just select the required ids since we will use
 
803
            # IPersonSet.getPrecachedPersonsFromIDs to load the results
 
804
            matching_with = With("MatchingPerson", matching_person_sql)
 
805
            result = self.store.with_(
 
806
                matching_with).using(*public_tables).find(
 
807
                Person,
 
808
                And(
 
809
                    SQL("Person.id = MatchingPerson.id"),
 
810
                    Or(
 
811
                        And(# A public person or team
 
812
                            Person.visibility == PersonVisibility.PUBLIC,
 
813
                            Person.merged == None,
 
814
                            Or(# A valid person-or-team is either a team...
 
815
                                # Note: 'Not' due to Bug 244768.
 
816
                                Not(Person.teamowner == None),
 
817
                                # Or a person who has preferred email address.
 
818
                                EmailAddress.status ==
 
819
                                    EmailAddressStatus.PREFERRED)),
 
820
                        # Or a private team
 
821
                        private_teams_query),
 
822
                    self.extra_clause),
 
823
                )
 
824
            # Better ranked matches go first.
 
825
            result.order_by(
 
826
                SQL("rank desc"), Person.displayname, Person.name)
 
827
        result.config(limit=self.LIMIT)
 
828
 
 
829
        # We will be displaying the person's irc nick(s) and emails in the
 
830
        # description so we need to bulk load them for performance, otherwise
 
831
        # we get one query per person per attribute.
662
832
        def pre_iter_hook(rows):
663
833
            persons = set(obj for obj in rows)
664
 
            bulk.load_referencing(IrcID, persons, ['personID'])
 
834
            # The emails.
 
835
            emails = bulk.load_referencing(
 
836
                EmailAddress, persons, ['personID'])
 
837
            email_by_person = dict((email.personID, email)
 
838
                for email in emails
 
839
                if email.status == EmailAddressStatus.PREFERRED)
 
840
 
 
841
            # The irc nicks.
 
842
            nicks = bulk.load_referencing(IrcID, persons, ['personID'])
 
843
            nicks_by_person = dict((nick.personID, nicks)
 
844
                for nick in nicks)
 
845
 
 
846
            for person in persons:
 
847
                cache = get_property_cache(person)
 
848
                cache.preferredemail = email_by_person.get(person.id, None)
 
849
                cache.ircnicknames = nicks_by_person.get(person.id, None)
665
850
 
666
851
        return DecoratedResultSet(result, pre_iter_hook=pre_iter_hook)
667
852
 
714
899
                        self.extra_clause)
715
900
            result = self.store.using(*tables).find(Person, query)
716
901
        else:
717
 
            name_match_query = SQL("Person.fti @@ ftq(%s)" % quote(text))
 
902
            if self.enhanced_picker_enabled:
 
903
                name_match_query = SQL("""
 
904
                    lower(Person.name) LIKE ? || '%%'
 
905
                    OR lower(Person.displayname) LIKE ? || '%%'
 
906
                    OR Person.fti @@ ftq(?)
 
907
                    """, [text, text, text]),
 
908
            else:
 
909
                name_match_query = SQL("Person.fti @@ ftq(%s)" % quote(text))
718
910
 
719
911
            email_storm_query = self.store.find(
720
912
                EmailAddress.personID,
732
924
                    Or(name_match_query,
733
925
                       EmailAddress.person != None)))
734
926
 
735
 
        # XXX: BradCrittenden 2009-05-07 bug=373228: A bug in Storm prevents
736
 
        # setting the 'distinct' and 'limit' options in a single call to
737
 
        # .config().  The work-around is to split them up.  Note the limit has
738
 
        # to be after the call to 'order_by' for this work-around to be
739
 
        # effective.
 
927
        # To get the correct results we need to do distinct first, then order
 
928
        # by, then limit.
740
929
        result.config(distinct=True)
741
930
        result.order_by(Person.displayname, Person.name)
742
931
        result.config(limit=self.LIMIT)