You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@bloodhound.apache.org by an...@apache.org on 2013/01/24 14:12:02 UTC

svn commit: r1437987 [1/2] - in /incubator/bloodhound/trunk/bloodhound_search: ./ bhsearch/ bhsearch/default-pages/ bhsearch/templates/ bhsearch/tests/

Author: andrej
Date: Thu Jan 24 13:12:01 2013
New Revision: 1437987

URL: http://svn.apache.org/viewvc?rev=1437987&view=rev
Log:
#285 Create prototype for improved search: add milestone support, facets support in UI, fix a few problems with Whoosh

Added:
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/base.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/milestone_search.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/milestone_search.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/real_index_view.py
Modified:
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/admin.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/api.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/default-pages/BloodhoundSearch
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/query_parser.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/templates/bhsearch.html
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/__init__.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/api.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/index_with_whoosh.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/ticket_search.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/utils.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/web_ui.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/whoosh_backend.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/wiki_search.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/ticket_search.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/web_ui.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/whoosh_backend.py
    incubator/bloodhound/trunk/bloodhound_search/bhsearch/wiki_search.py
    incubator/bloodhound/trunk/bloodhound_search/setup.py

Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/admin.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/admin.py?rev=1437987&r1=1437986&r2=1437987&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/admin.py (original)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/admin.py Thu Jan 24 13:12:01 2013
@@ -19,7 +19,8 @@
 #  under the License.
 
 r"""Administration commands for Bloodhound Search."""
-from trac.admin import *
+from trac.core import Component, implements
+from trac.admin import IAdminCommandProvider
 from bhsearch.api import BloodhoundSearchApi
 
 class BloodhoundSearchAdmin(Component):

Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/api.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/api.py?rev=1437987&r1=1437986&r2=1437987&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/api.py (original)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/api.py Thu Jan 24 13:12:01 2013
@@ -19,9 +19,8 @@
 #  under the License.
 
 r"""Core Bloodhound Search components."""
-
-from trac.core import *
 from trac.config import ExtensionOption
+from trac.core import Interface, Component, ExtensionPoint
 
 ASC = "asc"
 DESC = "desc"
@@ -33,6 +32,11 @@ class IndexFields(object):
     TIME = 'time'
     AUTHOR = 'author'
     CONTENT = 'content'
+    STATUS = 'status'
+    DUE = 'due'
+    COMPLETED = 'completed'
+    MILESTONE = 'milestone'
+    COMPONENT = 'component'
 
 class QueryResult(object):
     def __init__(self):
@@ -55,7 +59,7 @@ class ISearchBackend(Interface):
         Called when new document instance must be added
         """
 
-    def delete_doc(type, id, **kwargs):
+    def delete_doc(doc_type, doc_id, **kwargs):
         """
         Delete document from index
         """
@@ -90,14 +94,15 @@ class ISearchBackend(Interface):
         """
         Perform query implementation
 
-        :param query:
-        :param sort:
-        :param fields:
-        :param boost:
-        :param filter:
-        :param facets:
-        :param pagenum:
-        :param pagelen:
+        :param query: Parsed query object
+        :param sort: list of tuples  with field name and sort order:
+            [("field_name", "ASC")]
+        :param fields: list of fields to select
+        :param boost: list of fields with boost values
+        :param filter: filter query object
+        :param facets: list of facet fields
+        :param pagenum: page number
+        :param pagelen: page length
         :return: ResultsPage
         """
 
@@ -124,13 +129,42 @@ class ISearchParticipant(Interface):
     def get_title():
         """Return resource title"""
 
+    def get_default_facets():
+        """Return default facets for the specific resource type"""
+
 class IQueryParser(Interface):
     """Extension point for Bloodhound Search query parser.
     """
 
-    def parse(query_string, req = None):
+    def parse(query_string):
         """Parse query from string"""
 
+    def parse_filters(filters):
+        """Parse query filters"""
+
+class IDocIndexPreprocessor(Interface):
+    """Extension point for Bloodhound Search document pre-processing before
+    adding or update documents into index.
+    """
+
+    def pre_process(doc):
+        """Process document"""
+
+class IResultPostprocessor(Interface):
+    """Extension point for Bloodhound Search result post-processing before
+    returning result to caller.
+    """
+
+    def post_process(query_result):
+        """Process document"""
+
+class IQueryPreprocessor(Interface):
+    """Extension point for Bloodhound Search query pre processing.
+    """
+
+    def query_pre_process(query_parameters):
+        """Process query parameters"""
+
 class BloodhoundSearchApi(Component):
     """Implements core indexing functionality, provides methods for
     searching, adding and deleting documents from index.
@@ -145,6 +179,10 @@ class BloodhoundSearchApi(Component):
         'Name of the component implementing Bloodhound Search query \
         parser.')
 
