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

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

Improve look and feel of UI with tweaks to css + fix radio button
on search panel - set this explicitly as an input since htmlfill
doesn't handle the radio button defaults well + simplify template
logic for semantic search results + fix constraint summary string.

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        # NB, htmlfill doesn't handle the checked property correctly, so need
454        # to add this explicitly here
455        if 'source' not in self.inputs:
456            self.inputs['source'] = 'NERC_DDC'
457       
458        return self.savePageAndRender("browse/discovery_search", **self.inputs)
459
460       
461    def __checkBBoxValidity(self, bbox):
462        '''
463        Check the integrity of the bounding box; return any errors found as list
464        @return: list of errors
465        '''
466        errors = []
467       
468        for name, val in [('North', float(bbox[0])), ('South', float(bbox[3]))]:
469            if val > 90.0 or val < -90.:
470                errors.append("%s latitude exceeds valid range - -90 <= x <= 90" %name)
471               
472        for name, val in [('West', float(bbox[1])), ('East', float(bbox[2]))]:
473            if val > 180.0 or val < -180.:
474                errors.append("%s longitude exceeds valid range - -180 <= x <= 180" %name)
475        return errors
476
477           
478    def __checkform(self,expected):
479        '''
480        Simply checks the inputs to make sure the elements in expected are present
481        - NB, this isn't actually checking that a value for these inputs are set, it
482        is just checking the fields are there
483        @return array of missing inputs
484        '''
485        logging.debug("Checking for missing inputs")
486        missingInputs = []
487        for i in expected:
488            if i not in self.inputs:
489                logging.debug(" - found missing input: %s" %i)
490                missingInputs.append(i)
491        logging.debug("Finished checking for missing inputs")
492        return missingInputs
493       
494               
495    def __checkdates(self,dateRange):
496        '''
497        Check input dates for sanity
498        @return: error message, if invalid, None otherwise
499        '''
500        if not ValidDate(dateRange[0])*ValidDate(dateRange[1]):
501            return str(dateRange)
502        elif JulDay(dateRange[0]) >= JulDay(dateRange[1]):
503            return 'second date must be after first date'
504     
505        return None
506
507       
508    def __buildconstraints(self, dateRange, bbox, scope, searchString, geoSearch):
509        '''
510        Build and return a DiscoveryState.constraints object
511        '''
512        return constraints(dateRange=dateRange, bbox=bbox,
513                           scope=scope, searchString=searchString, 
514                           geoSearchType=geoSearch)
515       
516
517    def semantic(self):
518        self.__setup()
519        vs = VS(proxyServer = g.proxyServer)
520        if 'searchString' in self.inputs:
521            try:
522                [broader,narrower,synonyms] = vs.getRelated(self.inputs['searchString'])
523                #get a base string for the links to new searches
524                if 'start' in self.inputs: del self.inputs['start']
525                if 'howmany' in self.inputs: del self.inputs['howmany']
526                self.inputs['searchString']='###SEARCHSSTRING###'
527                q='%s/discovery?'%g.server
528                for i in self.inputs: q+='%s=%s&'%(i,self.inputs[i])
529                url=q[0:-1]
530                # and now build the links
531                c.narrower=[]
532                c.broader=[]
533                c.synonyms=[]
534                for i in narrower:
535                    c.narrower.append((i,url.replace('###SEARCHSSTRING###',i)))
536                for i in broader:
537                    c.broader.append((i,url.replace('###SEARCHSSTRING###',i)))
538                for i in synonyms:
539                    c.synonyms.append((i,url.replace('###SEARCHSSTRING###',i)))
540                if c.narrower!=[] or c.broader!=[] or c.synonyms!=[]: c.semAvailable=1
541            except IOError,e:
542                c.semAvailable=0
543                c.semError=' (No valid reply from vocabulary service)'
544                #This should go in a log file ...
545                print 'ERROR: Vocabulary Service: %s (for search [%s])'%(str(e),self.inputs['searchString'])
546        else:
547            broader,narrower,synonyms=[],[],[]
548            c.semAvailable=0
549            c.semError='.'
550       
551        return render('browse/semantic',fragment=True)
552
553   
554    def __displayBrowseSearchResults(self, searchClient):
555        '''
556        Provides the search results for Browse and NumSim content
557        @param searchClient: search client adhering to the ndg.common.clients.interfacesearchclient
558        interface - which has just ran a search
559        '''
560        c.results = searchClient.results
561        c.searchTarget = self.inputs['searchTarget']
562        textTarget = self.inputs['textTarget']
563
564        # check if we've done a search against atoms - NB, this should be the
565        # default eventually - so we can remove all the alternative options
566        isAtom = False
567        if textTarget == SearchClient.ATOM_TARGET:
568            isAtom = True
569       
570        for r in c.results:
571            id = r.id
572                # cope with atom docs
573            if isAtom:
574                r.link = r.href
575            else:
576                n=ndgObject(id,config=self.cf)
577                r.link={'Browse':n.BURL,'NumSim':n.URL}[c.searchTarget]
578
579        # filter atom docs according to publication state
580        if isAtom:
581            c.searchTerm = " - for search term, '%s'" %self.inputs['searchString']
582            if not g.atomEditorEnabled:
583                c.results = self.__filterAtomResults(c.results)
584
585            if c.results:
586                c.searchTerm += ' [%s results found]' %len(c.results)
587               
588               
589            html = render('genshi', 'browse/short_atom_results')
590            # make sure the edit links point to the editor, not the browse service
591            html = html.replace(VTD.BROWSE_SERVER_URL + '/editAtom', g.server + '/editAtom')
592            return html
593        else:
594            return render('browse/short_results')
595
596
597    def __filterAtomResults(self, results):
598        '''
599        Given a set of atom docs search results, filter these to only return docs in the
600        'published' or 'Published' state
601        @param results: list of results as returned by SearchClient
602        @return filteredResults: list of results with only published data included
603        '''
604        logging.debug("Filtering results to remove non-published data")
605        filteredResults = []
606        for result in results:
607            if result.collection.find('ublished') == -1:
608                logging.debug("- found non-published doc - ignoring")
609                continue
610            filteredResults.append(result)
611        logging.debug("- returning filtered results")
612        return filteredResults
613
614           
615    def clearSession(self):
616        '''
617        Clear out all session variables - to help when these change in development
618        '''
619        session.clear()
620        session.save()           
621     
Note: See TracBrowser for help on using the repository browser.