← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wallyworld/launchpad/filter-person-picker-3938 into lp:launchpad

 

Ian Booth has proposed merging lp:~wallyworld/launchpad/filter-person-picker-3938 into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #3938 in Launchpad itself: "Not possible to search only for teams in the picker"
  https://bugs.launchpad.net/launchpad/+bug/3938

For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/filter-person-picker-3938/+merge/72420

Apply the recently added vocab filtering infrastructure to the person picker to allow filtering on person/team.

== Implementation ==

The core changes are straightforward - create the new filters and implement the supportedFilters() method on the relevant vocabs. Other changes were also required:
- the vocab search() method signature needed to be modified to take the vocab_filter parameter; hence also changes to FilteredVocabularyBase
- add filtering support to InlineEditorPickerWidget
- add filtering support to bugtask assignee field since this widget is constructed manaually

== Demo / QA ==

Use the assignee picker to change a bug task assignee
Use the approver picker to change a bugtask approver
Change a branch reviewer

== Tests ==

Add tests for the core filtering behaviour on the person vocab, plus widget setup.

bin/test -vvc -t test_vocabulary -t test_popup -t test_inlineeditpickerwidget -t test_person_vocabularies -t vocabularies
bin/test --layer=YUI

== Lint ==

Linting changed files:
  lib/canonical/launchpad/vocabularies/dbobjects.py
  lib/canonical/launchpad/webapp/vocabulary.py
  lib/canonical/launchpad/webapp/tests/test_vocabulary.py
  lib/lp/app/browser/lazrjs.py
  lib/lp/app/browser/tests/test_inlineeditpickerwidget.py
  lib/lp/app/javascript/picker/picker_patcher.js
  lib/lp/app/javascript/picker/tests/test_picker_patcher.js
  lib/lp/app/widgets/popup.py
  lib/lp/app/widgets/tests/test_popup.py
  lib/lp/blueprints/vocabularies/specificationdependency.py
  lib/lp/bugs/browser/bugtask.py
  lib/lp/bugs/javascript/bugtask_index.js
  lib/lp/registry/vocabularies.py
  lib/lp/registry/vocabularies.zcml
  lib/lp/registry/tests/test_person_vocabularies.py
-- 
https://code.launchpad.net/~wallyworld/launchpad/filter-person-picker-3938/+merge/72420
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wallyworld/launchpad/filter-person-picker-3938 into lp:launchpad.
=== modified file 'lib/canonical/launchpad/vocabularies/dbobjects.py'
--- lib/canonical/launchpad/vocabularies/dbobjects.py	2011-08-10 10:11:16 +0000
+++ lib/canonical/launchpad/vocabularies/dbobjects.py	2011-08-22 13:16:25 +0000
@@ -162,7 +162,7 @@
             raise LookupError(token)
         return self.toTerm(result)
 
