From ad8c236aa52d339f0de2710748f29f5388a97ddd Mon Sep 17 00:00:00 2001 From: Helmut Merz Date: Fri, 20 May 2011 10:55:44 +0200 Subject: [PATCH] Basic refactoring of standard search functionality. Move everything to loops.expert, making loops.search obsolete. --- browser/node.py | 4 +- configure.zcml | 2 +- expert/browser/configure.zcml | 26 +++- expert/browser/search.pt | 286 ++++++++++++++++++++++++++++++++++ expert/browser/search.py | 259 +++++++++++++++++++++++++++++- expert/search.txt | 239 ++++++++++++++++++++++++++++ expert/tests.py | 1 + organize/stateful/browser.py | 2 +- search/README.txt | 4 +- search/browser.py | 3 +- search/configure.zcml | 4 +- search/search.pt | 3 + 12 files changed, 819 insertions(+), 14 deletions(-) create mode 100755 expert/search.txt diff --git a/browser/node.py b/browser/node.py index 46cc675..731910f 100644 --- a/browser/node.py +++ b/browser/node.py @@ -108,9 +108,9 @@ class NodeView(BaseView): subMacros=[node_macros.macros['page_actions'], i18n_macros.macros['language_switch']]) if self.globalOptions('expert.quicksearch'): - from loops.expert.browser.search import searchMacrosTemplate + from loops.expert.browser.search import search_template cm.register('top_actions', 'top_quicksearch', name='multi_actions', - subMacros=[searchMacrosTemplate.macros['quicksearch']], + subMacros=[search_template.macros['quicksearch']], priority=20) cm.register('portlet_left', 'navigation', title='Navigation', subMacro=node_macros.macros['menu']) diff --git a/configure.zcml b/configure.zcml index c401be7..e902763 100644 --- a/configure.zcml +++ b/configure.zcml @@ -474,7 +474,7 @@ - + diff --git a/expert/browser/configure.zcml b/expert/browser/configure.zcml index e6fb535..cca5154 100644 --- a/expert/browser/configure.zcml +++ b/expert/browser/configure.zcml @@ -16,7 +16,31 @@ + + + + + + diff --git a/expert/browser/search.pt b/expert/browser/search.pt index 3af2d8e..992f0b7 100644 --- a/expert/browser/search.pt +++ b/expert/browser/search.pt @@ -20,6 +20,57 @@ + + + +
@@ -87,5 +138,240 @@
+
+
+
+ + + + + + +

Type(s) to search for

+ + + + + + +   + + + +
+ + + + + + +

Text-based search

+ + + + + + + +    + + + + + + +   + + +
+ + + + + + +

Search via related concepts

+ + + + + + Type: + + + + + + + + + + + + + + + +    + + + + +   + + + +
+
+ +
+ + +
+ + + + + + +

Restrict to objects with certain states