+    index_pre_processors = ExtensionPoint(IDocIndexPreprocessor)
+    result_post_processors = ExtensionPoint(IResultPostprocessor)
+    query_processors = ExtensionPoint(IQueryPreprocessor)
+
     index_participants = ExtensionPoint(IIndexParticipant)
 
     def query(self, query, sort = None, fields = None,
@@ -170,19 +208,28 @@ class BloodhoundSearchApi(Component):
 
         parsed_query = self.parser.parse(query)
 
+        parsed_filters = self.parser.parse_filters(filter)
         # TODO: add query parsers and meta keywords post-parsing
 
         # TODO: apply security filters
 
-        query_result = self.backend.query(
+        query_parameters = dict(
             query = parsed_query,
             sort = sort,
             fields = fields,
-            filter = filter,
+            filter = parsed_filters,
             facets = facets,
             pagenum = pagenum,
             pagelen = pagelen,
+            boost = boost,
         )
+        for query_processor in self.query_processors:
+            query_processor.query_pre_process(query_parameters)
+
+        query_result = self.backend.query(**query_parameters)
+
+        for post_processor in self.result_post_processors:
+            post_processor.post_process(query_result)
 
         return query_result
 
@@ -192,13 +239,19 @@ class BloodhoundSearchApi(Component):
         self.log.info('Rebuilding the search index.')
         self.backend.recreate_index()
         operation_data = self.backend.start_operation()
+        doc = None
         try:
             for participant in self.index_participants:
                 docs = participant.get_entries_for_index()
                 for doc in docs:
-                    self.backend.add_doc(doc, **operation_data)
+#                    if doc["id"] == u'TracFastCgi':
+                    self._add_doc(doc, **operation_data)
+            doc = None
             self.backend.commit(True, **operation_data)
-        except:
+        except Exception, ex:
+            self.log.error(ex)
+            if doc:
+                self.log.error("Doc that triggers the error: %s" % doc)
             self.backend.cancel(**operation_data)
             raise
 
@@ -209,7 +262,7 @@ class BloodhoundSearchApi(Component):
                 doc[IndexFields.TYPE],
                 old_id,
                 **operation_data)
-            self.backend.add_doc(doc, **operation_data)
+            self._add_doc(doc, **operation_data)
             self.backend.commit(False, **operation_data)
         except:
             self.backend.cancel(**operation_data)
@@ -225,27 +278,33 @@ class BloodhoundSearchApi(Component):
 
         The doc must be dictionary with obligatory "type" field
         """
+
         operation_data = self.backend.start_operation()
         try:
-            self.backend.add_doc(doc, **operation_data)
+            self._add_doc(doc, **operation_data)
             self.backend.commit(False, **operation_data)
         except:
             self.backend.cancel(**operation_data)
             raise
 
 
-    def delete_doc(self, type, id):
+    def delete_doc(self, doc_type, doc_id):
         """Add a document from underlying search backend.
 
         The doc must be dictionary with obligatory "type" field
         """
         operation_data = self.backend.start_operation()
         try:
-            self.backend.delete_doc(type, id, **operation_data)
+            self.backend.delete_doc(doc_type, doc_id, **operation_data)
             self.backend.commit(False, **operation_data)
         except:
             self.backend.cancel(**operation_data)
             raise
 
 
+    def _add_doc(self, doc, **operation_data):
+        for preprocessor in self.index_pre_processors:
+            preprocessor.pre_process(doc)
+        self.backend.add_doc(doc, **operation_data)
+
 

Added: incubator/bloodhound/trunk/bloodhound_search/bhsearch/base.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/base.py?rev=1437987&view=auto
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/base.py (added)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/base.py Thu Jan 24 13:12:01 2013
@@ -0,0 +1,31 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+r"""Base classes for Bloodhound Search plugin."""
+from trac.core import Component
+from trac.config import BoolOption
+
+class BaseIndexer(Component):
+    """
+    This is base class for Bloodhound Search indexers of specific resource
+    """
+    silence_on_error = BoolOption('bhsearch', 'silence_on_error', "True",
+        """If true, do not throw an exception during indexing a resource""")
+

Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/default-pages/BloodhoundSearch
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/default-pages/BloodhoundSearch?rev=1437987&r1=1437986&r2=1437987&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/default-pages/BloodhoundSearch (original)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/default-pages/BloodhoundSearch Thu Jan 24 13:12:01 2013
@@ -1,3 +1,71 @@
 = Bloodhound Search =
 
-TODO: add docs here
\ No newline at end of file
+Current version is prototype phase 1. Read more about proposed functionality on [wiki:Proposals/BEP-0004 BEP-0004]
+
+== How to enable Bloodhound Search==
+ * Currently, Bloodhound Search is available only for dev configuration. Make sure that Bloodhound was installed using requirements-dev.txt:
+{{{
+pip install -r requirements-dev.txt
+}}}
+ * enable bhsearch.* components in trac.ini file
+{{{
+[components]
+...
+bhsearch.* = enabled
+}}}
+ * run initial index rebuild using trac-admin command line tool. TODO: web interface should be supported later
+{{{
+trac-admin <path_to_trac_environment> bhsearch rebuild
+}}}
+ * Bloodhound Search page will be available in Apps menu | Bloodhound Search
+
+== Search == #search
+Query syntax is described on wiki:Proposals/BEP-0004/ResourceQuery. Currently supported search queries are: 
+* List of all items: "*"
+* Free text search through indexed fields e.g. "some text"
+* Combination of free text and field specific query e.g. "test status:new keyword:starter" (default operator is AND)
+* Field specific query e.g. "status:new keyword:starter" (default operator is AND)
+* Boolean queries e.g. "test OR (status:new and keyword:starter)"
+
+Currently, only tickets and wiki pages with the following fields are indexed. Later more resources and fields will be added.
+
+Common fields for all resources:
+ * id - resource id. For ticket it is ticket id, for wiki it will wiki page name.
+ * type - resource type e.g. ticket, wiki etc.
+ * product - product name
+ * time - resource change time
+ * author - resource author. For ticket it is ticket reporter.
+ * content - ticket description, wiki page content
+ * changes - currently, only ticket comments are indexed
+
+Ticket specific fields:
+ * component
+ * status 
+ * resolution
+ * keywords
+ * milestone
+ * summary
+
+Default sort order is by boost score ASC and time DESC
+
+Default query boosting is: id = 6, type = 2, summary = 5, author = 3, milestone = 2, keywords = 2, component = 2, status = 2, content = 1, changes = 1, 
+
+== Prototype phase 2 limitations == #limitations
+ * Only tickets and wiki pages are indexed.
+ * UI does not support faceting
+ * Search  does not support meta keywords 
+ * Quick jump by ticket id and wiki name is not supported
+ * Possible bugs and inconveniences expected for prototype 
+
+== To be done ==
+Please consider below not complete list of TODOs.
+ * UI design for search results including facets and resource specific presentation. Initial proposal can be found on [wiki:Proposals/BEP-0004#resultview BEP-0004]
+ * Add facet support for search UI
+ * Improve index quality e.d. add more resources in index (milestones etc.), index more fields for ticket. Support pluggable schema generation.
+ * Improve search quality
+ * Improve index consistency. [=#consistency]  
+   * Current implementation uses ITicketChangeListener interface that triggers after DB committed. There is some chances to get inconsistency between DB and index. In this case reindex is required. One of the possible solution is contact Trac community to introduce a new within transaction  I<Resource>ChangingListener interface, save changes into additional table and index changes in separate thread.
+   * Some ticket changes are not reflected in event interfaces e.g. Version and Component renaming. One of the possible solution is to contact Trac community to introduce new interfaces.
+   * Alternative solution is triggering events from SQL proxy developed on multi-product branch
+   * Usage of DB triggers is another alternative
+ * A lot more things to do :)
\ No newline at end of file

Added: incubator/bloodhound/trunk/bloodhound_search/bhsearch/milestone_search.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/milestone_search.py?rev=1437987&view=auto
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/milestone_search.py (added)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/milestone_search.py Thu Jan 24 13:12:01 2013
@@ -0,0 +1,132 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+r"""Milestone specifics for Bloodhound Search plugin."""
+from bhsearch.api import IIndexParticipant, BloodhoundSearchApi, IndexFields, \
+    ISearchParticipant
+from bhsearch.base import BaseIndexer
+from trac.ticket import IMilestoneChangeListener, Milestone
+from trac.config import ListOption
+from trac.core import implements, Component
+
+MILESTONE_TYPE = u"milestone"
+
+class MilestoneIndexer(BaseIndexer):
+    implements(IMilestoneChangeListener, IIndexParticipant)
+
+    optional_fields = {
+        'description': IndexFields.CONTENT,
+        'due': IndexFields.DUE,
+        'completed': IndexFields.COMPLETED,
+    }
+
+
+    # IMilestoneChangeListener methods
+    def milestone_created(self, milestone):
+        self._index_milestone(milestone)
+
+    def milestone_changed(self, milestone, old_values):
+        if "name" in old_values:
+            self._rename_milestone(milestone, old_values["name"])
+        else:
+            self._index_milestone(milestone)
+
+    def milestone_deleted(self, milestone):
+        try:
+            search_api = BloodhoundSearchApi(self.env)
+            search_api.delete_doc(MILESTONE_TYPE, milestone.name)
+        except Exception, e:
+            if self.silence_on_error:
+                self.log.error("Error occurs during milestone indexing. \
+                    The error will not be propagated. Exception: %s", e)
+            else:
+                raise
+
+    def _rename_milestone(self, milestone, old_name):
+        #todo: reindex tickets that are referencing the renamed milestone
+        try:
+            doc = self.build_doc(milestone)
+            search_api = BloodhoundSearchApi(self.env)
+            search_api.change_doc_id(doc, old_name)
+        except Exception, e:
+            if self.silence_on_error:
+                self.log.error("Error occurs during renaming milestone from \
+                 %s to %s. The error will not be propagated. Exception: %s",
+                old_name, milestone.name, e)
+            else:
+                raise
+
+    def _index_milestone(self, milestone):
+        try:
+            doc = self.build_doc(milestone)
+            search_api = BloodhoundSearchApi(self.env)
+            search_api.add_doc(doc)
+        except Exception, e:
+            if self.silence_on_error:
+                self.log.error("Error occurs during wiki indexing. \
+                    The error will not be propagated. Exception: %s", e)
+            else:
+                raise
+
+    #IIndexParticipant members
+    def build_doc(self, trac_doc):
+        milestone = trac_doc
+        #TODO: a lot of improvements must be added here.
+        if milestone.is_completed:
+            status = 'completed'
+        else:
+            status = 'open'
+        doc = {
+            IndexFields.ID: milestone.name,
+            IndexFields.TYPE: MILESTONE_TYPE,
+            IndexFields.STATUS: status,
+        }
+
+        for field, index_field in self.optional_fields.iteritems():
+            value = getattr(milestone, field, None)
+            if value is not None:
+                doc[index_field] = value
+
+        return doc
+
+    def get_entries_for_index(self):
+        for milestone in Milestone.select(self.env, include_completed=True):
+            yield self.build_doc(milestone)
+
+class MilestoneSearchParticipant(Component):
+    implements(ISearchParticipant)
+
+    default_facets = ListOption('bhsearch', 'default_facets_milestone',
+        doc="""Default facets applied to search through milestones""")
+
+    #ISearchParticipant members
+    def get_search_filters(self, req=None):
+        if not req or 'MILESTONE_VIEW' in req.perm:
+            return MILESTONE_TYPE
+
+    def get_title(self):
+        return "Milestone"
+
+    def get_default_facets(self):
+        return self.default_facets
+
+    def format_search_results(self, res):
+        #TODO: add better milestone rendering
+        return u'Milestone: %s' % res['id']

Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/query_parser.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/query_parser.py?rev=1437987&r1=1437986&r2=1437987&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/query_parser.py (original)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/query_parser.py Thu Jan 24 13:12:01 2013
@@ -23,35 +23,51 @@ r"""Provides Bloodhound Search query par
 from bhsearch.api import IQueryParser
 from bhsearch.whoosh_backend import WhooshBackend
 from trac.core import Component, implements
-from whoosh.qparser import MultifieldParser
+from whoosh import query
+from whoosh.qparser import MultifieldParser, QueryParser
 
 class DefaultQueryParser(Component):
     implements(IQueryParser)
 
+    #todo: make field boost configurable e.g. read from config setting.
+    #This is prototype implementation ,the fields boost must be tuned later
+    field_boosts = dict(
+        id = 6,
+        type = 2,
+        summary = 5,
+        author = 3,
+        milestone = 2,
+        keywords = 2,
+        component = 2,
+        status = 2,
+        content = 1,
+        changes = 1,
+    )
+    parser = MultifieldParser(
+        field_boosts.keys(),
+        WhooshBackend.SCHEMA,
+        fieldboosts=field_boosts
+    )
+
     def parse(self, query_string):
-        #todo: make field boost configurable e.g. read from config setting
-        #this is prototype implementation ,the fields boost must be tuned later
-        field_boosts = dict(
-            id = 6,
-            type = 2,
-            summary = 5,
-            author = 3,
-            milestone = 2,
-            keywords = 2,
-            component = 2,
-            status = 2,
-            content = 1,
-            changes = 1,
-        )
-        parser = MultifieldParser(
-            field_boosts.keys(),
-            WhooshBackend.SCHEMA,
-            fieldboosts=field_boosts
-        )
+        query_string = query_string.strip()
+
+        if query_string == "" or query_string == "*" or query_string == "*:*":
+            return query.Every()
+
         query_string = unicode(query_string)
-        parsed_query = parser.parse(query_string)
+        parsed_query = self.parser.parse(query_string)
 
-        #todo: impelement pluggable mechanizem for query post processing
+        #todo: impalement pluggable mechanism for query post processing
         #e.g. meta keyword replacement etc.
         return parsed_query
 
+    def parse_filters(self, filters):
+        """Parse query filters"""
+        if not filters:
+            return None
+        parsed_filters = [self._parse_filter(filter) for filter in filters]
+        return query.And(parsed_filters).normalize()
+
+    def _parse_filter(self, filter):
+        return self.parse(unicode(filter))
\ No newline at end of file

Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/templates/bhsearch.html
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/templates/bhsearch.html?rev=1437987&r1=1437986&r2=1437987&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/templates/bhsearch.html (original)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/templates/bhsearch.html Thu Jan 24 13:12:01 2013
@@ -25,6 +25,19 @@
       xmlns:i18n="http://genshi.edgewall.org/i18n"
       xmlns:xi="http://www.w3.org/2001/XInclude">
   <xi:include href="layout.html" />
+
+  <py:def function="display_value(value)">
+    <py:choose test="value">
+      <py:when test="None">
+        <span class="label">empty</span>
+      </py:when>
+      <py:when test="''">
+        <span class="label">empty</span>
+      </py:when>
+      <py:otherwise>${value}</py:otherwise>
+    </py:choose>
+  </py:def>
+
   <head>
     <title py:choose="">
       <py:when test="query">Bloodhound Search Results</py:when>
@@ -41,48 +54,85 @@
     </script>
   </head>
   <body>
-    <div id="content" class="search">
+    <div id="content" class="row">
 
       <h1>This page provides prototype functionality.</h1>
-      <h1><label for="q">Search</label></h1>
       <form id="fullsearch" action="${href.bhsearch()}" method="get">
         <p>
           <input type="text" id="q" name="q" size="40" value="${query}" />
           <input type="hidden" name="noquickjump" value="1" />
           <input py:if="active_type" type="hidden" name="type" value="${active_type}" />
+          <py:for each="active_filter in active_filter_queries">
+            <input type="hidden" name="fq" value="${active_filter.query}" />
+          </py:for>
           <input type="submit" value="${_('Search')}" />
         </p>
       </form>
 
+      <!--This just a prototype implementation. Should be replaced by proper UI mocks-->
       <div>
-        <ul class="nav nav-tabs" id="mainnav">
+        <!--Render type tabs: All, Ticket, Wiki, etc.-->
+        <ul class="nav nav-tabs" id="search_types">
           <li py:for="idx, item in enumerate(i for i in types)"
-              class="${classes(first_last(idx, types), active=item.active)}"><a href="${item.href}">${item.label}</a></li>
+              class="${classes(first_last(idx, types), active=item.active)}">
+                <a href="${item.href}">${item.label}</a>
+          </li>
 
         </ul>
       </div>
 
-      <py:if test="results"><hr />
-        <h2 py:if="results">
-          Results <small>(${results.displayed_items()})</small>
-        </h2>
-        <div>
-          <dl id="results">
-
-            <!--This just a prototype implementation. Should be replaced by proper UI mocks-->
-            <py:for each="result in results">
-              <dt><a href="${result.href}" class="searchable">${result.title}</a></dt>
-              <dd class="searchable">${result.excerpt}</dd>
-              <dd>
-                <py:if test="result.author"><span class="author" i18n:msg="author">By ${format_author(result.author)}</span> &mdash;</py:if>
-                <span class="date">${result.date}</span>
-              </dd>
-            </py:for>
-          </dl>
+      <py:if test="active_filter_queries">
+        <div id="active_filter_queries">
+          <py:for each="active_filter in active_filter_queries">
+            &gt; <a href="${active_filter.href}">${active_filter.label}</a>
+          </py:for>
+        </div>
+      </py:if>
+
+      <py:if test="results">
+        <div class="span8">
+          <h2 py:if="results">
+            Results <small>(${results.displayed_items()})</small>
+          </h2>
+          <div>
+            <dl id="results">
+
+              <py:for each="result in results">
+                <dt><a href="${result.href}" class="searchable">${result.title}</a></dt>
+                <dd class="searchable">${result.excerpt}</dd>
+                <dd>
+                  <py:if test="result.author"><span class="author" i18n:msg="author">By ${format_author(result.author)}</span> &mdash;</py:if>
+                  <span class="date">${result.date}</span>
+                </dd>
+              </py:for>
+            </dl>
+          </div>
+
+          <xi:include py:with="paginator = results" href="bh_page_index.html" />
         </div>
-        <xi:include py:with="paginator = results" href="bh_page_index.html" />
+
+        <div class="span4">
+          <py:if test="facet_counts">
+            <!--Render facet counts-->
+            <h3>Facets</h3>
+            <ul id="facet_counts">
+              <li py:for="field, per_value_counts in facet_counts.iteritems()">
+                <h4 style="display: inline;">${field}</h4>
+                <ul id="facet_counts_value">
+                  <li py:for="value, item in per_value_counts.iteritems()">
+                    <a href="${item.href}"><strong>${display_value(value)}</strong></a>
+                    <span class="badge badge-info">${item.count}</span>
+                  </li>
+                  </ul>
+              </li>
+
+            </ul>
+          </py:if>
+        </div>
+
       </py:if>
 
+
       <div class="span12"
           py:if="query and not (results or quickjump)">
         <p id="notfound" class="alert">
@@ -97,5 +147,4 @@
 
     </div>
   </body>
-</html>
-
+</html>
\ No newline at end of file

Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/__init__.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/__init__.py?rev=1437987&r1=1437986&r2=1437987&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/__init__.py (original)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/__init__.py Thu Jan 24 13:12:01 2013
@@ -18,7 +18,8 @@
 #  specific language governing permissions and limitations
 #  under the License.
 import unittest
-from bhsearch.tests import whoosh_backend, index_with_whoosh, web_ui, ticket_search, api, wiki_search
+from bhsearch.tests import whoosh_backend, index_with_whoosh, web_ui, \
+    ticket_search, api, wiki_search
 
 def suite():
     suite = unittest.TestSuite()

Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/api.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/api.py?rev=1437987&r1=1437986&r2=1437987&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/api.py (original)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/api.py Thu Jan 24 13:12:01 2013
@@ -20,7 +20,7 @@
 import unittest
 import tempfile
 import shutil
-from bhsearch.api import BloodhoundSearchApi
+from bhsearch.api import BloodhoundSearchApi, ASC
 from bhsearch.query_parser import DefaultQueryParser
 from bhsearch.tests.utils import BaseBloodhoundSearchTest
 from bhsearch.ticket_search import TicketSearchParticipant
@@ -100,6 +100,26 @@ class ApiQueryWithWhooshTestCase(BaseBlo
         docs = results.docs
         self.assertEqual("summary1 keyword", docs[0]["summary"])
 
+    def test_that_filter_queries_applied(self):
+        #arrange
+        self.insert_ticket("t1", status="closed", component = "c1")
+        self.insert_ticket("t2", status="closed", component = "c1")
+        self.insert_ticket("t3", status="closed",
+            component = "NotInFilterCriteria")
+        #act
+        results = self.search_api.query(
+            "*",
+            filter= ['status:"closed"', 'component:"c1"'],
+            sort= [("id", ASC)]
+        )
+        self.print_result(results)
+        #assert
+        self.assertEqual(2, results.hits)
+        docs = results.docs
+        self.assertEqual("t1", docs[0]["summary"])
+        self.assertEqual("t2", docs[1]["summary"])
+
+
 #TODO: check this later
 #    @unittest.skip("Check with Whoosh community")
 #    def test_can_search_id_and_summary(self):

Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/index_with_whoosh.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/index_with_whoosh.py?rev=1437987&r1=1437986&r2=1437987&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/index_with_whoosh.py (original)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/index_with_whoosh.py Thu Jan 24 13:12:01 2013
@@ -17,12 +17,12 @@
 #  KIND, either express or implied.  See the License for the
 #  specific language governing permissions and limitations
 #  under the License.
-from datetime import datetime
 
 import unittest
 import tempfile
 import shutil
 from bhsearch.api import BloodhoundSearchApi
+from bhsearch.milestone_search import MilestoneIndexer
 from bhsearch.tests.utils import BaseBloodhoundSearchTest
 from bhsearch.ticket_search import TicketIndexer
 
@@ -41,6 +41,7 @@ class IndexWhooshTestCase(BaseBloodhound
         self.search_api = BloodhoundSearchApi(self.env)
         self.ticket_indexer = TicketIndexer(self.env)
         self.wiki_indexer = WikiIndexer(self.env)
+        self.milestone_indexer = MilestoneIndexer(self.env)
         self.ticket_system = TicketSystem(self.env)
 
     def tearDown(self):
@@ -110,6 +111,17 @@ class IndexWhooshTestCase(BaseBloodhound
         self.print_result(results)
         self.assertEqual(2, results.hits)
 
+    def test_can_reindex_milestones(self):
+        self.insert_milestone("M1")
+        self.insert_milestone("M2")
+        self.whoosh_backend.recreate_index()
+        #act
+        self.search_api.rebuild_index()
+        #assert
+        results = self.search_api.query("*:*")
+        self.print_result(results)
+        self.assertEqual(2, results.hits)
+
 
 def suite():
     return unittest.makeSuite(IndexWhooshTestCase, 'test')

Added: incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/milestone_search.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/milestone_search.py?rev=1437987&view=auto
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/milestone_search.py (added)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/milestone_search.py Thu Jan 24 13:12:01 2013
@@ -0,0 +1,142 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+import shutil
+import unittest
+import tempfile
+
+from bhsearch.api import BloodhoundSearchApi
+from bhsearch.milestone_search import MilestoneSearchParticipant
+from bhsearch.query_parser import DefaultQueryParser
+from bhsearch.tests.utils import BaseBloodhoundSearchTest
+from bhsearch.whoosh_backend import WhooshBackend
+
+from trac.test import EnvironmentStub
+from trac.ticket import Milestone
+
+
+class MilestoneIndexerEventsTestCase(BaseBloodhoundSearchTest):
+    DUMMY_MILESTONE_NAME = "dummyName"
+
+    def setUp(self):
+        self.env = EnvironmentStub(enable=['bhsearch.*'])
+        self.env.path = tempfile.mkdtemp('bhsearch-tempenv')
+        self.env.config.set('bhsearch', 'silence_on_error', "False")
+        self.whoosh_backend = WhooshBackend(self.env)
+        self.whoosh_backend.recreate_index()
+        self.search_api = BloodhoundSearchApi(self.env)
+        self.milestone_participant = MilestoneSearchParticipant(self.env)
+        self.query_parser = DefaultQueryParser(self.env)
+
+    def tearDown(self):
+        shutil.rmtree(self.env.path)
+        self.env.reset_db()
+
+    def test_can_index_created_milestone(self):
+        #arrange
+        self.insert_milestone(self.DUMMY_MILESTONE_NAME, "dummy text")
+        #act
+        results = self.search_api.query("*:*")
+        #assert
+        self.print_result(results)
+        self.assertEqual(1, results.hits)
+        doc = results.docs[0]
+        self.assertEqual(self.DUMMY_MILESTONE_NAME, doc["id"])
+        self.assertEqual("dummy text", doc["content"])
+        self.assertEqual("milestone", doc["type"])
+        self.assertNotIn("due", doc )
+
+    def test_can_index_minimal_milestone(self):
+        #arrange
+        self.insert_milestone(self.DUMMY_MILESTONE_NAME)
+        #act
+        results = self.search_api.query("*:*")
+        #assert
+        self.print_result(results)
+        self.assertEqual(1, results.hits)
+        doc = results.docs[0]
+        self.assertEqual(self.DUMMY_MILESTONE_NAME, doc["id"])
+        self.assertNotIn("content", doc)
+
+
+    def test_can_index_renamed_milestone(self):
+        #arrange
+        self.insert_milestone(self.DUMMY_MILESTONE_NAME, "dummy text")
+        self.change_milestone(
+            self.DUMMY_MILESTONE_NAME,
+            name="updated name",
+            description="updated description",
+        )
+        #act
+        results = self.search_api.query("*:*")
+        #assert
+        self.print_result(results)
+        self.assertEqual(1, results.hits)
+        doc = results.docs[0]
+        self.assertEqual("updated name", doc["id"])
+        self.assertEqual("updated description", doc["content"])
+
+    def test_can_index_changed_milestone(self):
+        #arrange
+        self.insert_milestone(self.DUMMY_MILESTONE_NAME, "dummy text")
+        self.change_milestone(
+            self.DUMMY_MILESTONE_NAME,
+            description="updated description",
+        )
+        #act
+        results = self.search_api.query("*:*")
+        #assert
+        self.print_result(results)
+        self.assertEqual(1, results.hits)
+        doc = results.docs[0]
+        self.assertEqual(self.DUMMY_MILESTONE_NAME, doc["id"])
+        self.assertEqual("updated description", doc["content"])
+
+    def test_can_index_delete(self):
+        #arrange
+        self.insert_milestone(self.DUMMY_MILESTONE_NAME)
+        Milestone(self.env, self.DUMMY_MILESTONE_NAME).delete()
+        #act
+        results = self.search_api.query("*.*")
+        #assert
+        self.print_result(results)
+        self.assertEqual(0, results.hits)
+
+    def test_can_reindex_minimal_milestone(self):
+        #arrange
+        self.insert_milestone(self.DUMMY_MILESTONE_NAME)
+        self.whoosh_backend.recreate_index()
+        #act
+        self.search_api.rebuild_index()
+        #assert
+        results = self.search_api.query("*:*")
+        self.print_result(results)
+        self.assertEqual(1, results.hits)
+        doc = results.docs[0]
+        self.assertEqual(self.DUMMY_MILESTONE_NAME, doc["id"])
+        self.assertEqual("milestone", doc["type"])
+
+
+def suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(MilestoneIndexerEventsTestCase, 'test'))
+    return suite
+
+if __name__ == '__main__':
+    unittest.main()

Added: incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/real_index_view.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/real_index_view.py?rev=1437987&view=auto
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/real_index_view.py (added)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/real_index_view.py Thu Jan 24 13:12:01 2013
@@ -0,0 +1,92 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+import unittest
+from bhsearch.query_parser import DefaultQueryParser
+from bhsearch.web_ui import BloodhoundSearchModule, RequestParameters
+import os
+from bhsearch.api import BloodhoundSearchApi
+from bhsearch.tests.utils import BaseBloodhoundSearchTest
+
+from bhsearch.whoosh_backend import WhooshBackend
+from trac.test import EnvironmentStub, Mock, MockPerm
+from whoosh import query
+from trac.web import Href, arg_list_to_args
+
+
+class RealIndexTestCase(BaseBloodhoundSearchTest):
+    """
+    This test case is not supposed to be run from CI tool.
+    The purpose of the class is to work with real Bloodhound Search Index and
+    should be used for debugging purposes only
+    """
+    def setUp(self):
+        self.env = EnvironmentStub(enable=['bhsearch.*'])
+        current_current_dir = os.getcwd()
+
+        real_env_path = os.path.join(
+            current_current_dir,
+            "../../../installer/bloodhound/environments/main")
+        self.env.path = real_env_path
+        self.whoosh_backend = WhooshBackend(self.env)
+        self.search_api = BloodhoundSearchApi(self.env)
+        self.web_ui = BloodhoundSearchModule(self.env)
+        self.query_parser = DefaultQueryParser(self.env)
+
+        self.req = Mock(
+            perm=MockPerm(),
+            chrome={'logo': {}},
+            href=Href("/main"),
+            args=arg_list_to_args([]),
+        )
+
+    def test_read_all(self):
+        result = self.whoosh_backend.query(
+            query.Every()
+        )
+        self.print_result(result)
+
+        result = self.whoosh_backend.query(
+            query.Every()
+        )
+        self.print_result(result)
+        self.assertLessEqual(1, result.hits)
+
+    def test_read_with_type_facet(self):
+        result = self.whoosh_backend.query(
+            query.Every()
+        )
+        self.print_result(result)
+
+        result = self.whoosh_backend.query(
+            query.Every(),
+            facets=["type"]
+        )
+        self.print_result(result)
+        self.assertLessEqual(1, result.hits)
+
+    def test_read_from_search_module(self):
+        self.req.args[RequestParameters.QUERY] = "*"
+        self.process_request()
+
+def suite():
+    pass
+
+if __name__ == '__main__':
+    unittest.main()

Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/ticket_search.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/ticket_search.py?rev=1437987&r1=1437986&r2=1437987&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/ticket_search.py (original)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/ticket_search.py Thu Jan 24 13:12:01 2013
@@ -17,10 +17,9 @@
 #  KIND, either express or implied.  See the License for the
 #  specific language governing permissions and limitations
 #  under the License.
-from datetime import datetime
-
 import unittest
 import tempfile
+
 from bhsearch.tests.utils import BaseBloodhoundSearchTest
 from bhsearch.ticket_search import TicketIndexer
 

Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/utils.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/utils.py?rev=1437987&r1=1437986&r2=1437987&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/utils.py (original)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/utils.py Thu Jan 24 13:12:01 2013
@@ -21,15 +21,15 @@
 r"""
 Test utils methods
 """
-import pprint
+from pprint import pprint
 import unittest
-from trac.ticket import Ticket
+from trac.ticket import Ticket, Milestone
 from trac.wiki import WikiPage
 
 class BaseBloodhoundSearchTest(unittest.TestCase):
     def print_result(self, result):
         print "Received result:"
-        pprint.pprint(result.__dict__)
+        pprint(result.__dict__)
 
     def create_dummy_ticket(self, summary = None):
         if not summary:
@@ -57,8 +57,42 @@ class BaseBloodhoundSearchTest(unittest.
         return page
 
     def insert_wiki(self, name, text = None, **kw):
-        """Helper for inserting a ticket into the database"""
         text = text or "Dummy text"
         page = self.create_wiki(name, text, **kw)
         return page.save("dummy author", "dummy comment", "::1")
 
+    def insert_milestone(self, name, description = None):
+        milestone = self.create_milestone(
+            name = name,
+            description = description)
+        return milestone.insert()
+
+    def create_milestone(self, name, description = None):
+        milestone = Milestone(self.env)
+        milestone.name = name
+        if description is not None:
+            milestone.description = description
+        return milestone
+
+    def change_milestone(self, name_to_change, name=None, description=None):
+        milestone = Milestone(self.env, name_to_change)
+        if name is not None:
+            milestone.name = name
+        if description is not None:
+            milestone.description = description
+        milestone.update()
+        return milestone
+
+    def process_request(self):
+        response = self.web_ui.process_request(self.req)
+        url, data, x = response
+        print "Received url: %s data:" % url
+        pprint(data)
+        if data.has_key("results"):
+            print "results :"
+            pprint(data["results"].__dict__)
+        return data
+
+
+
+

Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/web_ui.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/web_ui.py?rev=1437987&r1=1437986&r2=1437987&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/web_ui.py (original)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/web_ui.py Thu Jan 24 13:12:01 2013
@@ -17,23 +17,24 @@
 #  KIND, either express or implied.  See the License for the
 #  specific language governing permissions and limitations
 #  under the License.
-from pprint import pprint
-
 import unittest
 import tempfile
 import shutil
+
+from urllib import urlencode, unquote
+
 from bhsearch.api import BloodhoundSearchApi
 from bhsearch.tests.utils import BaseBloodhoundSearchTest
 from bhsearch.ticket_search import TicketSearchParticipant
 from bhsearch.web_ui import BloodhoundSearchModule, RequestParameters
-
 from bhsearch.whoosh_backend import WhooshBackend
+
 from trac.test import EnvironmentStub, Mock, MockPerm
 from trac.ticket.api import TicketSystem
 from trac.ticket import Ticket
 from trac.util.datefmt import FixedOffset
 from trac.util import format_datetime
-from trac.web import Href
+from trac.web import Href, arg_list_to_args, parse_arg_list
 
 BHSEARCH_URL = "/main/bhsearch"
 DEFAULT_DOCS_PER_PAGE = 10
@@ -43,50 +44,38 @@ class WebUiTestCaseWithWhoosh(BaseBloodh
         self.env = EnvironmentStub(enable=['bhsearch.*'])
         self.env.path = tempfile.mkdtemp('bhsearch-tempenv')
 
-#        self.perm = PermissionSystem(self.env)
         self.ticket_system = TicketSystem(self.env)
         self.whoosh_backend = WhooshBackend(self.env)
         self.whoosh_backend.recreate_index()
         self.search_api = BloodhoundSearchApi(self.env)
         self.ticket_participant = TicketSearchParticipant(self.env)
-        self.ticket_system = TicketSystem(self.env)
         self.web_ui = BloodhoundSearchModule(self.env)
 
         self.req = Mock(
             perm=MockPerm(),
             chrome={'logo': {}},
             href=Href("/main"),
-            args={},
+            args=arg_list_to_args([]),
         )
 
     def tearDown(self):
         shutil.rmtree(self.env.path)
         self.env.reset_db()
 
-    def _process_request(self):
-        response = self.web_ui.process_request(self.req)
-        url, data, x = response
-        print "Received url: %s data:" % url
-        pprint(data)
-        if data.has_key("results"):
-            print "results :"
-            pprint(data["results"].__dict__)
-        return data
-
     def test_can_process_empty_request(self):
-        data = self._process_request()
+        data = self.process_request()
         self.assertEqual("", data["query"])
 
     def test_can_process_query_empty_data(self):
         self.req.args[RequestParameters.QUERY] = "bla"
-        data = self._process_request()
+        data = self.process_request()
         self.assertEqual("bla", data["query"])
         self.assertEqual([], data["results"].items)
 
     def test_can_process_first_page(self):
         self._insert_tickets(5)
         self.req.args[RequestParameters.QUERY] = "summary:test"
-        data = self._process_request()
+        data = self.process_request()
         self.assertEqual("summary:test", data["query"])
         self.assertEqual(5, len(data["results"].items))
 
@@ -97,12 +86,14 @@ class WebUiTestCaseWithWhoosh(BaseBloodh
         ticket_time = ticket.time_changed
         #act
         self.req.args[RequestParameters.QUERY] = "*:*"
-        data = self._process_request()
+        data = self.process_request()
         result_items = data["results"].items
         #assert
         self.assertEqual(1, len(result_items))
         result_datetime = result_items[0]["date"]
-        print "Ticket time: %s, Returned time: %s" % (ticket_time, result_datetime)
+        print "Ticket time: %s, Returned time: %s" % (
+            ticket_time,
+            result_datetime)
         self.assertEqual(format_datetime(ticket_time), result_items[0]["date"])
 
     def test_can_return_user_time(self):
@@ -113,7 +104,7 @@ class WebUiTestCaseWithWhoosh(BaseBloodh
         #act
         self.req.tz = FixedOffset(60, 'GMT +1:00')
         self.req.args[RequestParameters.QUERY] = "*:*"
-        data = self._process_request()
+        data = self.process_request()
         result_items = data["results"].items
         #asset
         self.assertEqual(1, len(result_items))
@@ -126,7 +117,7 @@ class WebUiTestCaseWithWhoosh(BaseBloodh
     def test_ticket_href(self):
         self._insert_tickets(1)
         self.req.args[RequestParameters.QUERY] = "*:*"
-        data = self._process_request()
+        data = self.process_request()
         docs = data["results"].items
         self.assertEqual(1, len(docs))
         self.assertEqual("/main/ticket/1", docs[0]["href"])
@@ -134,31 +125,12 @@ class WebUiTestCaseWithWhoosh(BaseBloodh
     def test_page_href(self):
         self._insert_tickets(DEFAULT_DOCS_PER_PAGE+1)
         self.req.args[RequestParameters.QUERY] = "*:*"
-        data = self._process_request()
+        data = self.process_request()
         shown_pages =  data["results"].shown_pages
         second_page_href = shown_pages[1]["href"]
         self.assertIn("page=2", second_page_href)
         self.assertIn("q=*%3A*", second_page_href)
 
-    def test_facets_ticket_only(self):
-        self.insert_ticket("summary1 keyword", status="closed")
-        self.insert_ticket("summary2 keyword", status="new")
-        self.req.args[RequestParameters.QUERY] = "*:*"
-        data = self._process_request()
-        facets =  data["facets"]
-        pprint(facets)
-        self.assertEqual({'ticket': 2}, facets["type"])
-
-    def test_facets_ticket_and_wiki(self):
-        self.insert_ticket("summary1 keyword", status="closed")
-        self.insert_ticket("summary2 keyword", status="new")
-        self.insert_wiki("dummyTitle", "Some text")
-        self.req.args[RequestParameters.QUERY] = "*"
-        data = self._process_request()
-        facets =  data["facets"]
-        pprint(facets)
-        self.assertEqual({'ticket': 2, 'wiki': 1}, facets["type"])
-
     def test_can_apply_type_parameter(self):
         #arrange
         self.insert_ticket("summary1 keyword", status="closed")
@@ -167,8 +139,7 @@ class WebUiTestCaseWithWhoosh(BaseBloodh
         self.req.args[RequestParameters.QUERY] = "*"
         self.req.args[RequestParameters.TYPE] = "ticket"
         #act
-        data = self._process_request()
-        docs = data["results"].items
+        data = self.process_request()
         #assert
         active_type = data["active_type"]
         self.assertEquals("ticket", active_type)
@@ -191,7 +162,7 @@ class WebUiTestCaseWithWhoosh(BaseBloodh
         self.req.args[RequestParameters.TYPE] = "ticket"
         self.req.args[RequestParameters.PAGELEN] = "4"
         self.req.args[RequestParameters.PAGE] = "2"
-        data = self._process_request()
+        data = self.process_request()
         results = data["results"]
         docs = results.items
         self.assertEquals(4, len(docs))
@@ -211,7 +182,7 @@ class WebUiTestCaseWithWhoosh(BaseBloodh
 
     def test_type_grouping(self):
         self.req.args[RequestParameters.QUERY] = "*:*"
-        data = self._process_request()
+        data = self.process_request()
         resource_types =  data["types"]
 
         all = resource_types[0]
@@ -232,16 +203,219 @@ class WebUiTestCaseWithWhoosh(BaseBloodh
         self.req.args[RequestParameters.QUERY] = "*"
         self.req.args[RequestParameters.PAGELEN] = "4"
         self.req.args[RequestParameters.PAGE] = "2"
-        data = self._process_request()
+        data = self.process_request()
         #assert
         resource_types =  data["types"]
 
         all = resource_types[0]
-        self.assertIn("page=2", all["href"])
+        self.assertNotIn("page=2", all["href"])
 
         ticket = resource_types[1]
         self.assertNotIn("page=", ticket["href"])
 
+    def test_that_there_are_filters_in_type_links(self):
+        #arrange
+#        self._insert_tickets(2)
+        #act
+        self.req.args[RequestParameters.QUERY] = "*"
+        self.req.args[RequestParameters.TYPE] = "ticket"
+        self.req.args[RequestParameters.FILTER_QUERY] = "status:new"
+        data = self.process_request()
+        #assert
+        for type in data["types"]:
+            self.assertNotIn("fq=", type["href"])
+
+    def test_that_type_facet_is_in_default_search(self):
+        #arrange
+        self._insert_tickets(2)
+        #act
+        self.req.args[RequestParameters.QUERY] = "*"
+        data = self.process_request()
+        #assert
+        self.assertEquals(1, len(data["facet_counts"]))
+
+    def test_can_return_facets_counts_for_tickets(self):
+        #arrange
+        self.insert_ticket("T1", status="new", milestone="m1")
+        self.insert_ticket("T2", status="closed")
+        #act
+        self.req.args[RequestParameters.TYPE] = "ticket"
+        self.req.args[RequestParameters.QUERY] = "*"
+        data = self.process_request()
+        #assert
+        facet_counts =  data["facet_counts"]
+        status_counts = facet_counts["status"]
+        self.assertEquals(1, status_counts["new"]["count"])
+        self.assertEquals(1, status_counts["closed"]["count"])
+
+    def test_can_create_href_for_facet_counts(self):
+        #arrange
+        self.insert_ticket("T1", status="new")
+        self.insert_ticket("T2", status="closed")
+        #act
+        self.req.args[RequestParameters.TYPE] = "ticket"
+        self.req.args[RequestParameters.QUERY] = "*"
+        data = self.process_request()
+        #assert
+        facet_counts =  data["facet_counts"]
+        status_counts = facet_counts["status"]
+        self.assertEquals(1, status_counts["new"]["count"])
+        self.assertIn("fq=status%3A%22new%22", status_counts["new"]["href"])
+
+    def test_can_handle_none_in_facet_counts(self):
+        #arrange
+        self.insert_ticket("T1")
+        self.insert_ticket("T2")
+        #act
+        self.req.args[RequestParameters.TYPE] = "ticket"
+        self.req.args[RequestParameters.QUERY] = "*"
+        data = self.process_request()
+        #assert
+        facet_counts =  data["facet_counts"]
+        status_counts = facet_counts["status"]
+        empty_status_count = status_counts[None]
+        self.assertEquals(2, empty_status_count["count"])
+        self.assertIn(
+            'fq=NOT+(status:*)',
+            unquote(empty_status_count["href"]))
+
+    def test_can_return_empty_facets_result_for_wiki_pages(self):
+        #arrange
+        self.insert_wiki("W1","Some text")
+        #act
+        self.req.args[RequestParameters.TYPE] = "wiki"
+        self.req.args[RequestParameters.QUERY] = "*"
+        data = self.process_request()
+        #assert
+        facet_counts =  data["facet_counts"]
+        self.assertEquals({}, facet_counts)
+
+    def test_can_accept_multiple_filter_query_parameters(self):
+        #arrange
+        self.insert_ticket("T1", component="c1", status="new")
+        self.insert_ticket("T2", component="c1", status="new")
+        self.insert_ticket("T3",)
+        self._insert_wiki_pages(2)
+        #act
+        self.req.args[RequestParameters.TYPE] = "ticket"
+        self.req.args[RequestParameters.QUERY] = "*"
+        self.req.args[RequestParameters.FILTER_QUERY] = [
+            'component:"c1"', 'status:"new"']
+        data = self.process_request()
+        #assert
+        page_href = data["page_href"]
+        self.assertIn(urlencode({'fq':'component:"c1"'}), page_href)
+        self.assertIn(urlencode({'fq':'status:"new"'}), page_href)
+
+        docs = data["results"].items
+        self.assertEqual(2, len(docs))
+
+
+    def test_can_handle_empty_facet_result(self):
+        #arrange
+        self.insert_ticket("T1", component="c1", status="new")
+        self.insert_ticket("T2", component="c1", status="new")
+        #act
+        self.req.args[RequestParameters.TYPE] = "ticket"
+        self.req.args[RequestParameters.QUERY] = "*"
+        self.req.args[RequestParameters.FILTER_QUERY] = ['component:"c1"']
+        data = self.process_request()
+        #assert
+        facet_counts = data["facet_counts"]
+
+        milestone_facet_count = facet_counts["milestone"]
+        print unquote(milestone_facet_count[None]["href"])
+
+    def test_can_handle_multiple_same(self):
+        #arrange
+        self.insert_ticket("T1", component="c1", status="new")
+        self.insert_ticket("T2", component="c1", status="new")
+        #act
+        self.req.args[RequestParameters.TYPE] = "ticket"
+        self.req.args[RequestParameters.QUERY] = "*"
+        self.req.args[RequestParameters.FILTER_QUERY] = ['component:"c1"']
+        data = self.process_request()
+        #assert
+        facet_counts = data["facet_counts"]
+
+        component_facet_count = facet_counts["component"]
+        c1_href = component_facet_count["c1"]["href"]
+        print unquote(c1_href)
+        self.assertEquals(
+            1,
+            self._count_parameter_in_url(c1_href, "fq", 'component:"c1"'))
+
+    def test_can_return_current_filter_queries(self):
+        #arrange
+        self.insert_ticket("T1", component="c1", status="new")
+        self.insert_ticket("T2", component="c1", status="new")
+        #act
+        self.req.args[RequestParameters.TYPE] = "ticket"
+        self.req.args[RequestParameters.QUERY] = "*"
+        self.req.args[RequestParameters.FILTER_QUERY] = [
+            'component:"c1"',
+            'status:"new"']
+        data = self.process_request()
+        #assert
+        current_filter_queries = data["active_filter_queries"]
+        self.assertEquals(2, len(current_filter_queries))
+
+        component_filter =  current_filter_queries[0]
+        self.assertEquals('component:"c1"', component_filter["label"])
+        self.assertNotIn("fq=", component_filter["href"])
+
+        status_filter =  current_filter_queries[1]
+        self.assertEquals('status:"new"', status_filter["label"])
+        self.assertIn('fq=component:"c1"', unquote(status_filter["href"]))
+        self.assertNotIn('fq=status:"new"', unquote(status_filter["href"]))
+
+    def test_can_return_missing_milestone(self):
+        #arrange
+        self.insert_ticket("T1", component="c1", status="new")
+        self.insert_ticket("T2", component="c1", status="new", milestone="A")
+        #act
+        self.req.args[RequestParameters.TYPE] = "ticket"
+        self.req.args[RequestParameters.FILTER_QUERY] = ["NOT (milestone:*)"]
+        self.req.args[RequestParameters.QUERY] = "*"
+        data = self.process_request()
+        #assert
+        items = data["results"].items
+        self.assertEquals(1, len(items))
+
+    def test_can_return_no_results_for_missing_milestone(self):
+        #arrange
+        self.insert_ticket("T1", component="c1", status="new", milestone="A")
+        self.insert_ticket("T2", component="c1", status="new", milestone="A")
+        #act
+        self.req.args[RequestParameters.TYPE] = "ticket"
+        self.req.args[RequestParameters.FILTER_QUERY] = ["NOT (milestone:*)"]
+        self.req.args[RequestParameters.QUERY] = "*"
+        data = self.process_request()
+        #assert
+        items = data["results"].items
+        self.assertEquals(0, len(items))
+
+    def test_that_type_facet_has_href_to_type(self):
+        #arrange
+        self.insert_ticket("T1", component="c1", status="new", milestone="A")
+        #act
+        self.req.args[RequestParameters.QUERY] = "*"
+        data = self.process_request()
+        #assert
+        ticket_facet_href = data["facet_counts"]["type"]["ticket"]["href"]
+        ticket_facet_href = unquote(ticket_facet_href)
+        self.assertIn("type=ticket", ticket_facet_href)
+        self.assertNotIn("fq=", ticket_facet_href)
+
+    def _count_parameter_in_url(self, url, parameter_name, value):
+        parameter_to_find = (parameter_name, value)
+        parsed_parameters = parse_arg_list(url)
+        i = 0
+        for parameter in parsed_parameters:
+            if parameter == parameter_to_find:
+                i += 1
+
+        return i
 
     def _assertResourceType(self, type, label, active, href_contains = None):
         self.assertEquals(label, type["label"])
@@ -252,6 +426,11 @@ class WebUiTestCaseWithWhoosh(BaseBloodh
     def _insert_tickets(self, n):
         for i in range(1, n+1):
             self.insert_ticket("test %s" % i)
+
+    def _insert_wiki_pages(self, n):
+        for i in range(1, n+1):
+            self.insert_wiki("test %s" % i)
+
 def suite():
     return unittest.makeSuite(WebUiTestCaseWithWhoosh, 'test')
 

Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/whoosh_backend.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/whoosh_backend.py?rev=1437987&r1=1437986&r2=1437987&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/whoosh_backend.py (original)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/whoosh_backend.py Thu Jan 24 13:12:01 2013
@@ -17,7 +17,7 @@
 #  KIND, either express or implied.  See the License for the
 #  specific language governing permissions and limitations
 #  under the License.
-from datetime import datetime, timedelta
+from datetime import datetime
 
 import unittest
 import tempfile
@@ -25,12 +25,14 @@ import shutil
 from bhsearch.api import ASC, DESC, SCORE
 from bhsearch.query_parser import DefaultQueryParser
 from bhsearch.tests.utils import BaseBloodhoundSearchTest
-from bhsearch.whoosh_backend import WhooshBackend
+from bhsearch.whoosh_backend import WhooshBackend, \
+    WhooshEmptyFacetErrorWorkaround
 from trac.test import EnvironmentStub
 from trac.util.datefmt import FixedOffset, utc
 from whoosh import index, sorting, query
 from whoosh.fields import Schema, ID, TEXT, KEYWORD
-from whoosh.qparser import MultifieldPlugin, QueryParser, WhitespacePlugin, PhrasePlugin
+from whoosh.qparser import MultifieldPlugin, QueryParser, WhitespacePlugin, \
+    PhrasePlugin
 
 
 class WhooshBackendTestCase(BaseBloodhoundSearchTest):
@@ -39,7 +41,7 @@ class WhooshBackendTestCase(BaseBloodhou
         self.env.path = tempfile.mkdtemp('bhsearch-tempenv')
         self.whoosh_backend = WhooshBackend(self.env)
         self.whoosh_backend.recreate_index()
-        self.default_parser = DefaultQueryParser(self.env)
+        self.parser = DefaultQueryParser(self.env)
 
     def tearDown(self):
         shutil.rmtree(self.env.path)
@@ -92,7 +94,7 @@ class WhooshBackendTestCase(BaseBloodhou
         result = whoosh_backend2.query(query.Every())
         self.assertEqual(2, result.hits)
 
-    def test_can_multi_sort_asc(self):
+    def test_can_apply_multiple_sort_conditions_asc(self):
         self.whoosh_backend.add_doc(dict(id="2", type="ticket2"))
         self.whoosh_backend.add_doc(dict(id="3", type="ticket1"))
         self.whoosh_backend.add_doc(dict(id="4", type="ticket3"))
@@ -109,7 +111,7 @@ class WhooshBackendTestCase(BaseBloodhou
                           {'type': 'ticket3', 'id': '4'}],
             result.docs)
 
-    def test_can_multi_sort_desc(self):
+    def test_can_apply_multiple_sort_conditions_desc(self):
         self.whoosh_backend.add_doc(dict(id="2", type="ticket2"))
         self.whoosh_backend.add_doc(dict(id="3", type="ticket1"))
         self.whoosh_backend.add_doc(dict(id="4", type="ticket3"))
@@ -159,7 +161,7 @@ class WhooshBackendTestCase(BaseBloodhou
             time=the_third_date,
         ))
 
-        parsed_query = self.default_parser.parse("summary:texttofind")
+        parsed_query = self.parser.parse("summary:texttofind")
 
         result = self.whoosh_backend.query(
             parsed_query,
@@ -215,7 +217,8 @@ class WhooshBackendTestCase(BaseBloodhou
         self.assertEqual(0, result.hits)
 
     def test_can_search_time_with_utc_tzinfo(self):
-        time = datetime(2012, 12, 13, 11, 8, 34, 711957, tzinfo=FixedOffset(0, 'UTC'))
+        time = datetime(2012, 12, 13, 11, 8, 34, 711957,
+            tzinfo=FixedOffset(0, 'UTC'))
         self.whoosh_backend.add_doc(dict(id="1", type="ticket", time=time))
         result = self.whoosh_backend.query(query.Every())
         self.print_result(result)
@@ -245,14 +248,13 @@ class WhooshBackendTestCase(BaseBloodhou
         self.whoosh_backend.add_doc(dict(id="2", type="wiki" ))
         result = self.whoosh_backend.query(
             query.Every(),
-            filter=[("type", "ticket")],
+            filter=query.Term("type", "ticket"),
             facets=["type"]
         )
         self.print_result(result)
         self.assertEqual(1, result.hits)
         self.assertEqual("ticket", result.docs[0]["type"])
 
-
     @unittest.skip("TODO clarify behavior on Whoosh mail list")
     def test_can_search_id_and_summary_TODO(self):
         #arrange
@@ -275,6 +277,67 @@ class WhooshBackendTestCase(BaseBloodhou
         self.print_result(result)
         self.assertEqual(2, result.hits)
 
+    def test_no_index_error_when_counting_facet_on_missing_field(self):
+        """
+        Whoosh 2.4.1 raises "IndexError: list index out of range"
+        when search contains facets on field that is missing in at least one
+        document in the index. The error manifests only when index contains
+        more than one segment
+
+        Introduced workaround should solve this problem.
+        """
+        #add more tickets to make sure we have more than one segment in index
+        count = 20
+        for i in range(count):
+            self.insert_ticket("test %s" % (i))
+
+        result = self.whoosh_backend.query(
+            query.Every(),
+            facets=["milestone"]
+        )
+        self.assertEquals(count, result.hits)
+
+    def test_can_query_missing_field_and_type(self):
+        self.whoosh_backend.add_doc(dict(id="1", type="ticket"))
+        self.whoosh_backend.add_doc(dict(id="2", type="ticket", milestone="A"))
+        self.whoosh_backend.add_doc(dict(id="3", type="wiki"))
+        filter = self.parser.parse_filters(["NOT (milestone:*)", "type:ticket"])
+        result = self.whoosh_backend.query(
+            query.Every(),
+            filter=filter,
+        )
+        self.print_result(result)
+        self.assertEqual(1, result.hits)
+        facets = result.docs[0]
+        self.assertEqual("1", result.docs[0]["id"])
+
+
+    def test_can_query_missing_field(self):
+        self.whoosh_backend.add_doc(dict(id="1", type="ticket"))
+        self.whoosh_backend.add_doc(dict(id="2", type="ticket", milestone="A"))
+        filter = self.parser.parse_filters(["NOT (milestone:*)"])
+        result = self.whoosh_backend.query(
+            query.Every(),
+            filter=filter,
+        )
+        self.print_result(result)
+        self.assertEqual(1, result.hits)
+        facets = result.docs[0]
+        self.assertEqual("1", result.docs[0]["id"])
+
+
+    @unittest.skip("TODO clarify behavior on Whoosh mail list")
+    def test_can_query_missing_field_and_type_with_no_results(self):
+        self.whoosh_backend.add_doc(dict(id="1", type="ticket"))
+        self.whoosh_backend.add_doc(dict(id="3", type="wiki"))
+        filter = self.parser.parse_filters(["NOT (milestone:*)", "type:ticket"])
+        result = self.whoosh_backend.query(
+            query.Every(),
+            filter=filter,
+        )
+        self.print_result(result)
+        self.assertEqual(0, result.hits)
+
 class WhooshFunctionalityTestCase(unittest.TestCase):
     def setUp(self):
         self.index_dir = tempfile.mkdtemp('whoosh_index')
@@ -293,10 +356,10 @@ class WhooshFunctionalityTestCase(unitte
 
         ix = index.create_in(self.index_dir, schema=schema)
         with ix.writer() as w:
-            w.add_document(unique_id="1",type="type1")
-            w.add_document(unique_id="2",type="type2", status="New")
+            w.add_document(unique_id=u"1", type=u"type1")
+            w.add_document(unique_id=u"2", type=u"type2", status=u"New")
 
-        facet_fields = ("type", "status" )
+        facet_fields = (u"type", u"status" )
         groupedby = facet_fields
         with ix.searcher() as s:
             r = s.search(
@@ -311,7 +374,7 @@ class WhooshFunctionalityTestCase(unitte
             {'status': {None: 1, 'New': 1}, 'type': {'type1': 1, 'type2': 1}},
             facets)
 
-    def test_groupedby_empty_field(self):
+    def test_can_use_query_and_groupedby_empty_field(self):
         """
         Whoosh 2.4 raises an error when simultaneously using filters and facets
         in search:
@@ -331,8 +394,8 @@ class WhooshFunctionalityTestCase(unitte
 
         ix = index.create_in(self.index_dir, schema=schema)
         with ix.writer() as w:
-            w.add_document(unique_id=u"1",type=u"type1")
-            w.add_document(unique_id=u"2",type=u"type2")
+            w.add_document(unique_id=u"1", type=u"type1")
+            w.add_document(unique_id=u"2", type=u"type2")
 
         with ix.searcher() as s:
             with self.assertRaises(AttributeError):
@@ -343,13 +406,42 @@ class WhooshFunctionalityTestCase(unitte
                     filter=query.Term("type", "type1")
                 )
 
-#    def _prepare_groupedby(self, facets):
-#        if not facets:
-#            return None
-#        groupedby = sorting.Facets()
-#        for facet_name in facets:
-#            groupedby.add_field(facet_name, allow_overlap=True, maptype=sorting.Count)
-#        return groupedby
+    def test_out_of_range_on_empty_facets(self):
+        """
+        Whoosh raises exception IndexError: list index out of range
+        when search contains facets on field that is missing in at least one
+        document in the index. The error manifests only when index contains
+        more than one segment
+
+        The problem expected to be fixed in the next release.
+
+        For the time of being, whoosh-backend have to introduce workaround in
+        order to fix the problem. This unit-test is just a reminder to remove
+        workaround when the fixed version of Whoosh is applied.
+        """
+        schema = Schema(
+                unique_id=ID(stored=True, unique=True),
+                status=ID(stored=True),
+                )
+
+#        ix = RamStorage().create_index(schema)
+        ix = index.create_in(self.index_dir, schema=schema)
+        def insert_docs():
+            with ix.writer() as w:
+                for i in range(10):
+                    w.add_document(unique_id=unicode(i))
+
+        #the problem occurs only when index contains more than one segment
+        insert_docs()
+        insert_docs()
+
+        with ix.searcher() as s:
+            with self.assertRaises(IndexError):
+                s.search(
+                    query.Every(),
+                    groupedby=(u"status"),
+                    maptype=sorting.Count,
+                )
 
     def _load_facets(self, non_paged_results):
         facet_names = non_paged_results.facet_names()
@@ -361,11 +453,57 @@ class WhooshFunctionalityTestCase(unitte
         return facets_result
 
 
+class WhooshEmptyFacetErrorWorkaroundTestCase(BaseBloodhoundSearchTest):
+    def setUp(self):
+        self.env = EnvironmentStub(enable=['bhsearch.*'])
+        self.env.path = tempfile.mkdtemp('bhsearch-tempenv')
+        self.whoosh_backend = WhooshBackend(self.env)
+        self.whoosh_backend.recreate_index()
+        self.parser = DefaultQueryParser(self.env)
+        self.empty_facet_workaround = WhooshEmptyFacetErrorWorkaround(self.env)
+
+    def tearDown(self):
+        shutil.rmtree(self.env.path)
+        self.env.reset_db()
+
+    def test_set_should_not_be_empty_fields(self):
+        self.insert_ticket("test x")
+        result = self.whoosh_backend.query(query.Every())
+        self.print_result(result)
+        doc = result.docs[0]
+        null_marker = WhooshEmptyFacetErrorWorkaround.NULL_MARKER
+        self.assertEqual(null_marker, doc["component"])
+        self.assertEqual(null_marker, doc["status"])
+        self.assertEqual(null_marker, doc["milestone"])
+
+    def test_can_fix_query_filter(self):
+        parsed_filter = self.parser.parse_filters(
+            ["type:ticket", "NOT (milestone:*)"])
+        query_parameters = dict(filter=parsed_filter)
+        self.empty_facet_workaround.query_pre_process(
+            query_parameters)
+
+        result_filter = query_parameters["filter"]
+        print result_filter
+        self.assertEquals('(type:ticket AND milestone:empty)', str(result_filter))
+
+    def test_does_interfere_query_filter_if_not_needed(self):
+        parsed_filter = self.parser.parse_filters(
+            ["type:ticket", "milestone:aaa"])
+        query_parameters = dict(filter=parsed_filter)
+        self.empty_facet_workaround.query_pre_process(
+            query_parameters)
+
+        result_filter = query_parameters["filter"]
+        print result_filter
+        self.assertEquals('(type:ticket AND milestone:aaa)', str(result_filter))
 
 def suite():
     suite = unittest.TestSuite()
     suite.addTest(unittest.makeSuite(WhooshBackendTestCase, 'test'))
     suite.addTest(unittest.makeSuite(WhooshFunctionalityTestCase, 'test'))
+    suite.addTest(unittest.makeSuite(WhooshEmptyFacetErrorWorkaroundTestCase,
+        'test'))
     return suite
 
 if __name__ == '__main__':

Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/wiki_search.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/wiki_search.py?rev=1437987&r1=1437986&r2=1437987&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/wiki_search.py (original)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/tests/wiki_search.py Thu Jan 24 13:12:01 2013
@@ -18,9 +18,9 @@
 #  specific language governing permissions and limitations
 #  under the License.
 import shutil
-
 import unittest
 import tempfile
+
 from bhsearch.api import BloodhoundSearchApi
 from bhsearch.query_parser import DefaultQueryParser
 from bhsearch.tests.utils import BaseBloodhoundSearchTest
@@ -70,7 +70,7 @@ class WikiIndexerEventsTestCase(BaseBloo
         shutil.rmtree(self.env.path)
         self.env.reset_db()
 
-    def test_can_index_on_new_page(self):
+    def test_can_add_new_wiki_page_to_index(self):
         #arrange
         self.insert_wiki(self.DUMMY_PAGE_NAME, "dummy text")
         #act
@@ -83,7 +83,7 @@ class WikiIndexerEventsTestCase(BaseBloo
         self.assertEqual("dummy text", doc["content"])
         self.assertEqual("wiki", doc["type"])
 
-    def test_can_delete_page(self):
+    def test_can_delete_wiki_page_from_index(self):
         #arrange
         self.insert_wiki(self.DUMMY_PAGE_NAME)
         WikiPage(self.env, self.DUMMY_PAGE_NAME).delete()
@@ -119,17 +119,6 @@ class WikiIndexerEventsTestCase(BaseBloo
         self.assertEqual(1, results.hits)
         self.assertEqual("NewPageName", results.docs[0]["id"])
 
-    def test_can_index_deleted_event(self):
-        #arrange
-        self.insert_wiki(self.DUMMY_PAGE_NAME)
-        page = WikiPage(self.env, self.DUMMY_PAGE_NAME)
-        page.delete()
-        #act
-        results = self.search_api.query("*:*")
-        #assert
-        self.print_result(results)
-        self.assertEqual(0, results.hits)
-
     def test_can_index_version_deleted_event(self):
         #arrange
         self.insert_wiki(self.DUMMY_PAGE_NAME, "version1")
@@ -144,11 +133,10 @@ class WikiIndexerEventsTestCase(BaseBloo
         self.assertEqual(1, results.hits)
         self.assertEqual("version1", results.docs[0]["content"])
 
-
-
 def suite():
     suite = unittest.TestSuite()
-    suite.addTest(unittest.makeSuite(WikiIndexerSilenceOnExceptionTestCase, 'test'))
+    suite.addTest(unittest.makeSuite(
+        WikiIndexerSilenceOnExceptionTestCase, 'test'))
     suite.addTest(unittest.makeSuite(WikiIndexerEventsTestCase, 'test'))
     return suite
 

Modified: incubator/bloodhound/trunk/bloodhound_search/bhsearch/ticket_search.py
URL: http://svn.apache.org/viewvc/incubator/bloodhound/trunk/bloodhound_search/bhsearch/ticket_search.py?rev=1437987&r1=1437986&r2=1437987&view=diff
==============================================================================
--- incubator/bloodhound/trunk/bloodhound_search/bhsearch/ticket_search.py (original)
+++ incubator/bloodhound/trunk/bloodhound_search/bhsearch/ticket_search.py Thu Jan 24 13:12:01 2013
@@ -19,21 +19,21 @@
 #  under the License.
 
 r"""Ticket specifics for Bloodhound Search plugin."""
-from bhsearch.api import ISearchParticipant, BloodhoundSearchApi, IIndexParticipant, IndexFields
+from bhsearch.api import ISearchParticipant, BloodhoundSearchApi, \
+    IIndexParticipant, IndexFields
+from bhsearch.base import BaseIndexer
 from genshi.builder import tag
-from trac.core import *
 from trac.ticket.api import ITicketChangeListener
 from trac.ticket import Ticket
 from trac.ticket.query import Query
-from trac.config import Option
+from trac.config import ListOption
+from trac.core import implements, Component
 
-TICKET_TYPE = "ticket"
-TICKET_STATUS = "status"
+TICKET_TYPE = u"ticket"
+TICKET_STATUS = u"status"
 
-class TicketIndexer(Component):
+class TicketIndexer(BaseIndexer):
     implements(ITicketChangeListener, IIndexParticipant)
-    silence_on_error = Option('bhsearch', 'silence_on_error', "True",
-        """If true, do not throw an exception during indexing a resource""")
 
     #ITicketChangeListener methods
     def ticket_created(self, ticket):
@@ -50,8 +50,8 @@ class TicketIndexer(Component):
             search_api = BloodhoundSearchApi(self.env)
             search_api.delete_doc(TICKET_TYPE, ticket.id)
         except Exception, e:
-            if self.silence_on_error.lower() == "true":
-                self.log.error("Error occurs during ticket indexing. \
+            if self.silence_on_error:
+                self.log.error("Error occurs during deleting ticket. \
                     The error will not be propagated. Exception: %s", e)
             else:
                 raise
@@ -59,14 +59,13 @@ class TicketIndexer(Component):
     def _index_ticket(
             self,
             ticket,
-            raise_exception = False,
             ):
         try:
             search_api = BloodhoundSearchApi(self.env)
             doc = self.build_doc(ticket)
             search_api.add_doc(doc)
         except Exception, e:
-            if (not raise_exception) and self.silence_on_error.lower() == "true":
+            if self.silence_on_error:
                 self.log.error("Error occurs during ticket indexing. \
                     The error will not be propagated. Exception: %s", e)
             else:
@@ -80,23 +79,24 @@ class TicketIndexer(Component):
             IndexFields.TYPE: TICKET_TYPE,
             IndexFields.TIME: ticket.time_changed,
             }
-        fields = [('component',),
-                  ('description',IndexFields.CONTENT),
-                  ('keywords',),
-                  ('milestone',),
-                  ('summary',),
-                  ('status',),
-                  ('resolution',),
-                  ('reporter',IndexFields.AUTHOR),
+        fields = [
+            ('component',),
+              ('description',IndexFields.CONTENT),
+              ('keywords',),
+              ('milestone',),
+              ('summary',),
+              ('status', TICKET_STATUS),
+              ('resolution',),
+              ('reporter',IndexFields.AUTHOR),
         ]
         for f in fields:
-          if f[0] in ticket.values:
-              if len(f) == 1:
-                  doc[f[0]] = ticket.values[f[0]]
-              elif len(f) == 2:
-                  doc[f[1]] = ticket.values[f[0]]
+            if f[0] in ticket.values:
+                if len(f) == 1:
+                    doc[f[0]] = ticket.values[f[0]]
+                elif len(f) == 2:
+                    doc[f[1]] = ticket.values[f[0]]
         doc['changes'] = u'\n\n'.join([x[4] for x in ticket.get_changelog()
-          if x[2] == u'comment'])
+                                       if x[2] == u'comment'])
         return doc
 
     def get_entries_for_index(self):
@@ -114,6 +114,10 @@ class TicketIndexer(Component):
 class TicketSearchParticipant(Component):
     implements(ISearchParticipant)
 
+    default_facets = ListOption('bhsearch', 'default_facets_ticket',
+                            'status,milestone,component',
+        doc="""Default facets applied to search through tickets""")
+
     #ISearchParticipant members
     def get_search_filters(self, req=None):
         if not req or 'TICKET_VIEW' in req.perm:
@@ -122,12 +126,15 @@ class TicketSearchParticipant(Component)
     def get_title(self):
         return "Ticket"
 
+    def get_default_facets(self):
+        return self.default_facets
+
     def format_search_results(self, res):
         if not TICKET_STATUS in res:
-          stat = 'undefined_status'
-          class_ = 'undefined_status'
+            stat = 'undefined_status'
+            css_class = 'undefined_status'
         else:
-            class_= res[TICKET_STATUS]
+            css_class = res[TICKET_STATUS]
             if res[TICKET_STATUS] == 'closed':
                 resolution = ""
                 if 'resolution' in res:
@@ -136,6 +143,6 @@ class TicketSearchParticipant(Component)
             else:
                 stat = res[TICKET_STATUS]
 
-        id = tag(tag.span('#'+res['id'], class_=class_))
+        id = tag(tag.span('#'+res['id'], class_=css_class))
         return id + ': %s (%s)' % (res['summary'], stat)