source: MILK/trunk/milk_server/milk_server/controllers/browse/discovery.py @ 5306

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/MILK/trunk/milk_server/milk_server/controllers/browse/discovery.py@5306
Revision 5306, 26.4 KB checked in by cbyrom, 11 years ago (diff)

Add new template method, 'semanticSearch' to do the asynchronous
loading of alternative search options - and replace the previous
template code with this in both results pages. Fix a couple of issues
with the PagerState? object to ensure start/last page stride is set
correctly.

Line 
1'''
2Controller for the discovery search functionality
3'''
4import socket, logging
5from paste.request import parse_querystring
6from ndg.common.src.clients.ws.discovery.discoveryserviceclient import DiscoveryServiceClient
7from ndg.common.src.clients.xmldb.eXist.searchclient import SearchClient
8from ndg.common.src.clients.http.vocabserverclient import VocabServerClient as VS
9from ndg.common.src.models.vocabtermdata import VocabTermData as VTD
10from ndg.common.src.models.ndgObject import ndgObject
11from ndg.common.src.models.DIF import DIF
12from ndg.common.src.lib.mailer import mailHandler
13from milk_server.lib.base import *
14from milk_server.lib.Date import *
15from milk_server.models.DiscoveryState import DiscoveryState, constraints
16from milk_server.controllers.home import HomeController
17import browserconstants as bc
18from milk_server.lib.Utilities import getURLConstraints
19import milk_server.lib.constants as constants
20
21
22class DiscoveryController(HomeController):
23    '''
24    Provides the pylons controller for NDG discovery
25    '''
26
27    def __setup(self):
28        '''
29        Common setup for controller methods
30        '''
31        self.cf=request.environ['ndgConfig']
32        self.inputs=dict(parse_querystring(request.environ))
33       
34        self.message=''
35        c.inputErrors = {}    # dict to store error messages
36       
37        c.discoveryUrl = h.url_for('discovery')
38                       
39   
40    def index(self):
41        '''
42        Main entry point for doing discovery searches
43        '''
44        self.__setup()
45       
46        # if inputs are not set: if the discovery mode is enabled, display
47        # the search screen, otherwise redirect to the default home page according
48        # to what milk mode is set (i.e. editor/browse)
49        if not self.inputs or 'ClearForm' in self.inputs:
50            if g.discoveryEnabled:
51                return self.__advancedPrompt()
52            else:
53                logging.info("Discovery mode not enabled - redirect to default")
54                return h.redirect_to(h.url_for('default'))
55       
56        self.__getInputs()
57
58        if 'orderBy' not in self.inputs:
59            self.inputs['orderBy'] = g.orderByList[0]
60        c.orderByList = h.options_for_select(g.orderByList, self.inputs['orderBy'])
61
62        if 'orderDirection' not in self.inputs:
63            self.inputs['orderDirection'] = constants.ORDER_BY_DIRECTION[0]
64        c.orderDirection = h.options_for_select(constants.ORDER_BY_DIRECTION,
65                                                self.inputs['orderDirection']) 
66       
67        # if any errors are found, return user to search page
68        if c.inputErrors:
69            return self.__advancedPrompt()
70
71        searchString = self.inputs['searchString']
72        if 'vocabTerm' in self.inputs:
73            searchString += " %s" %self.inputs['vocabTerm']
74           
75        # users can return to search page to refine the search inputs; in this case
76        # they will have a 'constrained' input
77        self.constraints = self.__buildconstraints(self.dateRange, self.bbox, self.scope,
78                                                   searchString, self.inputs['geoSearchType'])
79        if 'constrained' in self.inputs: 
80            return self.__advancedPrompt(searchConstraints = self.constraints)
81
82        # ok, now go do the search
83        try:
84            return self.__runSearch(searchString, self.inputs['textTarget'],
85                                    self.inputs['start'], self.inputs['howmany'], 
86                                    orderBy = self.inputs['orderBy'], 
87                                    orderDirection = self.inputs['orderDirection'],
88                                    scope = self.scope, dateRange = self.dateRange, 
89                                    bbox = self.bbox, geoSearch=self.inputs['geoSearchType'])
90        except Exception, e:
91            if g.debugModeOn == 'True':
92                raise e
93            else:
94                c.xml='Unexpected error: %s'%(str(e))
95                return render('error')
96
97
98    def __getInputs(self):
99        '''
100        Retrieve the user inputs and set defaults.  Values are stored in the
101        self.inputs dict
102        '''
103        logging.debug("Getting user inputs")
104       
105        # restore contraints from input, if set
106        if 'constraints' in self.inputs:
107            constraints = getURLConstraints(self.inputs['constraints'])
108            del self.inputs['constraints']
109            # NB, be careful not to overwrite newly passed in info
110            for key, val in constraints.items():
111                if key not in self.inputs:
112                    self.inputs[key] = val
113                   
114        if 'vocabTerm' in self.inputs and 'searchString' not in self.inputs:
115            self.inputs['searchString'] = ""
116           
117        # see if this is a discovery search or a more complicated search
118        if 'searchTarget' not in self.inputs: 
119            self.inputs['searchTarget']='Discovery'
120       
121        # set default for table paging, if not already set
122        # NB, url arguments need converting back to ints
123        if 'start' not in self.inputs:
124            self.inputs['start'] = 1
125        else:
126            self.inputs['start'] = int(self.inputs['start'])
127           
128        if 'howmany' not in self.inputs:
129            self.inputs['howmany'] = 10
130        else:
131            self.inputs['howmany'] = int(self.inputs['howmany'])
132           
133        # the simplest query we might get is a text search, in which case
134        # the inputs should be start, howmany and searchString (although
135        # maybe not in that order. The next simplest is one with
136        # a specified textTarget, after that we need all the inputs.
137        if 'searchString' in self.inputs and 'textTarget' not in self.inputs:
138            # it's a simple text search
139            self.inputs['textTarget']='All'
140
141        # the next simplest is one that includes texttarget as well ...
142        expected=['searchString','textTarget']
143        missingInputs = self.__checkform(expected)
144        if missingInputs:
145            if bc.INCOMPLETE_SEARCH_INPUT_MESSAGE not in c.inputErrors:
146                c.inputErrors[bc.INCOMPLETE_SEARCH_INPUT_MESSAGE] = []
147            c.inputErrors[bc.INCOMPLETE_SEARCH_INPUT_MESSAGE].extend(missingInputs)
148
149        self.__getSpatioTemporalInputs()
150       
151        logging.debug("User inputs retrieved")
152
153
154    def __getSpatioTemporalInputs(self):
155        '''
156        Get spatiotemporal input data - and set up any defaults, if required
157        '''
158        logging.debug("Getting spatiotemporal inputs")
159        if 'geoSearchType' not in self.inputs:
160            self.inputs['geoSearchType']='overlaps'
161
162        # now we add the defaults... this is kind of historical - NOT SURE THIS IS STILL NEEDED
163        if len(self.inputs)==6:
164            self.bbox=None
165            self.dateRange=None
166            self.scope=None
167            return
168       
169        if 'source' in self.inputs and self.inputs['source'] != 'All':
170            # NB, the WSDL expects a list
171            self.scope = [self.inputs['source']]
172        else:
173            self.scope = None
174           
175        missingInputs = self.__checkform(['bboxN','bboxE','bboxS','bboxW','geoSearchType'])
176        if missingInputs: 
177            self.bbox = None
178        else:
179            # default form has a global bounding box, NB, internal to this routine we use bbox=[N,W,E,S], not [W,S,E,N]!
180            self.bbox = [self.inputs['bboxN'], self.inputs['bboxW'],
181                         self.inputs['bboxE'], self.inputs['bboxS']]
182           
183            errors = self.__checkBBoxValidity(self.bbox)
184            if errors:
185                if bc.INVALID_BBOX_MESSAGE not in c.inputErrors:
186                    c.inputErrors[bc.INVALID_BBOX_MESSAGE] = []
187                c.inputErrors[bc.INVALID_BBOX_MESSAGE].extend(errors)
188
189        missingInputs = self.__checkform(['startDate', 'endDate'])
190        self.dateRange = None
191        if missingInputs:
192            self.dateRange = None
193        elif self.inputs['startDate'] and not self.inputs['endDate']:
194            c.inputErrors[bc.INCOMPLETE_DATERANGE_MESSAGE] = ['End date missing']
195        elif not self.inputs['startDate'] and self.inputs['endDate']:
196            c.inputErrors[bc.INCOMPLETE_DATERANGE_MESSAGE] = ['Start date missing']
197        elif self.inputs['startDate'] and self.inputs['endDate']:
198            dateError = None
199            try:
200                year, month, day = self.inputs['startDate'].split('/')
201                self.dateRange = [(day, month, year)]
202                year, month, day = self.inputs['endDate'].split('/')
203                self.dateRange.append((day, month, year))
204
205                if self.dateRange <> [("","",""),("","","")]:
206                    dateError = self.__checkdates(self.dateRange)
207                   
208                else: 
209                    self.dateRange = None
210                               
211            except:
212                dateError = 'Invalid date provided'
213
214            if dateError:
215                if bc.INVALID_DATERANGE_MESSAGE not in c.inputErrors:
216                    c.inputErrors[bc.INVALID_DATERANGE_MESSAGE] = []
217                c.inputErrors[bc.INVALID_DATERANGE_MESSAGE].append(dateError)
218       
219        logging.debug("Spatiotemporal inputs retrieved")
220
221
222    def __getSearchClient(self, clientType):
223        '''
224        Retrieve the appropriate client to complete the search
225        - currently supported are browse and discovery clients
226        @param clientType: type of search client to use.  Currently accepts,
227        'Discovery', 'Browse' and 'NumSim'
228        @raise ValueError if unrecognised search client entered
229        @return search client adhering to the ndg.common.clients.interfacesearchclient
230        interface
231        '''
232        logging.debug("Getting %s type search client" %clientType)
233        searchClient = None
234        if clientType =='Discovery':
235            logging.info(" - use Discovery service to complete search")
236            if g.discoveryServiceURL:
237                searchClient = DiscoveryServiceClient(HostAndPort = g.discoveryServiceURL)
238            else:
239                searchClient = DiscoveryServiceClient()
240               
241        elif clientType in ['Browse','NumSim']:
242            logging.info(" - use Browse service to complete search")
243            searchClient = SearchClient(dbHostName = g.localEXist,
244                                        configFileName = g.pwFile)
245        else:
246            raise ValueError("Unrecognised search type, '%s'" %clientType)
247       
248        logging.debug("- returning search client")
249        return searchClient
250       
251
252    def __runSearch(self, searchString, textTarget, start,
253                    howmany, orderBy = None, orderDirection = None, scope = None, 
254                    dateRange = None, bbox = None, geoSearch = 'overlaps'):
255        '''
256        Carry out a text search for <searchString>
257        in the <textTarget> where the accepted text target values are controlled
258        by the DiscoveryTemplate GUI, and are: All, Authors, Parameters
259        @param searchString: string to search for
260        @param textTarget: target of the search - either, 'All', 'Authors' or 'Parameters'
261        @param start: starting record to return
262        @param howmany: number of records to return
263        @keyword orderBy: Field to order results by - NB, must be one of the
264        getList('orderByFieldList') values
265        @keyword orderDirection: Direction the 'orderBy' results should be returned in
266        - NB, this is currently 'ascending' or 'descending'
267        @keyword scope: scope of search - either NERC, NERC_DDC, MDIP or DPPP. Default = None
268        @keyword dateRange: date range in format [startDate, endDate] where the
269        date objects are tuples with format (day, month, year). Default = None
270        @keyword bbox: Bounding box in format [N,W,E,S]. Default = None
271        @keyword geoSearch: type of spatial search. Defaults to 'overlaps'.
272        '''
273        logging.debug("Running text search with string, '%s'" %searchString)
274       
275        searchClient = self.__getSearchClient(self.inputs['searchTarget'])
276       
277        if self.inputs['searchTarget'] in ['Browse','NumSim']:
278            textTarget = self.inputs['searchTarget']
279            if textTarget == 'Browse':
280                # NB, this switches the searching to be done on atom format
281                # rather than moles1.3 format docs
282                textTarget = SearchClient.ATOM_TARGET#'ndg_B_metadata'
283           
284        # PJK 04/09/08 Handle errors more gracefully
285        #
286        # http://proj.badc.rl.ac.uk/ndg/ticket/984
287        try:
288            searchClient.search(searchString,
289                                start = start,
290                                howmany = howmany,
291                                target = textTarget,
292                                scope = scope,
293                                dateRange = dateRange,
294                                bbox = bbox,
295                                geoSearchType = geoSearch,
296                                orderBy = orderBy,
297                                orderDirection = orderDirection)
298        except socket.error, e:
299            logging.error("Socket error for discovery service search: %s" % e)
300            c.xml='The Discovery Service is unavailable.  Please check with '+\
301                    'your system administrator'
302            return render('error')
303        except Exception, e:
304            logging.error("Calling discovery service search: %s" % e)
305            c.xml='An internal error occured.  Please check with ' + \
306                    'your system administrator'
307            return render('error')
308           
309        logging.debug("Search returned - now processing results")
310        # DiscoveryState object is a wrapper to the various search config
311        # variables
312        c.state = DiscoveryState(searchClient.serverSessionID, searchString,
313                                 request.environ, searchClient.hits, self.constraints,
314                                 start, howmany)
315
316        return self.__processSearchResults(searchClient, c.state)
317
318
319    def __processSearchResults(self, searchClient, ds):
320        '''
321        Process the results from a search - as ran by the input search client object
322        @param searchClient: search client adhering to the ndg.common.clients.interfacesearchclient
323        interface - which has just ran a search
324        @param ds: DiscoveryState object with info on the search
325        '''
326        if searchClient.error:
327            logging.error("Error encountered whilst running search: %s" %searchClient.error)
328            m=''
329            for i in searchClient.error:
330                m+='<p>%s</p>'%i
331            c.xml = m
332            return render('error')
333       
334        hits = searchClient.hits
335        # NB, this is used in the semantic search function of results.kid and short_results.kid
336        c.querystring = request.environ['QUERY_STRING']
337        c.altSearchURL = '%s?%s'%(h.url_for(action='semantic'),
338                                  request.environ['QUERY_STRING'])
339
340        difs = []
341        errors = []
342
343        if hits == 0 and ds.constraintsInstance['textTarget'] != SearchClient.ATOM_TARGET:
344            outMessage = 'No records found for "%s"[constraints: %s]' \
345                %(ds.searchString, ds.constraints)
346            logging.info(outMessage) 
347            c.xml='<p>' + outMessage + '</p>'
348
349        else:
350            try:
351                # display browse search results differently
352                if self.inputs['searchTarget'] != 'Discovery':
353                    return self.__displayBrowseSearchResults(searchClient)
354   
355                # now actually retrieve the search records
356                results = searchClient.getLabelledDocs(format='DIF')
357   
358                if not results:
359                    c.xml='<p>No results for "%s"!</p>'%ds.searchString
360                else:
361                    for result in results: 
362                        obj=ndgObject(result[0], config = self.cf)
363                        try:
364                            difs.append(DIF(result[1],ndgObj=obj))
365                        except ValueError,e:
366                            errors.append((result[0], str(e)))
367       
368                    if not difs:
369                        c.xml='<p>No usable results for "%s"!</p>'%ds.searchString
370                   
371                    elif errors:
372                        c.xml='<p>Search results for "%s"'%ds.searchString
373                        dp=[]
374                        for e in errors:
375                            n=ndgObject(e[0])
376                            if n.repository not in dp: 
377                                dp.append(n.repository)
378                        if len(dp)<>1: 
379                            dp='[Various Data Providers]'
380                        else:
381                            dp='[%s]'%dp[0] 
382                           
383                        c.xml+=' (unfortunately %s hits matched unformattable documents from %s, an internal error has been logged):</p>'%(len(errors),dp)
384                        status, message=mailHandler([g.metadataMaintainer],'DIF errors',
385                                                    str(errors), server = g.mailServer)
386                        if not status:
387                            c.xml+='<p> Actually, not even an internal error has been logged. <br/>'
388                            c.xml+='Internal sending of mail failed with error [%s]</p>'%message
389                   
390                # if we're here, we're ready to display the dif records
391                c.difs = difs
392                session['results'] = h.current_url()
393                session.save()
394               
395                # set up the displayed tabs
396                if len(c.pageTabs)==1: 
397                    c.pageTabs.append(('Results', session['results']))
398                    c.pageTabs.append(('Selections',
399                                       h.url_for(controller='visualise/selectedItems',
400                                                 action='index')))
401                elif c.pageTabs[1][0]!='Results':
402                        c.pageTabs.insert(1,('Results',session['results']))
403                        selectionsNeeded=1
404                        for tab in c.pageTabs[0]:
405                            if tab == 'Selections':
406                                selectionsNeeded=0
407                        if selectionsNeeded:
408                            c.pageTabs.append(('Selections',
409                                       h.url_for(controller='visualise/selectedItems',
410                                                 action='index')))
411                           
412                   
413            except ValueError,e:
414                if g.debugModeOn == 'True':
415                    raise ValueError,str(e)
416
417                c.xml='<p> Error retrieving documents for %s hits is [%s]</p>'%(hits,e)
418
419        return render('browse/results')
420       
421    def __advancedPrompt(self, searchConstraints = None):
422        '''
423        This provides the advanced search input page
424        @keyword searchConstraints: a DiscoveryState.constraints object with the
425        search filter details in
426        '''
427        #defaults
428        c.title = bc.DISCOVERY_HOME_TITLE
429        c.bbox='90.0','-180.0','180.0','-90.0'
430        c.startDate = ''
431        c.endDate = ''
432        c.textTarget='All'
433        c.searchString=''
434        c.source=['All']
435        c.geoSearchType='overlaps'
436
437        # apply any available constraints
438        if searchConstraints:
439            if searchConstraints['dateRange']:
440                c.startDate = '%s/%s/%s' %searchConstraints['dateRange'][0]
441                c.endDate = '%s/%s/%s' %searchConstraints['dateRange'][1]
442            if searchConstraints['bbox']:
443                c.bbox=searchConstraints['bbox']
444            if searchConstraints['textTarget']:
445                c.textTarget=searchConstraints['textTarget']
446            if searchConstraints['searchString']:
447                c.searchString=searchConstraints['searchString']
448            if searchConstraints['scope']:
449                c.source=searchConstraints['scope']
450            if searchConstraints['geoSearchType']:
451                c.geoSearchType = searchConstraints['geoSearchType']
452       
453        return self.savePageAndRender("browse/discovery_search", **self.inputs)
454
455       
456    def __checkBBoxValidity(self, bbox):
457        '''
458        Check the integrity of the bounding box; return any errors found as list
459        @return: list of errors
460        '''
461        errors = []
462       
463        for name, val in [('North', float(bbox[0])), ('South', float(bbox[3]))]:
464            if val > 90.0 or val < -90.:
465                errors.append("%s latitude exceeds valid range - -90 <= x <= 90" %name)
466               
467        for name, val in [('West', float(bbox[1])), ('East', float(bbox[2]))]:
468            if val > 180.0 or val < -180.:
469                errors.append("%s longitude exceeds valid range - -180 <= x <= 180" %name)
470        return errors
471
472           
473    def __checkform(self,expected):
474        '''
475        Simply checks the inputs to make sure the elements in expected are present
476        - NB, this isn't actually checking that a value for these inputs are set, it
477        is just checking the fields are there
478        @return array of missing inputs
479        '''
480        logging.debug("Checking for missing inputs")
481        missingInputs = []
482        for i in expected:
483            if i not in self.inputs:
484                logging.debug(" - found missing input: %s" %i)
485                missingInputs.append(i)
486        logging.debug("Finished checking for missing inputs")
487        return missingInputs
488       
489               
490    def __checkdates(self,dateRange):
491        '''
492        Check input dates for sanity
493        @return: error message, if invalid, None otherwise
494        '''
495        if not ValidDate(dateRange[0])*ValidDate(dateRange[1]):
496            return str(dateRange)
497        elif JulDay(dateRange[0]) >= JulDay(dateRange[1]):
498            return 'second date must be after first date'
499     
500        return None
501
502       
503    def __buildconstraints(self, dateRange, bbox, scope, searchString, geoSearch):
504        '''
505        Build and return a DiscoveryState.constraints object
506        '''
507        return constraints(dateRange=dateRange, bbox=bbox,
508                           scope=scope, searchString=searchString, 
509                           geoSearchType=geoSearch)
510       
511
512    def semantic(self):
513        self.__setup()
514        vs = VS(proxyServer = g.proxyServer)
515        if 'searchString' in self.inputs:
516            try:
517                [broader,narrower,synonyms] = vs.getRelated(self.inputs['searchString'])
518                #get a base string for the links to new searches
519                if 'start' in self.inputs: del self.inputs['start']
520                if 'howmany' in self.inputs: del self.inputs['howmany']
521                self.inputs['searchString']='###SEARCHSSTRING###'
522                q='%s/discovery?'%g.server
523                for i in self.inputs: q+='%s=%s&'%(i,self.inputs[i])
524                url=q[0:-1]
525                # and now build the links
526                c.narrower=[]
527                c.broader=[]
528                c.synonyms=[]
529                for i in narrower:
530                    c.narrower.append((i,url.replace('###SEARCHSSTRING###',i)))
531                for i in broader:
532                    c.broader.append((i,url.replace('###SEARCHSSTRING###',i)))
533                for i in synonyms:
534                    c.synonyms.append((i,url.replace('###SEARCHSSTRING###',i)))
535                if c.narrower!=[] or c.broader!=[] or c.synonyms!=[]: c.semAvailable=1
536            except IOError,e:
537                c.semAvailable=0
538                c.semError=' (No valid reply from vocabulary service)'
539                #This should go in a log file ...
540                print 'ERROR: Vocabulary Service: %s (for search [%s])'%(str(e),self.inputs['searchString'])
541        else:
542            broader,narrower,synonyms=[],[],[]
543            c.semAvailable=0
544            c.semError='.'
545       
546        return render('browse/semantic',fragment=True)
547
548   
549    def __displayBrowseSearchResults(self, searchClient):
550        '''
551        Provides the search results for Browse and NumSim content
552        @param searchClient: search client adhering to the ndg.common.clients.interfacesearchclient
553        interface - which has just ran a search
554        '''
555        c.results = searchClient.results
556        c.searchTarget = self.inputs['searchTarget']
557        textTarget = self.inputs['textTarget']
558
559        # check if we've done a search against atoms - NB, this should be the
560        # default eventually - so we can remove all the alternative options
561        isAtom = False
562        if textTarget == SearchClient.ATOM_TARGET:
563            isAtom = True
564       
565        for r in c.results:
566            id = r.id
567                # cope with atom docs
568            if isAtom:
569                r.link = r.href
570            else:
571                n=ndgObject(id,config=self.cf)
572                r.link={'Browse':n.BURL,'NumSim':n.URL}[c.searchTarget]
573
574        # filter atom docs according to publication state
575        if isAtom:
576            c.searchTerm = " - for search term, '%s'" %self.inputs['searchString']
577            if not g.atomEditorEnabled:
578                c.results = self.__filterAtomResults(c.results)
579
580            if c.results:
581                c.searchTerm += ' [%s results found]' %len(c.results)
582               
583               
584            html = render('genshi', 'browse/short_atom_results')
585            # make sure the edit links point to the editor, not the browse service
586            html = html.replace(VTD.BROWSE_SERVER_URL + '/editAtom', g.server + '/editAtom')
587            return html
588        else:
589            return render('browse/short_results')
590
591
592    def __filterAtomResults(self, results):
593        '''
594        Given a set of atom docs search results, filter these to only return docs in the
595        'published' or 'Published' state
596        @param results: list of results as returned by SearchClient
597        @return filteredResults: list of results with only published data included
598        '''
599        logging.debug("Filtering results to remove non-published data")
600        filteredResults = []
601        for result in results:
602            if result.collection.find('ublished') == -1:
603                logging.debug("- found non-published doc - ignoring")
604                continue
605            filteredResults.append(result)
606        logging.debug("- returning filtered results")
607        return filteredResults
608
609           
610    def clearSession(self):
611        '''
612        Clear out all session variables - to help when these change in development
613        '''
614        session.clear()
615        session.save()           
616     
Note: See TracBrowser for help on using the repository browser.