+ + + + Workflow + States + + + + + + + +   + + + +
+ + + +   + \ No newline at end of file diff --git a/expert/browser/search.py b/expert/browser/search.py index dd756d3..ff719c2 100644 --- a/expert/browser/search.py +++ b/expert/browser/search.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2009 Helmut Merz helmutm@cy55.de +# Copyright (c) 2011 Helmut Merz helmutm@cy55.de # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -28,22 +28,26 @@ from zope.app.pagetemplate import ViewPageTemplateFile from zope.cachedescriptors.property import Lazy from zope.traversing.api import getName, getParent +from cybertools.stateful.interfaces import IStateful, IStatesDefinition +from loops.browser.common import BaseView from loops.browser.node import NodeView +from loops.common import adapted, AdapterBase from loops.expert.concept import ConceptQuery, FullQuery +from loops.interfaces import IResource from loops.organize.personal.browser.filter import FilterView from loops import util from loops.util import _ -searchMacrosTemplate = ViewPageTemplateFile('search.pt') +search_template = ViewPageTemplateFile('search.pt') -class SearchResults(NodeView): +class QuickSearchResults(NodeView): """ Provides results listing """ @Lazy def search_macros(self): - return self.controller.getTemplateMacros('search', searchMacrosTemplate) + return self.controller.getTemplateMacros('search', search_template) @Lazy def macro(self): @@ -64,3 +68,250 @@ class SearchResults(NodeView): result = fv.apply(result) result.sort(key=lambda x: x.title) return self.viewIterator(result) + + +class Search(BaseView): + + maxRowNum = 0 + + @Lazy + def search_macros(self): + return self.controller.getTemplateMacros('search', search_template) + + @Lazy + def macro(self): + return self.search_macros['search'] + + @property + def rowNum(self): + """ Return the rowNum to be used for identifying the current search + parameter row. + """ + n = self.request.get('loops.rowNum', 0) + if n: # if given directly we don't use the calculation + return n + n = (self.maxRowNum or self.request.get('loops.maxRowNum', 0)) + 1 + self.maxRowNum = n + return n + + @Lazy + def presetSearchTypes(self): + """ Return a list of concept type info dictionaries (see BaseView) + that should be displayed on a separate search parameter row. + """ + #return ITypeManager(self.context).listTypes(include=('search',)) + return self.listTypesForSearch(include=('search',)) + + def conceptsForType(self, token): + result = ConceptQuery(self).query(type=token) + fv = FilterView(self.context, self.request) + result = fv.apply(result) + result.sort(key=lambda x: x.title) + noSelection = dict(token='none', title=u'not selected') + return [noSelection] + [dict(title=adapted(o, self.languageInfo).title, + token=util.getUidForObject(o)) + for o in result] + + def initDojo(self): + self.registerDojo() + cm = self.controller.macros + jsCall = ('dojo.require("dojo.parser");' + 'dojo.require("dijit.form.FilteringSelect");' + 'dojo.require("dojox.data.QueryReadStore");') + cm.register('js-execute', jsCall, jsCall=jsCall) + + def listConcepts(self, filterMethod=None): + """ Used for dijit.FilteringSelect. + """ + request = self.request + request.response.setHeader('Content-Type', 'text/plain; charset=UTF-8') + title = request.get('name') + if title == '*': + title = None + types = request.get('searchType') + data = [] + if title or types: + if title is not None: + title = title.replace('(', ' ').replace(')', ' ').replace(' -', ' ') + #title = title.split(' ', 1)[0] + if not types: + types = ['loops:concept:*'] + if not isinstance(types, (list, tuple)): + types = [types] + for type in types: + result = self.executeQuery(title=title or None, type=type, + exclude=('hidden',)) + fv = FilterView(self.context, self.request) + result = fv.apply(result) + for o in result: + if o.getLoopsRoot() == self.loopsRoot: + adObj = adapted(o, self.languageInfo) + if filterMethod is not None and not filterMethod(adObj): + continue + name = self.getRowName(adObj) + if title and title.endswith('*'): + title = title[:-1] + sort = ((title and name.startswith(title) and '0' or '1') + + name.lower()) + if o.conceptType is None: + raise ValueError('Concept Type missing for %r.' % name) + data.append({'label': self.getRowLabel(adObj, name), + 'name': name, + 'id': util.getUidForObject(o), + 'sort': sort}) + data.sort(key=lambda x: x['sort']) + if not title: + data.insert(0, {'label': '', 'name': '', 'id': ''}) + json = [] + for item in data[:100]: + json.append("{label: '%s', name: '%s', id: '%s'}" % + (item['label'], item['name'], item['id'])) + json = "{identifier: 'id', items: [%s]}" % ', '.join(json) + #print '***', json + return json + + def executeQuery(self, **kw): + return ConceptQuery(self).query(**kw) + + def getRowName(self, obj): + return obj.getLongTitle() + + def getRowLabel(self, obj, name=None): + if isinstance(obj, AdapterBase): + obj = obj.context + if name is None: + name = obj.title + return '%s (%s)' % (name, obj.conceptType.title) + + @Lazy + def statesDefinitions(self): + return [component.getUtility(IStatesDefinition, name=n) + for n in self.globalOptions('organize.stateful.resource', ())] + + @Lazy + def selectedStates(self): + result = {} + for k, v in self.request.form.items(): + if k.startswith('state.') and v: + result[k] = v + return result + + def submitReplacing(self, targetId, formId, view): + self.registerDojo() + return 'submitReplacing("%s", "%s", "%s"); return false;' % ( + targetId, formId, + '%s/.target%s/@@searchresults.html' % (view.url, self.uniqueId)) + + @Lazy + def results(self): + form = self.request.form + type = form.get('search.1.text', 'loops:*') + text = form.get('search.2.text') + if text is not None: + text = util.toUnicode(text, encoding='ISO8859-15') # IE hack!!! + useTitle = form.get('search.2.title') + useFull = form.get('search.2.full') + conceptType = form.get('search.3.type', 'loops:concept:*') + #conceptTitle = form.get('search.3.text') + #if conceptTitle is not None: + # conceptTitle = util.toUnicode(conceptTitle, encoding='ISO8859-15') + conceptUid = form.get('search.3.text') + result = FullQuery(self).query(text=text, type=type, + useTitle=useTitle, useFull=useFull, + #conceptTitle=conceptTitle, + conceptUid=conceptUid, + conceptType=conceptType) + rowNum = 4 + while rowNum < 10: + addCriteria = form.get('search.%i.text_selected' % rowNum) + rowNum += 1 + if not addCriteria: + break + if addCriteria == 'none': + continue + addSelection = FullQuery(self).query(text=text, type=type, + useTitle=useTitle, useFull=useFull, + conceptUid=addCriteria) + if result: + result = [r for r in result if r in addSelection] + else: + result = addSelection + result = [r for r in result if self.checkStates(r)] + fv = FilterView(self.context, self.request) + result = fv.apply(result) + result = sorted(result, key=lambda x: x.title.lower()) + return self.viewIterator(result) + + def checkStates(self, obj): + if not IResource.providedBy(obj): + return True + for std, states in self.selectedStates.items(): + if std.startswith('state.resource.'): + std = std[len('state.resource.'):] + else: + continue + stf = component.queryAdapter(obj, IStateful, name=std) + if stf is None: + continue + for state in states: + if stf.state == state: + break + else: + return False + return True + + +#class SearchResults(BaseView): +class SearchResults(NodeView): + """ Provides results as inner HTML """ + + @Lazy + def search_macros(self): + return self.controller.getTemplateMacros('search', search_template) + + @Lazy + def macro(self): + return self.search_macros['search_results'] + + def __call__(self): + return innerHtml(self) + + @Lazy + def results(self): + form = self.request.form + type = form.get('search.1.text', 'loops:*') + text = form.get('search.2.text') + if text is not None: + text = util.toUnicode(text, encoding='ISO8859-15') # IE hack!!! + useTitle = form.get('search.2.title') + useFull = form.get('search.2.full') + conceptType = form.get('search.3.type', 'loops:concept:*') + #conceptTitle = form.get('search.3.text') + #if conceptTitle is not None: + # conceptTitle = util.toUnicode(conceptTitle, encoding='ISO8859-15') + conceptUid = form.get('search.3.text') + result = FullQuery(self).query(text=text, type=type, + useTitle=useTitle, useFull=useFull, + #conceptTitle=conceptTitle, + conceptUid=conceptUid, + conceptType=conceptType) + rowNum = 4 + while rowNum < 10: + addCriteria = form.get('search.%i.text_selected' % rowNum) + rowNum += 1 + if not addCriteria: + break + if addCriteria == 'none': + continue + addSelection = FullQuery(self).query(text=text, type=type, + useTitle=useTitle, useFull=useFull, + conceptUid=addCriteria) + if result: + result = [r for r in result if r in addSelection] + else: + result = addSelection + fv = FilterView(self.context, self.request) + result = fv.apply(result) + result = sorted(result, key=lambda x: x.title.lower()) + return self.viewIterator(result) + diff --git a/expert/search.txt b/expert/search.txt new file mode 100755 index 0000000..fa33f30 --- /dev/null +++ b/expert/search.txt @@ -0,0 +1,239 @@ +=================================================================== +loops.search - Provide search functionality for the loops framework +=================================================================== + + ($Id$) + + +Let's do some basic set up + + >>> from zope.app.testing.setup import placefulSetUp, placefulTearDown + >>> site = placefulSetUp(True) + + >>> from zope import component, interface + >>> from zope.interface import implements + +and setup a simple loops site with a concept manager and some concepts +(with all the type machinery, what in real life is done via standard +ZCML setup): + + >>> from loops.concept import Concept + >>> from loops.type import ConceptType, TypeConcept + >>> from loops.interfaces import ITypeConcept + >>> from loops.base import Loops + >>> from loops.expert.testsetup import TestSite + >>> t = TestSite(site) + >>> concepts, resources, views = t.setup() + + >>> loopsRoot = site['loops'] + >>> query = concepts['query'] + >>> topic = concepts['topic'] + +In addition we create a concept that holds the search page and a node +(page) that links to this concept: + + >>> search = concepts['search'] = Concept(u'Search') + >>> search.conceptType = query + + >>> from loops.view import Node + >>> page = views['page'] = Node('Search Page') + >>> page.target = search + +Search views +------------ + +Now we are ready to create a search view object: + + >>> from zope.publisher.browser import TestRequest + >>> from loops.expert.browser.search import Search + >>> searchView = Search(search, TestRequest()) + +The search view provides values for identifying the search form itself +and the parameter rows; the rowNum is auto-incremented, so it should be +accessed exactly once per row: + + >>> searchView.rowNum + 1 + >>> searchView.rowNum + 2 + +The search view provides vocabularies for types that allow the selection +of types to search for; this needs an ITypeManager adapter registered via +zcml in real life: + + >>> from loops.type import LoopsTypeManager + >>> component.provideAdapter(LoopsTypeManager) + + >>> t = searchView.typesForSearch() + >>> len(t) + 14 + >>> t.getTermByToken('loops:resource:*').title + 'Any Resource' + + >>> t = searchView.conceptTypesForSearch() + >>> len(t) + 11 + >>> t.getTermByToken('loops:concept:*').title + 'Any Concept' + +To execute the search in the context of a node we have to set up a node +view for our page. The submitReplacing method returns a JavaScript call +that will replace the results part on the search page; as this registers +the dojo library with the view's controller we also have to supply +a controller attribute for the search view. + + >>> from loops.browser.node import NodeView + >>> request = TestRequest() + >>> pageView = NodeView(page, request) + + >>> from cybertools.browser.liquid.controller import Controller + >>> searchView.controller = Controller(searchView, request) + + >>> searchView.submitReplacing('1.results', '1.search.form', pageView) + 'submitReplacing("1.results", "1.search.form", + "http://127.0.0.1/loops/views/page/.target80/@@searchresults.html");...' + +Basic (text/title) search +------------------------- + +The searchresults.html view, i.e. the SearchResults view class provides the +result set of the search via its `results` property. + +Before accessing the `results` property we have to prepare a +resource we can search for and index it in the catalog. + + >>> from loops.resource import Resource + >>> rplone = resources['plone'] = Resource() + + >>> from zope.app.catalog.interfaces import ICatalog + >>> from loops import util + >>> catalog = component.getUtility(ICatalog) + >>> catalog.index_doc(int(util.getUidForObject(rplone)), rplone) + + >>> from loops.expert.browser.search import SearchResults + >>> form = {'search.2.title': True, 'search.2.text': u'plone'} + >>> request = TestRequest(form=form) + >>> resultsView = SearchResults(page, request) + + >>> results = list(resultsView.results) + >>> len(results) + 1 + >>> results[0].context == rplone + True + + >>> form = {'search.2.title': True, 'search.2.text': u'foo'} + >>> request = TestRequest(form=form) + >>> resultsView = SearchResults(page, request) + >>> len(list(resultsView.results)) + 0 + +Search via related concepts +--------------------------- + +We first have to prepare some test concepts (topics); we also assign our test +resource (rplone) from above to one of the topics: + + >>> czope = concepts['zope'] = Concept(u'Zope') + >>> czope2 = concepts['zope2'] = Concept(u'Zope 2') + >>> czope3 = concepts['zope3'] = Concept(u'Zope 3') + >>> cplone = concepts['plone'] = Concept(u'Plone') + >>> for c in (czope, czope2, czope3, cplone): + ... c.conceptType = topic + ... catalog.index_doc(int(util.getUidForObject(c)), c) + >>> czope.assignChild(czope2) + >>> czope.assignChild(czope3) + >>> czope2.assignChild(cplone) + >>> rplone.assignConcept(cplone) + +Now we can fill our search form and execute the query; note that all concepts +found are listed, plus all their children and all resources associated +with them: + + >>> uid = util.getUidForObject(concepts['zope']) + >>> form = {'search.3.type': 'loops:concept:topic', 'search.3.text': uid} + >>> request = TestRequest(form=form) + >>> resultsView = SearchResults(page, request) + >>> results = list(resultsView.results) + >>> len(results) + 5 + >>> results[0].context.__name__ + u'plone' + + >>> uid = util.getUidForObject(concepts['zope3']) + >>> form = {'search.3.type': 'loops:concept:topic', 'search.3.text': uid} + >>> request = TestRequest(form=form) + >>> resultsView = SearchResults(page, request) + >>> results = list(resultsView.results) + >>> len(results) + 1 + >>> results[0].context.__name__ + u'zope3' + +To support easy entry of concepts to search for we can preselect the available +concepts (optionally restricted to a certain type) by entering text parts +of the concepts' titles: + + >>> form = {'searchType': 'loops:concept:topic', 'name': u'zope'} + >>> request = TestRequest(form=form) + >>> view = Search(page, request) + >>> view.listConcepts() + u"{identifier: 'id', items: [{label: 'Zope (Topic)', name: 'Zope', id: '85'}, {label: 'Zope 2 (Topic)', name: 'Zope 2', id: '87'}, {label: 'Zope 3 (Topic)', name: 'Zope 3', id: '89'}]}" + +Preset Concept Types on Search Forms +------------------------------------ + +Often we want to include certain types in our search. We can instruct +the search form to include lines for these types by giving these types +a certain qualifier, via the option attribute of the type interface. + +Let's start with a new type, the customer type. + + >>> customer = concepts['customer'] + >>> custType = ITypeConcept(customer) + >>> custType.options + [] + + >>> cust1 = concepts['cust1'] + >>> cust2 = concepts['cust2'] + >>> for c in (cust1, cust2): + ... c.conceptType = customer + ... catalog.index_doc(int(util.getUidForObject(c)), c) + + >>> from cybertools.typology.interfaces import IType + >>> IType(cust1).qualifiers + ('concept',) + + >>> searchView = Search(search, TestRequest()) + >>> list(searchView.presetSearchTypes) + [] + +We can now add a 'search' qualifier to the customer type's options +and thus include the customer type in the preset search types. + + >>> custType.options = ('qualifier:search',) + >>> IType(cust1).qualifiers + ('concept', 'search') + >>> searchView = Search(search, TestRequest()) + >>> list(searchView.presetSearchTypes) + [{'token': 'loops:concept:customer', 'title': u'Customer'}] + + >>> searchView.conceptsForType('loops:concept:customer') + [{'token': 'none', 'title': u'not selected'}, + {'token': '58', 'title': u'Customer 1'}, + {'token': '60', 'title': u'Customer 2'}, + {'token': '62', 'title': u'Customer 3'}] + +Let's use this new search option for querying: + + >>> form = {'search.4.text_selected': u'58'} + >>> resultsView = SearchResults(page, TestRequest(form=form)) + >>> results = list(resultsView.results) + >>> results[0].title + u'Customer 1' + + +Automatic Filtering +------------------- + +TODO - more to come... + diff --git a/expert/tests.py b/expert/tests.py index 85f33ff..4e63f95 100755 --- a/expert/tests.py +++ b/expert/tests.py @@ -24,6 +24,7 @@ def test_suite(): return unittest.TestSuite(( unittest.makeSuite(Test), DocFileSuite('README.txt', optionflags=flags), + DocFileSuite('search.txt', optionflags=flags), )) if __name__ == '__main__': diff --git a/organize/stateful/browser.py b/organize/stateful/browser.py index b07c26d..794b4c4 100644 --- a/organize/stateful/browser.py +++ b/organize/stateful/browser.py @@ -31,7 +31,7 @@ from cybertools.stateful.interfaces import IStateful, IStatesDefinition from loops.browser.common import BaseView from loops.browser.concept import ConceptView from loops.expert.query import And, Or, State, Type, getObjects -from loops.search.browser import search_template +from loops.expert.browser.search import search_template from loops.util import _ diff --git a/search/README.txt b/search/README.txt index ed48760..fa33f30 100755 --- a/search/README.txt +++ b/search/README.txt @@ -45,7 +45,7 @@ Search views Now we are ready to create a search view object: >>> from zope.publisher.browser import TestRequest - >>> from loops.search.browser import Search + >>> from loops.expert.browser.search import Search >>> searchView = Search(search, TestRequest()) The search view provides values for identifying the search form itself @@ -110,7 +110,7 @@ resource we can search for and index it in the catalog. >>> catalog = component.getUtility(ICatalog) >>> catalog.index_doc(int(util.getUidForObject(rplone)), rplone) - >>> from loops.search.browser import SearchResults + >>> from loops.expert.browser.search import SearchResults >>> form = {'search.2.title': True, 'search.2.text': u'plone'} >>> request = TestRequest(form=form) >>> resultsView = SearchResults(page, request) diff --git a/search/browser.py b/search/browser.py index a4f077a..982a278 100644 --- a/search/browser.py +++ b/search/browser.py @@ -37,6 +37,7 @@ from cybertools.typology.interfaces import ITypeManager from loops.browser.common import BaseView from loops.browser.node import NodeView from loops.common import adapted, AdapterBase +#from loops.expert.browser.search import searchMacrosTemplate as search_template from loops.expert.concept import ConceptQuery, FullQuery from loops.interfaces import IResource from loops.organize.personal.browser.filter import FilterView @@ -44,7 +45,7 @@ from loops import util from loops.util import _ -search_template = ViewPageTemplateFile('search.pt') +#search_template = ViewPageTemplateFile('search.pt') class Search(BaseView): diff --git a/search/configure.zcml b/search/configure.zcml index 8df9762..cb773cb 100644 --- a/search/configure.zcml +++ b/search/configure.zcml @@ -6,7 +6,7 @@ i18n_domain="zope" > - + />--> diff --git a/search/search.pt b/search/search.pt index e3ca8a2..a7d0a91 100644 --- a/search/search.pt +++ b/search/search.pt @@ -43,6 +43,9 @@
+ + +