appypod-rattail/fields/search.py

416 lines
19 KiB
Python
Raw Normal View History

2013-08-21 06:54:56 -05:00
# ------------------------------------------------------------------------------
# This file is part of Appy, a framework for building applications in the Python
# language. Copyright (C) 2007 Gaetan Delannay
# Appy is free software; you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
# Appy is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License along with
# Appy. If not, see <http://www.gnu.org/licenses/>.
# ------------------------------------------------------------------------------
from appy.px import Px
from appy.gen import utils as gutils
from appy.gen.indexer import defaultIndexes
from appy.shared import utils as sutils
from group import Group
# ------------------------------------------------------------------------------
class Search:
'''Used for specifying a search for a given class.'''
def __init__(self, name=None, group=None, sortBy='', sortOrder='asc',
maxPerPage=30, default=False, colspan=1, translated=None,
show=True, showActions=True, translatedDescr=None,
checkboxes=False, checkboxesDefault=True, **fields):
# "name" is mandatory, excepted in some special cases (ie, when used as
# "select" param for a Ref field).
2013-08-21 06:54:56 -05:00
self.name = name
# Searches may be visually grouped in the portlet.
self.group = Group.get(group)
self.sortBy = sortBy
self.sortOrder = sortOrder
self.maxPerPage = maxPerPage
2013-08-21 06:54:56 -05:00
# If this search is the default one, it will be triggered by clicking
# on main link.
self.default = default
self.colspan = colspan
# If a translated name or description is already given here, we will
# use it instead of trying to translate from labels.
self.translated = translated
self.translatedDescr = translatedDescr
# Condition for showing or not this search
self.show = show
# Condition for showing or not actions on every result of this search.
# Can be: True, False or "inline". If True, actions will appear in a
# "div" tag, below the object title; if "inline", they will appear
# besides it, producing a more compact list of results.
self.showActions = showActions
2013-08-21 06:54:56 -05:00
# In the dict below, keys are indexed field names or names of standard
# indexes, and values are search values.
self.fields = fields
# Do we need to display checkboxes for every object of the query result?
self.checkboxes = checkboxes
# Default value for checkboxes
self.checkboxesDefault = checkboxesDefault
2013-08-21 06:54:56 -05:00
@staticmethod
def getIndexName(name, klass, usage='search'):
'''Gets the name of the Zope index that corresponds to p_name. Indexes
can be used for searching (p_usage="search") or for sorting
(usage="sort"). The method returns None if the field named
p_name can't be used for p_usage.'''
# Manage indexes that do not have a corresponding field
if name == 'created': return 'Created'
elif name == 'modified': return 'Modified'
elif name in defaultIndexes: return name
2013-08-21 06:54:56 -05:00
else:
# Manage indexes corresponding to fields
field = getattr(klass, name, None)
if field: return field.getIndexName(usage)
2013-08-21 06:54:56 -05:00
@staticmethod
def getSearchValue(fieldName, fieldValue, klass):
'''Returns a transformed p_fieldValue for producing a valid search
value as required for searching in the index corresponding to
p_fieldName.'''
field = getattr(klass, fieldName, None)
if (field and (field.getIndexType() == 'TextIndex')) or \
(fieldName == 'SearchableText'):
# For TextIndex indexes. We must split p_fieldValue into keywords.
res = gutils.Keywords(fieldValue).get()
elif isinstance(fieldValue, basestring) and fieldValue.endswith('*'):
v = fieldValue[:-1]
# Warning: 'z' is higher than 'Z'!
res = {'query':(v,v+'z'), 'range':'min:max'}
elif type(fieldValue) in sutils.sequenceTypes:
if fieldValue and isinstance(fieldValue[0], basestring):
# We have a list of string values (ie: we need to
# search v1 or v2 or...)
res = fieldValue
else:
# We have a range of (int, float, DateTime...) values
minv, maxv = fieldValue
rangev = 'minmax'
queryv = fieldValue
if minv == None:
rangev = 'max'
queryv = maxv
elif maxv == None:
rangev = 'min'
queryv = minv
res = {'query':queryv, 'range':rangev}
else:
res = fieldValue
return res
def updateSearchCriteria(self, criteria, klass, advanced=False):
'''This method updates dict p_criteria with all the search criteria
corresponding to this Search instance. If p_advanced is True,
p_criteria correspond to an advanced search, to be stored in the
session: in this case we need to keep the Appy names for parameters
sortBy and sortOrder (and not "resolve" them to Zope's sort_on and
sort_order).'''
# Put search criteria in p_criteria
for name, value in self.fields.iteritems():
2013-08-21 06:54:56 -05:00
# Management of searches restricted to objects linked through a
# Ref field: not implemented yet.
if name == '_ref': continue
2013-08-21 06:54:56 -05:00
# Make the correspondence between the name of the field and the
# name of the corresponding index, excepted if advanced is True: in
# that case, the correspondence will be done later.
if not advanced:
indexName = Search.getIndexName(name, klass)
2013-08-21 06:54:56 -05:00
# Express the field value in the way needed by the index
criteria[indexName] = Search.getSearchValue(name, value, klass)
2013-08-21 06:54:56 -05:00
else:
criteria[name] = value
2013-08-21 06:54:56 -05:00
# Add a sort order if specified
if self.sortBy:
c = criteria
2013-08-21 06:54:56 -05:00
if not advanced:
c['sort_on']=Search.getIndexName(self.sortBy,klass,usage='sort')
c['sort_order']= (self.sortOrder=='desc') and 'reverse' or None
2013-08-21 06:54:56 -05:00
else:
c['sortBy'] = self.sortBy
c['sortOrder'] = self.sortOrder
2013-08-21 06:54:56 -05:00
def isShowable(self, klass, tool):
'''Is this Search instance (defined in p_klass) showable?'''
if self.show.__class__.__name__ == 'staticmethod':
return gutils.callMethod(tool, self.show, klass=klass)
return self.show
def getSessionKey(self, className, full=True):
'''Returns the name of the key, in the session, where results for this
search are stored when relevant. If p_full is False, only the suffix
of the session key is returned (ie, without the leading
"search_").'''
res = (self.name == 'allSearch') and className or self.name
if not full: return res
return 'search_%s' % res
2013-08-21 06:54:56 -05:00
class UiSearch:
'''Instances of this class are generated on-the-fly for manipulating a
Search from the User Interface.'''
# Rendering a search
2013-08-21 06:54:56 -05:00
pxView = Px('''
<div class="portletSearch">
<a href=":'%s?className=%s&amp;search=%s' % \
(queryUrl, className, search.name)"
class=":(search.name == currentSearch) and 'current' or ''"
2013-08-21 15:25:27 -05:00
title=":search.translatedDescr">:search.translated</a>
2013-08-21 06:54:56 -05:00
</div>''')
# Search results, as a list (used by pxResult below)
pxResultList = Px('''
<x var="showHeaders=showHeaders|True;
checkboxes=uiSearch.search.checkboxes;
checkboxesId=rootHookId + '_objs';
cbShown=uiSearch.showCheckboxes();
cbDisplay=cbShown and 'table-cell' or 'none'">
<script>:uiSearch.getAjaxData(ajaxHookId, ztool, popup=inPopup, \
checkboxes=checkboxes, checkboxesId=checkboxesId, \
cbDisplay=cbDisplay, startNumber=startNumber, \
totalNumber=totalNumber)</script>
<table class="list" width="100%">
<!-- Headers, with filters and sort arrows -->
<tr if="showHeaders">
<th if="checkboxes" class="cbCell" style=":'display:%s' % cbDisplay">
<img src=":url('checkall')" class="clickable"
title=":_('check_uncheck')"
onclick=":'toggleAllCbs(%s)' % q(checkboxesId)"/>
</th>
<th for="column in columns"
var2="field=column.field;
sortable=field.isSortable(usage='search');
filterable=field.filterable"
width=":column.width" align=":column.align">
<x>::ztool.truncateText(_(field.labelId))</x>
<x if="(totalNumber &gt; 1) or filterValue">:tool.pxSortAndFilter</x>
<x>:tool.pxShowDetails</x>
</th>
</tr>
<!-- Results -->
<tr if="not zobjects">
<td colspan=":len(columns)+1">:_('query_no_result')</td>
</tr>
<x for="zobj in zobjects"
var2="@currentNumber=currentNumber + 1;
rowCss=loop.zobj.odd and 'even' or 'odd'">:obj.pxViewAsResult</x>
</table>
<!-- The button for selecting objects and closing the popup -->
<div if="inPopup and cbShown" align=":dleft">
<input type="button"
var="label=_('object_link_many'); css=ztool.getButtonCss(label)"
value=":label" class=":css" style=":url('linkMany', bg=True)"
onclick=":'onSelectObjects(%s,%s,%s,%s,%s,%s,%s)' % \
(q(rootHookId), q(uiSearch.initiator.url), \
q(uiSearch.initiatorMode), q(sortKey), q(sortOrder), \
q(filterKey), q(filterValue))"/>
</div>
<!-- Init checkboxes if present -->
<script if="checkboxes">:'initCbs(%s)' % q(checkboxesId)</script>
<script>:'initFocus(%s)' % q(ajaxHookId)</script></x>''')
# Search results, as a grid (used by pxResult below)
pxResultGrid = Px('''
<table width="100%"
var="modeElems=resultMode.split('_');
cols=(len(modeElems)==2) and int(modeElems[1]) or 4;
rows=ztool.splitList(zobjects, cols)">
<tr for="row in rows" valign="middle">
<td for="zobj in row" width=":'%d%%' % (100/cols)" align="center"
style="padding-top: 25px"
var2="obj=zobj.appy(); mayView=zobj.mayView()">
<x var="@currentNumber=currentNumber + 1"
for="column in columns"
var2="field=column.field">:field.pxRenderAsResult</x>
</td>
</tr>
</table>''')
# Render search results
pxResult = Px('''
<div var="ajaxHookId='queryResult';
className=req['className'];
searchName=req.get('search', '');
uiSearch=uiSearch|ztool.getSearch(className,searchName,ui=True);
rootHookId=uiSearch.getRootHookId();
refInfo=ztool.getRefInfo();
refObject=refInfo[0];
refField=refInfo[1];
refUrlPart=refObject and ('&amp;ref=%s:%s' % (refObject.id, \
refField)) or '';
startNumber=req.get('startNumber', '0');
startNumber=int(startNumber);
sortKey=req.get('sortKey', '');
sortOrder=req.get('sortOrder', 'asc');
filterKey=req.get('filterKey', '');
filterValue=req.get('filterValue', '');
queryResult=ztool.executeQuery(className, \
search=uiSearch.search, startNumber=startNumber, \
remember=True, sortBy=sortKey, sortOrder=sortOrder, \
filterKey=filterKey, filterValue=filterValue, \
refObject=refObject, refField=refField);
zobjects=queryResult.objects;
totalNumber=queryResult.totalNumber;
batchSize=queryResult.batchSize;
batchNumber=len(zobjects);
navBaseCall='askQueryResult(%s,%s,%s,%s,%s,**v**)' % \
(q(ajaxHookId), q(ztool.absolute_url()), q(className), \
q(searchName),int(inPopup));
showNewSearch=showNewSearch|True;
newSearchUrl='%s/search?className=%s%s' % \
(ztool.absolute_url(), className, refUrlPart);
showSubTitles=req.get('showSubTitles', 'true') == 'true';
klass=ztool.getAppyClass(className);
resultMode=uiSearch.getResultMode(klass);
target=ztool.getLinksTargetInfo(klass)"
id=":ajaxHookId">
<x if="zobjects or filterValue">
<!-- Display here POD templates if required -->
<table var="fields=ztool.getResultPodFields(className);
layoutType='view'"
if="not inPopup and zobjects and fields" align=":dright">
<tr>
<td var="zobj=zobjects[0]; obj=zobj.appy()"
for="field in fields"
class=":not loop.field.last and 'pod' or ''">:field.pxRender</td>
</tr>
</table>
<!-- The title of the search -->
<p if="not inPopup">
<x>::uiSearch.translated</x> (<span class="discreet">:totalNumber</span>)
<x if="showNewSearch and (searchName == 'customSearch')">&nbsp;&mdash;
&nbsp;<i><a href=":newSearchUrl">:_('search_new')</a></i>
</x>
</p>
<table width="100%">
<tr valign="top">
<!-- Search description -->
<td if="uiSearch.translatedDescr">
<span class="discreet">:uiSearch.translatedDescr</span><br/>
</td>
<!-- (Top) navigation -->
<td align=":dright" width="150px">:tool.pxNavigate</td>
</tr>
</table>
<!-- Results, as a list or grid -->
<x var="columnLayouts=ztool.getResultColumnsLayouts(className, refInfo);
columns=ztool.getColumnsSpecifiers(className,columnLayouts,dir);
currentNumber=0">
<x if="resultMode == 'list'">:uiSearch.pxResultList</x>
<x if="resultMode != 'list'">:uiSearch.pxResultGrid</x>
</x>
<!-- (Bottom) navigation -->
<x>:tool.pxNavigate</x>
</x>
<x if="not zobjects and not filterValue">
<x>:_('query_no_result')</x>
<x if="showNewSearch and (searchName == 'customSearch')"><br/>
<i class="discreet"><a href=":newSearchUrl">:_('search_new')</a></i></x>
</x>
</div>''')
2013-08-21 06:54:56 -05:00
def __init__(self, search, className, tool):
self.search = search
self.name = search.name
self.type = 'search'
self.colspan = search.colspan
self.className = className
# Property "display" of the div tag containing actions for every search
# result.
self.showActions = search.showActions
if search.showActions == True: self.showActions = 'block'
2013-08-21 06:54:56 -05:00
if search.translated:
self.translated = search.translated
self.translatedDescr = search.translatedDescr
else:
# The label may be specific in some special cases
2013-08-21 06:54:56 -05:00
labelDescr = ''
if search.name == 'allSearch':
label = '%s_plural' % className
elif search.name == 'customSearch':
label = 'search_results'
elif search.name == '_field_':
label = None
2013-08-21 06:54:56 -05:00
else:
label = '%s_search_%s' % (className, search.name)
labelDescr = label + '_descr'
_ = tool.translate
self.translated = label and _(label) or ''
self.translatedDescr = labelDescr and _(labelDescr) or ''
def setInitiator(self, initiator, field, mode):
'''If the search is defined in an attribute Ref.select, we receive here
the p_initiator object, its Ref p_field and the p_mode, that can be:
- "repl" if the objects selected in the popup will replace already
tied objects;
- "add" if those objects will be added to the already tied ones.
.'''
self.initiator = initiator
self.initiatorField = field
self.initiatorMode = mode
# "initiatorHook" is the ID of the initiator field's XHTML tag.
self.initiatorHook = '%s_%s' % (initiator.uid, field.name)
def getRootHookId(self):
'''If an initiator field is there, return the initiator hook.
Else, simply return the name of the search.'''
return getattr(self, 'initiatorHook', self.name)
def getResultMode(self, klass):
'''Must we show, on pxResult, instances of p_klass as a list or
as a grid?'''
return getattr(klass, 'resultMode', 'list')
def showCheckboxes(self):
'''If checkboxes are enabled for this search (and if an initiator field
is there), they must be visible only if the initiator field is
multivalued. Indeed, if it is not the case, it has no sense to select
multiple objects. But in this case, we still want checkboxes to be in
the DOM because they store object UIDs.'''
if not self.search.checkboxes: return
return not self.initiator or self.initiatorField.isMultiValued()
def getCbJsInit(self, hookId):
'''Returns the code that creates JS data structures for storing the
status of checkboxes for every result of this search.'''
default = self.search.checkboxesDefault and 'unchecked' or 'checked'
return '''var node=document.getElementById('%s');
node['_appy_objs_cbs'] = {};
node['_appy_objs_sem'] = '%s';''' % (hookId, default)
def getAjaxData(self, hook, ztool, **params):
'''Initializes an AjaxData object on the DOM node corresponding to
p_hook = the whole search result.'''
# Complete params with default parameters
params['className'] = self.className
params['searchName'] = self.name
params = sutils.getStringDict(params)
return "getAjaxHook('%s',true)['ajax']=new AjaxData('%s', " \
"'pxResult', %s, null, '%s')" % \
(hook, hook, params, ztool.absolute_url())
def getAjaxDataRow(self, zobj, parentHook, **params):
'''Initializes an AjaxData object on the DOM node corresponding to
p_hook = a row within the list of results.'''
hook = zobj.id
return "getAjaxHook('%s',true)['ajax']=new AjaxData('%s', " \
"'pxViewAsResultFromAjax',%s,'%s','%s')" % \
(hook, hook, sutils.getStringDict(params), parentHook,
zobj.absolute_url())
2013-08-21 06:54:56 -05:00
# ------------------------------------------------------------------------------