-    def search(self, query):
+    def search(self, query, vocab_filter=None):
         """Search for web bug trackers."""
         query = ensure_unicode(query).lower()
         results = IStore(self._table).find(
@@ -179,7 +179,7 @@
 
     def searchForTerms(self, query=None, vocab_filter=None):
         """See `IHugeVocabulary`."""
-        results = self.search(query)
+        results = self.search(query, vocab_filter)
         return CountableIterator(results.count(), results, self.toTerm)
 
 
@@ -436,7 +436,7 @@
         else:
             return self.toTerm(obj)
 
-    def search(self, query):
+    def search(self, query, vocab_filter=None):
         """Return a resultset of archives.
 
         This is a helper required by `SQLObjectVocabularyBase.searchForTerms`.

=== modified file 'lib/canonical/launchpad/webapp/tests/test_vocabulary.py'
--- lib/canonical/launchpad/webapp/tests/test_vocabulary.py	2011-08-20 10:53:02 +0000
+++ lib/canonical/launchpad/webapp/tests/test_vocabulary.py	2011-08-22 13:16:25 +0000
@@ -18,6 +18,11 @@
 class TestVocabulary(FilteredVocabularyBase):
     implements(IHugeVocabulary)
 
+    def search(self, query=None, vocab_filter=None):
+        assert(isinstance(vocab_filter, VocabularyFilter))
+        assert(vocab_filter.name == "ALL")
+        assert(vocab_filter.title == "All")
+
     def searchForTerms(self, query=None, vocab_filter=None):
         assert(isinstance(vocab_filter, VocabularyFilter))
         assert(vocab_filter.name == "ALL")
@@ -49,3 +54,22 @@
         vocab = TestVocabulary()
         self.assertRaises(
             ValueError, vocab.searchForTerms, vocab_filter="invalid")
+
+    def test_search_filter_parameter_as_string(self):
+        # If the vocab filter parameter is passed in as a string (name), it is
+        # correctly transformed to a VocabularyFilter instance.
+        vocab = TestVocabulary()
+        vocab.search(vocab_filter="ALL")
+
+    def test_search_filter_parameter_as_filter(self):
+        # If the vocab filter parameter is passed in as a filter instance, it
+        # is used as is.
+        vocab = TestVocabulary()
+        vocab.search(vocab_filter=FilteredVocabularyBase.ALL_FILTER)
+
+    def test_search_invalid_filter_parameter(self):
+        # If the vocab filter parameter is passed in as a string (name), and
+        # the string is not a valid filter name, an exception is raised.
+        vocab = TestVocabulary()
+        self.assertRaises(
+            ValueError, vocab.search, vocab_filter="invalid")

=== modified file 'lib/canonical/launchpad/webapp/vocabulary.py'
--- lib/canonical/launchpad/webapp/vocabulary.py	2011-08-20 10:53:02 +0000
+++ lib/canonical/launchpad/webapp/vocabulary.py	2011-08-22 13:16:25 +0000
@@ -256,8 +256,10 @@
     def __getattribute__(self, name):
         func = object.__getattribute__(self, name)
         if (safe_hasattr(func, '__call__')
-                and func.__name__ == 'searchForTerms'):
-            def searchForTerms(
+                and (
+                    func.__name__ == 'searchForTerms'
+                    or func.__name__ == 'search')):
+            def do_search(
                     query=None, vocab_filter=None, *args, **kwargs):
                 if isinstance(vocab_filter, basestring):
                     for filter in self.supportedFilters():
@@ -268,7 +270,7 @@
                         raise ValueError(
                             "Invalid vocab filter value: %s" % vocab_filter)
                 return func(query, vocab_filter, *args, **kwargs)
-            return searchForTerms
+            return do_search
         else:
             return func
 
@@ -309,7 +311,7 @@
         results = self.search(query)
         return CountableIterator(results.count(), results, self.toTerm)
 
-    def search(self):
+    def search(self, query, vocab_filter=None):
         # This default implementation of searchForTerms glues together
         # the legacy API of search() with the toTerm method. If you
         # don't reimplement searchForTerms you will need to at least
@@ -422,7 +424,7 @@
             raise LookupError(token)
         return self.toTerm(objs[0])
 
-    def search(self, query):
+    def search(self, query, vocab_filter=None):
         """Return terms where query is a subtring of the name."""
         if query:
             clause = CONTAINSSTRING(self._table.q.name, ensure_unicode(query))
@@ -449,7 +451,7 @@
         if self.displayname is None:
             self.displayname = 'Select %s' % self.__class__.__name__
 
-    def search(self, query):
+    def search(self, query, vocab_filter=None):
         # XXX kiko 2007-01-17: this is a transitional shim; we're going to
         # get rid of search() altogether, but for now we've only done it for
         # the NamedSQLObjectHugeVocabulary.

=== modified file 'lib/lp/app/browser/lazrjs.py'
--- lib/lp/app/browser/lazrjs.py	2011-07-27 05:05:46 +0000
+++ lib/lp/app/browser/lazrjs.py	2011-08-22 13:16:25 +0000
@@ -306,7 +306,8 @@
             selected_value=self.selected_value,
             selected_value_metadata=self.selected_value_metadata,
             null_display_value=self.null_display_value,
-            show_search_box=self.show_search_box)
+            show_search_box=self.show_search_box,
+            vocabulary_filters=self.vocabulary_filters)
 
     @property
     def json_config(self):
@@ -318,6 +319,27 @@
         return registry.get(
             IVocabulary, self.exported_field.vocabularyName)
 
+    @cachedproperty
+    def vocabulary_filters(self):
+        # Only IHugeVocabulary's have filters.
+        if not IHugeVocabulary.providedBy(self.vocabulary):
+            return []
+        supported_filters = self.vocabulary.supportedFilters()
+        # If we have no filters or just the ALL filter, then no filtering
+        # support is required.
+        filters = []
+        if (len(supported_filters) == 0 or
+           (len(supported_filters) == 1
+            and supported_filters[0].name == 'ALL')):
+            return filters
+        for filter in supported_filters:
+            filters.append({
+                'name': filter.name,
+                'title': filter.title,
+                'description': filter.description,
+                })
+        return filters
+
     @property
     def show_search_box(self):
         return IHugeVocabulary.providedBy(self.vocabulary)

=== modified file 'lib/lp/app/browser/tests/test_inlineeditpickerwidget.py'
--- lib/lp/app/browser/tests/test_inlineeditpickerwidget.py	2011-07-27 05:05:46 +0000
+++ lib/lp/app/browser/tests/test_inlineeditpickerwidget.py	2011-08-22 13:16:25 +0000
@@ -39,6 +39,25 @@
         widget = self.getWidget(vocabulary='ValidPersonOrTeam')
         self.assertTrue(widget.config['show_search_box'])
 
+    def test_vocabulary_filters(self):
+        # Make sure that when given a vocabulary which supports vocab filters,
+        # the vocab filters are include in the widget config.
+        widget = self.getWidget(vocabulary='ValidPersonOrTeam')
+        self.assertEquals([
+            {'name': 'ALL',
+             'title': 'All',
+             'description': 'Display all search results'},
+            {'name': 'PERSON',
+             'title': 'Person',
+             'description':
+                 'Display search results for people only'},
+            {'name': 'TEAM',
+             'title': 'Team',
+             'description':
+                 'Display search results for teams only'}
+            ],
+            widget.config['vocabulary_filters'])
+
     def test_normal_vocabulary_is_not_searchable(self):
         # Make sure that when given a field for a normal vocabulary, the
         # picker is set to show the search box.

=== modified file 'lib/lp/app/javascript/picker/picker_patcher.js'
--- lib/lp/app/javascript/picker/picker_patcher.js	2011-08-19 03:00:28 +0000
+++ lib/lp/app/javascript/picker/picker_patcher.js	2011-08-22 13:16:25 +0000
@@ -37,6 +37,7 @@
 
     var null_display_value = 'None';
     var show_search_box = true;
+    var vocabulary_filters;
 
     resource_uri = Y.lp.client.normalize_uri(resource_uri);
 
@@ -47,6 +48,7 @@
         if (config.show_search_box !== undefined) {
             show_search_box = config.show_search_box;
         }
+        vocabulary_filters = config.vocabulary_filters;
     }
 
     var content_box = Y.one('#' + content_box_id);
@@ -97,7 +99,8 @@
     };
 
     config.save = save;
-    var picker = namespace.create(vocabulary, config);
+    var picker = namespace.create(
+        vocabulary, config, undefined, vocabulary_filters);
     picker._resource_uri = resource_uri;
 
     // If we are to pre-load the vocab, we need a spinner.

=== modified file 'lib/lp/app/javascript/picker/tests/test_picker_patcher.js'
--- lib/lp/app/javascript/picker/tests/test_picker_patcher.js	2011-08-19 03:00:28 +0000
+++ lib/lp/app/javascript/picker/tests/test_picker_patcher.js	2011-08-22 13:16:25 +0000
@@ -271,7 +271,7 @@
         cleanup_widget(this.picker);
     },
 
-    create_picker: function(show_search_box) {
+    create_picker: function(show_search_box, extra_config) {
         var config = {
             "step_title": "Choose someone",
             "header": "Pick Someone",
@@ -280,6 +280,9 @@
         if (show_search_box !== undefined) {
             config.show_search_box = show_search_box;
         }
+        if (extra_config !== undefined) {
+           config = Y.merge(extra_config, config);
+        }
         this.picker = Y.lp.app.picker.addPickerPatcher(
                 this.vocabulary,
                 "foo/bar",
@@ -336,8 +339,15 @@
         this.picker.set('min_search_chars', 0);
         this.picker.fire('search', '');
         Assert.areEqual(null, this.picker.get('error'));
+    },
+
+    test_vocab_filter_config: function () {
+        // The vocab filter config is correctly used to create the picker.
+        var filters = [{name: 'ALL', title: 'All', description: 'All'}];
+        this.create_picker(undefined,  {'vocabulary_filters': filters});
+        var filter_options = this.picker.get('filter_options');
+        Assert.areEqual(filters, filter_options);
     }
-
 }));
 
 suite.add(new Y.Test.Case({

=== modified file 'lib/lp/app/widgets/popup.py'
--- lib/lp/app/widgets/popup.py	2011-08-18 08:00:28 +0000
+++ lib/lp/app/widgets/popup.py	2011-08-22 13:16:25 +0000
@@ -17,6 +17,7 @@
 from zope.schema.interfaces import IChoice
 
 from canonical.launchpad.webapp import canonical_url
+from canonical.launchpad.webapp.vocabulary import IHugeVocabulary
 from canonical.lazr.utils import safe_hasattr
 from lp.app.browser.stringformatter import FormattersAPI
 from lp.app.browser.vocabulary import get_person_picker_entry_metadata
@@ -166,6 +167,9 @@
                 "The %r.%s interface attribute doesn't have its "
                 "vocabulary specified."
                 % (choice.context, choice.__name__))
+        # Only IHugeVocabulary's have filters.
+        if not IHugeVocabulary.providedBy(choice.vocabulary):
+            return []
         supported_filters = choice.vocabulary.supportedFilters()
         # If we have no filters or just the ALL filter, then no filtering
         # support is required.

=== modified file 'lib/lp/app/widgets/tests/test_popup.py'
--- lib/lp/app/widgets/tests/test_popup.py	2011-08-18 08:00:28 +0000
+++ lib/lp/app/widgets/tests/test_popup.py	2011-08-22 13:16:25 +0000
@@ -64,7 +64,17 @@
         widget_config = simplejson.loads(picker_widget.json_config)
         self.assertEqual(
             'ValidTeamOwner', picker_widget.vocabulary_name)
-        self.assertEqual([], picker_widget.vocabulary_filters)
+        self.assertEqual([
+            {'name': 'ALL',
+             'title': 'All',
+             'description': 'Display all search results'},
+            {'name': 'PERSON',
+             'title': 'Person',
+             'description': 'Display search results for people only'},
+            {'name': 'TEAM',
+             'title': 'Team',
+             'description': 'Display search results for teams only'}
+            ], picker_widget.vocabulary_filters)
         self.assertEqual(self.vocabulary.displayname, widget_config['header'])
         self.assertEqual(self.vocabulary.step_title,
             widget_config['step_title'])

=== modified file 'lib/lp/blueprints/vocabularies/specificationdependency.py'
--- lib/lp/blueprints/vocabularies/specificationdependency.py	2011-03-15 02:49:11 +0000
+++ lib/lp/blueprints/vocabularies/specificationdependency.py	2011-08-22 13:16:25 +0000
@@ -27,7 +27,6 @@
     NamedSQLObjectVocabulary,
     SQLObjectVocabularyBase,
     )
-from lp.blueprints.interfaces.specification import ISpecification
 from lp.blueprints.model.specification import (
     recursive_blocked_query,
     Specification,
@@ -163,7 +162,7 @@
             return self.toTerm(spec)
         raise LookupError(token)
 
-    def search(self, query):
+    def search(self, query, vocab_filter=None):
         """See `SQLObjectVocabularyBase.search`.
 
         We find specs where query is in the text of name or title, or matches

=== modified file 'lib/lp/bugs/browser/bugtask.py'
--- lib/lp/bugs/browser/bugtask.py	2011-08-15 22:24:35 +0000
+++ lib/lp/bugs/browser/bugtask.py	2011-08-22 13:16:25 +0000
@@ -271,6 +271,7 @@
 from lp.services.fields import PersonChoice
 from lp.services.propertycache import cachedproperty
 
+vocabulary_registry = getVocabularyRegistry()
 
 DISPLAY_BUG_STATUS_FOR_PATCHES = {
     BugTaskStatus.NEW: True,
@@ -1053,12 +1054,14 @@
     return '_'.join(parts)
 
 
-def get_assignee_vocabulary(context):
+def get_assignee_vocabulary_info(context):
     """The vocabulary of bug task assignees the current user can set."""
     if context.userCanSetAnyAssignee(getUtility(ILaunchBag).user):
-        return 'ValidAssignee'
+        vocab_name = 'ValidAssignee'
     else:
-        return 'AllUserTeamsParticipation'
+        vocab_name = 'AllUserTeamsParticipation'
+    vocab = vocabulary_registry.get(None, vocab_name)
+    return vocab_name, vocab.supportedFilters()
 
 
 class BugTaskBugWatchMixin:
@@ -1339,10 +1342,10 @@
             self.form_fields.get('assignee', False)):
             # Make the assignee field editable
             self.form_fields = self.form_fields.omit('assignee')
+            vocabulary, ignored = get_assignee_vocabulary_info(self.context)
             self.form_fields += formlib.form.Fields(PersonChoice(
                 __name__='assignee', title=_('Assigned to'), required=False,
-                vocabulary=get_assignee_vocabulary(self.context),
-                readonly=False))
+                vocabulary=vocabulary, readonly=False))
             self.form_fields['assignee'].custom_widget = CustomWidgetFactory(
                 BugTaskAssigneeWidget)
 
