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

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

Simplify structure of discovery controller, using the results template
to render searches even when no results are found. Add new error
dict to avoid confusing with the error dict used by the formencode
input checker. Add additional input checks and improve error handling.
Add dropdown list to select order by values + add new constants
module to store associated data and error keys + tidy up DiscoveryState?
object to make clearer and logic more consistent + fix paging controls
for results data.

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