blob: 65973ec43ba90be64301cf861a24792cc313f0ca [file] [log] [blame]
#!/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"""Ticket specifics for Bloodhound Search plugin."""
from bhsearch import BHSEARCH_CONFIG_SECTION
from bhsearch.api import (ISearchParticipant, BloodhoundSearchApi,
IIndexParticipant, IndexFields)
from bhsearch.search_resources.base import BaseIndexer, BaseSearchParticipant
from bhsearch.utils import get_product
from genshi.builder import tag
from trac.ticket.api import TicketSystem
from trac.ticket import Ticket
from trac.config import ListOption, Option
from trac.core import implements
from trac.resource import IResourceChangeListener
from trac.ticket.model import Component
TICKET_TYPE = u"ticket"
class TicketFields(IndexFields):
SUMMARY = "summary"
MILESTONE = 'milestone'
COMPONENT = 'component'
KEYWORDS = "keywords"
RESOLUTION = "resolution"
CHANGES = 'changes'
OWNER = 'owner'
class TicketIndexer(BaseIndexer):
implements(IResourceChangeListener, IIndexParticipant)
optional_fields = {
'component': TicketFields.COMPONENT,
'description': TicketFields.CONTENT,
'keywords': TicketFields.KEYWORDS,
'milestone': TicketFields.MILESTONE,
'summary': TicketFields.SUMMARY,
'status': TicketFields.STATUS,
'resolution': TicketFields.RESOLUTION,
'reporter': TicketFields.AUTHOR,
'owner': TicketFields.OWNER,
}
def __init__(self):
self.fields = TicketSystem(self.env).get_ticket_fields()
self.text_area_fields = set(
f['name'] for f in self.fields if f['type'] =='textarea')
#IResourceChangeListener methods
def match_resource(self, resource):
if isinstance(resource, (Component, Ticket)):
return True
return False
def resource_created(self, resource, context):
# pylint: disable=unused-argument
if isinstance(resource, Ticket):
self._index_ticket(resource)
def resource_changed(self, resource, old_values, context):
# pylint: disable=unused-argument
if isinstance(resource, Ticket):
self._index_ticket(resource)
elif isinstance(resource, Component):
self._component_changed(resource, old_values)
def resource_deleted(self, resource, context):
# pylint: disable=unused-argument
if isinstance(resource, Ticket):
self._ticket_deleted(resource)
def resource_version_deleted(self, resource, context):
pass
def _component_changed(self, component, old_values):
if "name" in old_values:
old_name = old_values["name"]
try:
search_api = BloodhoundSearchApi(self.env)
with search_api.start_operation() as operation_context:
TicketIndexer(self.env).reindex_tickets(
search_api,
operation_context,
component=component.name)
except Exception, e:
if self.silence_on_error:
self.log.error("Error occurs during renaming Component \
from %s to %s. The error will not be propagated. \
Exception: %s",
old_name, component.name, e)
else:
raise
def _ticket_deleted(self, ticket):
"""Called when a ticket is deleted."""
try:
search_api = BloodhoundSearchApi(self.env)
search_api.delete_doc(ticket.product, TICKET_TYPE, ticket.id)
except Exception, e:
if self.silence_on_error:
self.log.error("Error occurs during deleting ticket. \
The error will not be propagated. Exception: %s", e)
else:
raise
def reindex_tickets(self,
search_api,
operation_context,
**kwargs):
for ticket in self._fetch_tickets(**kwargs):
self._index_ticket(ticket, search_api, operation_context)
def _fetch_tickets(self, **kwargs):
for ticket_id in self._fetch_ids(**kwargs):
yield Ticket(self.env, ticket_id)
def _fetch_ids(self, **kwargs):
sql = "SELECT id FROM ticket"
args = []
conditions = []
for key, value in kwargs.iteritems():
args.append(value)
conditions.append(key + "=%s")
if conditions:
sql = sql + " WHERE " + " AND ".join(conditions)
for row in self.env.db_query(sql, args):
yield int(row[0])
def _index_ticket(self, ticket, search_api=None, operation_context=None):
try:
if not search_api:
search_api = BloodhoundSearchApi(self.env)
doc = self.build_doc(ticket)
search_api.add_doc(doc, operation_context)
except Exception, e:
if self.silence_on_error:
self.log.error("Error occurs during ticket indexing. \
The error will not be propagated. Exception: %s", e)
else:
raise
#IIndexParticipant members
def build_doc(self, trac_doc):
ticket = trac_doc
searchable_name = '#%(ticket.id)s %(ticket.id)s' %\
{'ticket.id': ticket.id}
doc = {
IndexFields.ID: str(ticket.id),
IndexFields.NAME: searchable_name,
'_stored_' + IndexFields.NAME: str(ticket.id),
IndexFields.TYPE: TICKET_TYPE,
IndexFields.TIME: ticket.time_changed,
IndexFields.PRODUCT: get_product(self.env).prefix,
}
# TODO: Add support for moving tickets between products.
for field, index_field in self.optional_fields.iteritems():
if field in ticket.values:
field_content = ticket.values[field]
if field in self.text_area_fields:
field_content = self.wiki_formatter.format(field_content)
doc[index_field] = field_content
doc[TicketFields.CHANGES] = u'\n\n'.join(
[self.wiki_formatter.format(x[4]) for x in ticket.get_changelog()
if x[2] == u'comment'])
return doc
def get_entries_for_index(self):
for ticket in self._fetch_tickets():
yield self.build_doc(ticket)
class TicketSearchParticipant(BaseSearchParticipant):
implements(ISearchParticipant)
participant_type = TICKET_TYPE
required_permission = 'TICKET_VIEW'
default_facets = [
IndexFields.PRODUCT,
TicketFields.STATUS,
TicketFields.MILESTONE,
TicketFields.COMPONENT,
]
default_grid_fields = [
TicketFields.ID,
TicketFields.SUMMARY,
TicketFields.STATUS,
TicketFields.MILESTONE,
TicketFields.COMPONENT,
]
prefix = TICKET_TYPE
default_facets = ListOption(
BHSEARCH_CONFIG_SECTION,
prefix + '_default_facets',
default=",".join(default_facets),
doc="""Default facets applied to search view of specific resource""")
default_view = Option(
BHSEARCH_CONFIG_SECTION,
prefix + '_default_view',
doc = """If true, show grid as default view for specific resource in
Bloodhound Search results""")
default_grid_fields = ListOption(
BHSEARCH_CONFIG_SECTION,
prefix + '_default_grid_fields',
default = ",".join(default_grid_fields),
doc="""Default fields for grid view for specific resource""")
#ISearchParticipant members
def get_title(self):
return "Ticket"
def format_search_results(self, res):
if not TicketFields.STATUS in res:
stat = 'undefined_status'
css_class = 'undefined_status'
else:
css_class = res[TicketFields.STATUS]
if res[TicketFields.STATUS] == 'closed':
resolution = ""
if 'resolution' in res:
resolution = res['resolution']
stat = '%s: %s' % (res['status'], resolution)
else:
stat = res[TicketFields.STATUS]
id = res['hilited_id'] or res['id']
id = tag.span('#', id, class_=css_class)
summary = res['hilited_summary'] or res['summary']
return tag('[', res['product'], '] ', id, ': ', summary, ' (%s)' % stat)