@@ -2654,7 +2657,6 @@
 
         if vocabulary is None:
             assert vocabulary_name is not None, 'No vocabulary specified.'
-            vocabulary_registry = getVocabularyRegistry()
             vocabulary = vocabulary_registry.get(
                 self.context, vocabulary_name)
         for term in vocabulary:
@@ -3586,7 +3588,21 @@
 
     def js_config(self):
         """Configuration for the JS widgets on the row, JSON-serialized."""
-        assignee_vocabulary = get_assignee_vocabulary(self.context)
+        assignee_vocabulary, assignee_vocabulary_filters = (
+            get_assignee_vocabulary_info(self.context))
+        # If we have no filters or just the ALL filter, then no filtering
+        # support is required.
+        filter_details = []
+        if (len(assignee_vocabulary_filters) > 1 or
+               (len(assignee_vocabulary_filters) == 1
+                and assignee_vocabulary_filters[0].name != 'ALL')):
+            for filter in assignee_vocabulary_filters:
+                filter_details.append({
+                    'name': filter.name,
+                    'title': filter.title,
+                    'description': filter.description,
+                    })
+
         # Display the search field only if the user can set any person
         # or team
         user = getUtility(ILaunchBag).user
@@ -3603,6 +3619,7 @@
             'assignee_is_team': self.context.assignee
                 and self.context.assignee.is_team,
             'assignee_vocabulary': assignee_vocabulary,
+            'assignee_vocabulary_filters': filter_details,
             'hide_assignee_team_selection': hide_assignee_team_selection,
             'user_can_unassign': self.context.userCanUnassign(user),
             'target_is_product': IProduct.providedBy(self.context.target),

=== modified file 'lib/lp/bugs/javascript/bugtask_index.js'
--- lib/lp/bugs/javascript/bugtask_index.js	2011-08-11 22:41:38 +0000
+++ lib/lp/bugs/javascript/bugtask_index.js	2011-08-22 13:16:25 +0000
@@ -850,6 +850,7 @@
             "assignee_link",
             assignee_content.get('id'),
             {"picker_type": "person",
+             "vocabulary_filters": conf.assignee_vocabulary_filters,
              "step_title": step_title,
              "header": "Change assignee",
              "selected_value": conf.assignee_value,

=== modified file 'lib/lp/registry/tests/test_person_vocabularies.py'
--- lib/lp/registry/tests/test_person_vocabularies.py	2011-06-21 05:52:56 +0000
+++ lib/lp/registry/tests/test_person_vocabularies.py	2011-08-22 13:16:25 +0000
@@ -12,6 +12,7 @@
 from zope.security.proxy import removeSecurityProxy
 
 from canonical.launchpad.ftests import login_person
+from canonical.launchpad.webapp.vocabulary import FilteredVocabularyBase
 from canonical.testing.layers import (
     DatabaseFunctionalLayer,
     LaunchpadZopelessLayer,
@@ -22,6 +23,7 @@
     TeamSubscriptionPolicy,
     )
 from lp.registry.interfaces.karma import IKarmaCacheManager
+from lp.registry.vocabularies import ValidPersonOrTeamVocabulary
 from lp.services.features.testing import FeatureFixture
 from lp.testing import (
     StormStatementRecorder,
@@ -48,11 +50,12 @@
     def getVocabulary(self, context):
         return self.vocabulary_registry.get(context, self.vocabulary_name)
 
-    def searchVocabulary(self, context, text):
+    def searchVocabulary(self, context, text, vocab_filter=None):
         if Store.of(context) is not None:
             Store.of(context).flush()
         vocabulary = self.getVocabulary(context)
-        return removeSecurityProxy(vocabulary)._doSearch(text)
+        removeSecurityProxy(vocabulary).allow_null_search = True
+        return removeSecurityProxy(vocabulary).search(text, vocab_filter)
 
 
 class TestValidPersonOrTeamVocabulary(VocabularyTestBase,
@@ -65,6 +68,16 @@
     layer = LaunchpadZopelessLayer
     vocabulary_name = 'ValidPersonOrTeam'
 
+    def test_supported_filters(self):
+        # The vocab supports the correct filters.
+        self.assertEqual([
+            FilteredVocabularyBase.ALL_FILTER,
+            ValidPersonOrTeamVocabulary.PERSON_FILTER,
+            ValidPersonOrTeamVocabulary.TEAM_FILTER,
+            ],
+            self.getVocabulary(None).supportedFilters()
+        )
+
     def addKarma(self, person, value, product=None, distribution=None):
         if product:
             kwargs = dict(product_id=product.id)
@@ -138,6 +151,42 @@
             self.assertContentEqual(
                 [person], self.searchVocabulary(person, irc.nickname.lower()))
 
+    def _person_filter_tests(self, person):
+        results = self.searchVocabulary(None, '', 'PERSON')
+        for personorteam in results:
+            self.assertFalse(personorteam.is_team)
+        results = self.searchVocabulary(None, u'fred', 'PERSON')
+        self.assertEqual([person], list(results))
+
+    def test_person_filter(self):
+        # Test that the person filter only returns people
+        # (with and without feature flag).
+        person = self.factory.makePerson(
+            name="fredperson", email="fredperson@xxxxxxx")
+        self.factory.makeTeam(
+            name="fredteam", email="fredteam@xxxxxxx")
+        with FeatureFixture(PERSON_AFFILIATION_RANK_FLAG):
+            self._person_filter_tests(person)
+        self._person_filter_tests(person)
+
+    def _team_filter_tests(self, team):
+        results = self.searchVocabulary(None, '', 'TEAM')
+        for personorteam in results:
+            self.assertTrue(personorteam.is_team)
+        results = self.searchVocabulary(None, u'fred', 'TEAM')
+        self.assertEqual([team], list(results))
+
+    def test_team_filter(self):
+        # Test that the team filter only returns teams.
+        # (with and without feature flag).
+        self.factory.makePerson(
+            name="fredperson", email="fredperson@xxxxxxx")
+        team = self.factory.makeTeam(
+            name="fredteam", email="fredteam@xxxxxxx")
+        with FeatureFixture(PERSON_AFFILIATION_RANK_FLAG):
+            self._team_filter_tests(team)
+        self._team_filter_tests(team)
+
 
 class TestValidPersonOrTeamPreloading(VocabularyTestBase,
                                       TestCaseWithFactory):
@@ -271,3 +320,27 @@
         owned_team = self.factory.makeTeam(owner=context_team)
         results = self.searchVocabulary(context_team, owned_team.name)
         self.assertNotIn(owned_team, results)
+
+
+class TestValidPersonVocabulary(VocabularyTestBase,
+                                      TestCaseWithFactory):
+    """Test that the ValidPersonVocabulary behaves as expected."""
+
+    layer = LaunchpadZopelessLayer
+    vocabulary_name = 'ValidPerson'
+
+    def test_supported_filters(self):
+        # The vocab shouldn't support person or team filters.
+        self.assertEqual([], self.getVocabulary(None).supportedFilters())
+
+
+class TestValidTeamVocabulary(VocabularyTestBase,
+                                      TestCaseWithFactory):
+    """Test that the ValidTeamVocabulary behaves as expected."""
+
+    layer = LaunchpadZopelessLayer
+    vocabulary_name = 'ValidTeam'
+
+    def test_supported_filters(self):
+        # The vocab shouldn't support person or team filters.
+        self.assertEqual([], self.getVocabulary(None).supportedFilters())

=== modified file 'lib/lp/registry/vocabularies.py'
--- lib/lp/registry/vocabularies.py	2011-08-20 10:53:02 +0000
+++ lib/lp/registry/vocabularies.py	2011-08-22 13:16:25 +0000
@@ -292,7 +292,7 @@
             raise LookupError(token)
         return self.toTerm(product)
 
-    def search(self, query):
+    def search(self, query, vocab_filter=None):
         """See `SQLObjectVocabularyBase`.
 
         Returns products where the product name, displayname, title,
@@ -345,7 +345,7 @@
             raise LookupError(token)
         return self.toTerm(project)
 
-    def search(self, query):
+    def search(self, query, vocab_filter=None):
         """See `SQLObjectVocabularyBase`.
 
         Returns projects where the project name, displayname, title,
@@ -423,7 +423,7 @@
         """Return `IPerson` objects that match the text."""
         return getUtility(IPersonSet).find(text)
 
-    def search(self, text):
+    def search(self, text, vocab_filter=None):
         """See `SQLObjectVocabularyBase`.
 
         Return people/teams whose fti or email address match :text.
@@ -457,7 +457,7 @@
             text, exclude_inactive_accounts=False,
             must_have_email=self.must_have_email)
 
-    def search(self, text):
+    def search(self, text, vocab_filter=None):
         """See `SQLObjectVocabularyBase`.
 
         Return people whose fti or email address match :text.
@@ -478,6 +478,32 @@
     must_have_email = False
 
 
+class VocabularyFilterPerson(VocabularyFilter):
+    # A filter returning just persons.
+
+    def __new__(cls):
+        return super(VocabularyFilter, cls).__new__(
+            cls, 'PERSON', 'Person',
+            'Display search results for people only')
+
+    @property
+    def filter_terms(self):
+        return [Person.teamownerID == None]
+
+
+class VocabularyFilterTeam(VocabularyFilter):
+    # A filter returning just teams.
+
+    def __new__(cls):
+        return super(VocabularyFilter, cls).__new__(
+            cls, 'TEAM', 'Team',
+            'Display search results for teams only')
+
+    @property
+    def filter_terms(self):
+        return [Person.teamownerID != None]
+
+
 class ValidPersonOrTeamVocabulary(
         BasePersonVocabulary, SQLObjectVocabularyBase):
     """The set of valid, viewable Persons/Teams in Launchpad.
@@ -508,6 +534,9 @@
 
     LIMIT = 100
 
+    PERSON_FILTER = VocabularyFilterPerson()
+    TEAM_FILTER = VocabularyFilterTeam()
+
     def __contains__(self, obj):
         return obj in self._doSearch()
 
@@ -556,19 +585,22 @@
             private_query = False
         return (private_query, tables)
 
-    def _doSearch(self, text=""):
+    def _doSearch(self, text="", vocab_filter=None):
         """Return the people/teams whose fti or email address match :text:"""
         if self.enhanced_picker_enabled:
-            return self._doSearchWithImprovedSorting(text)
+            return self._doSearchWithImprovedSorting(text, vocab_filter)
         else:
-            return self._doSearchWithOriginalSorting(text)
+            return self._doSearchWithOriginalSorting(text, vocab_filter)
 
-    def _doSearchWithOriginalSorting(self, text=""):
+    def _doSearchWithOriginalSorting(self, text="", vocab_filter=None):
         private_query, private_tables = self._privateTeamQueryAndTables()
         exact_match = None
+        extra_clauses = [self.extra_clause]
+        if vocab_filter:
+            extra_clauses.extend(vocab_filter.filter_terms)
 
         # Short circuit if there is no search text - all valid people and
-        # teams have been requested.
+        # teams have been requested. We still honour the vocab filter.
         if not text:
             tables = [
                 Person,
@@ -583,7 +615,7 @@
                        private_query,
                        ),
                     Person.merged == None,
-                    self.extra_clause
+                    *extra_clauses
                     )
                 )
         else:
@@ -683,7 +715,7 @@
             exact_match = (Person.name == text)
             result = self.store.using(subselect).find(
                 (Person, exact_match),
-                self.extra_clause)
+                *extra_clauses)
         # XXX: BradCrittenden 2009-05-07 bug=373228: A bug in Storm prevents
         # setting the 'distinct' and 'limit' options in a single call to
         # .config().  The work-around is to split them up.  Note the limit has
@@ -704,13 +736,16 @@
         result.config(limit=self.LIMIT)
         return result
 
-    def _doSearchWithImprovedSorting(self, text=""):
+    def _doSearchWithImprovedSorting(self, text="", vocab_filter=None):
         """Return the people/teams whose fti or email address match :text:"""
 
         private_query, private_tables = self._privateTeamQueryAndTables()
+        extra_clauses = [self.extra_clause]
+        if vocab_filter:
+            extra_clauses.extend(vocab_filter.filter_terms)
 
-        # Short circuit if there is no search text - all valid people and
-        # teams have been requested.
+        # Short circuit if there is no search text - all valid people and/or
+        # teams have been requested. We still honour the vocab filter.
         if not text:
             tables = [
                 Person,
@@ -725,7 +760,7 @@
                        private_query,
                        ),
                     Person.merged == None,
-                    self.extra_clause
+                    *extra_clauses
                     )
                 )
             result.config(distinct=True)
@@ -852,7 +887,7 @@
                                     EmailAddressStatus.PREFERRED)),
                         # Or a private team
                         private_teams_query),
-                    self.extra_clause),
+                    *extra_clauses),
                 )
             # Better ranked matches go first.
             if (getFeatureFlag('disclosure.person_affiliation_rank.enabled')
@@ -893,7 +928,7 @@
 
         return DecoratedResultSet(result, pre_iter_hook=pre_iter_hook)
 
-    def search(self, text):
+    def search(self, text, vocab_filter=None):
         """Return people/teams whose fti or email address match :text:."""
         if not text:
             if self.allow_null_search:
@@ -902,13 +937,17 @@
                 return self.emptySelectResults()
 
         text = ensure_unicode(text).lower()
-        return self._doSearch(text=text)
+        return self._doSearch(text=text, vocab_filter=vocab_filter)
 
     def searchForTerms(self, query=None, vocab_filter=None):
         """See `IHugeVocabulary`."""
-        results = self.search(query)
+        results = self.search(query, vocab_filter)
         return CountableIterator(results.count(), results, self.toTerm)
 
+    def supportedFilters(self):
+        """See `IHugeVocabulary`."""
+        return [self.ALL_FILTER, self.PERSON_FILTER, self.TEAM_FILTER]
+
 
 class ValidTeamVocabulary(ValidPersonOrTeamVocabulary):
     """The set of all valid, public teams in Launchpad."""
@@ -922,7 +961,7 @@
     # Search with empty string returns all teams.
     allow_null_search = True
 
-    def _doSearch(self, text=""):
+    def _doSearch(self, text="", vocab_filter=None):
         """Return the teams whose fti, IRC, or email address match :text:"""
 
         private_query, private_tables = self._privateTeamQueryAndTables()
@@ -974,6 +1013,9 @@
         result.config(limit=self.LIMIT)
         return result
 
+    def supportedFilters(self):
+        return []
+
 
 class ValidPersonVocabulary(ValidPersonOrTeamVocabulary):
     """The set of all valid persons who are not teams in Launchpad."""
@@ -986,6 +1028,9 @@
     # Cache table to use for checking validity.
     cache_table_name = 'ValidPersonCache'
 
+    def supportedFilters(self):
+        return []
+
 
 class TeamVocabularyMixin:
     """Common methods for team vocabularies."""
@@ -1202,7 +1247,7 @@
             raise LookupError(token)
         return self.getTerm(team_list)
 
-    def search(self, text=None):
+    def search(self, text=None, vocab_filter=None):
         """Search for active mailing lists.
 
         :param text: The name of a mailing list, which can be a partial
@@ -1327,7 +1372,7 @@
         except IndexError:
             raise LookupError(token)
 
-    def search(self, query):
+    def search(self, query, vocab_filter=None):
         """Return terms where query is a substring of the version or name"""
         if not query:
             return self.emptySelectResults()
@@ -1383,7 +1428,7 @@
             return self.toTerm(result)
         raise LookupError(token)
 
-    def search(self, query):
+    def search(self, query, vocab_filter=None):
         """Return terms where query is a substring of the name."""
         if not query:
             return self.emptySelectResults()
@@ -1682,7 +1727,7 @@
         else:
             return self.toTerm(obj)
 
-    def search(self, query):
+    def search(self, query, vocab_filter=None):
         """Return terms where query is a substring of the name"""
         if not query:
             return self.emptySelectResults()
@@ -1735,7 +1780,7 @@
         else:
             return self.toTerm(obj)
 
-    def search(self, query):
+    def search(self, query, vocab_filter=None):
         """Return terms where query is a substring of the name."""
         if not query:
             return self.emptySelectResults()

=== modified file 'lib/lp/registry/vocabularies.zcml'
--- lib/lp/registry/vocabularies.zcml	2011-08-18 08:00:28 +0000
+++ lib/lp/registry/vocabularies.zcml	2011-08-22 13:16:25 +0000
@@ -487,4 +487,16 @@
             permission="zope.Public"
             attributes="name title description"/>
     </class>
+    <class
+        class="lp.registry.vocabularies.VocabularyFilterPerson">
+        <require
+            permission="zope.Public"
+            attributes="name title description"/>
+    </class>
+    <class
+        class="lp.registry.vocabularies.VocabularyFilterTeam">
+        <require
+            permission="zope.Public"
+            attributes="name title description"/>
+    </class>
 </configure>