README update for latest SDK; misc small cleanup
[gae-samples.git] / search / product_search_python / handlers.py
blobe80831095a457eb738cca0c88203ee70322c0cf1
1 #!/usr/bin/env python
3 # Copyright 2012 Google Inc.
5 # Licensed under the Apache License, Version 2.0 (the "License");
6 # you may not use this file except in compliance with the License.
7 # You may obtain a copy of the License at
9 # http://www.apache.org/licenses/LICENSE-2.0
11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS,
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 # See the License for the specific language governing permissions and
15 # limitations under the License.
17 """Contains the non-admin ('user-facing') request handlers for the app."""
20 import logging
21 import time
22 import traceback
23 import urllib
24 import wsgiref
26 from base_handler import BaseHandler
27 import config
28 import docs
29 import models
30 import utils
32 from google.appengine.api import search
33 from google.appengine.api import users
34 from google.appengine.ext.deferred import defer
35 from google.appengine.ext import ndb
38 class IndexHandler(BaseHandler):
39 """Displays the 'home' page."""
41 def get(self):
42 cat_info = models.Category.getCategoryInfo()
43 sort_info = docs.Product.getSortMenu()
44 template_values = {
45 'cat_info': cat_info,
46 'sort_info': sort_info,
48 self.render_template('index.html', template_values)
51 class ShowProductHandler(BaseHandler):
52 """Display product details."""
54 def parseParams(self):
55 """Filter the param set to the expected params."""
56 # The dict can be modified to add any defined defaults.
58 params = {
59 'pid': '',
60 'pname': '',
61 'comment': '',
62 'rating': '',
63 'category': ''
65 for k, v in params.iteritems():
66 # Possibly replace default values.
67 params[k] = self.request.get(k, v)
68 return params
70 def get(self):
71 """Do a document search for the given product id,
72 and display the retrieved document fields."""
74 params = self.parseParams()
76 pid = params['pid']
77 if not pid:
78 # we should not reach this
79 msg = 'Error: do not have product id.'
80 url = '/'
81 linktext = 'Go to product search page.'
82 self.render_template(
83 'notification.html',
84 {'title': 'Error', 'msg': msg,
85 'goto_url': url, 'linktext': linktext})
86 return
87 doc = docs.Product.getDocFromPid(pid)
88 if not doc:
89 error_message = ('Document not found for pid %s.' % pid)
90 return self.abort(404, error_message)
91 logging.error(error_message)
92 pdoc = docs.Product(doc)
93 pname = pdoc.getName()
94 app_url = wsgiref.util.application_uri(self.request.environ)
95 rlink = '/reviews?' + urllib.urlencode({'pid': pid, 'pname': pname})
96 template_values = {
97 'app_url': app_url,
98 'pid': pid,
99 'pname': pname,
100 'review_link': rlink,
101 'comment': params['comment'],
102 'rating': params['rating'],
103 'category': pdoc.getCategory(),
104 'prod_doc': doc,
105 # for this demo, 'admin' status simply equates to being logged in
106 'user_is_admin': users.get_current_user()}
107 self.render_template('product.html', template_values)
110 class CreateReviewHandler(BaseHandler):
111 """Process the submission of a new review."""
113 def parseParams(self):
114 """Filter the param set to the expected params."""
116 params = {
117 'pid': '',
118 'pname': '',
119 'comment': 'this is a great product',
120 'rating': '5',
121 'category': ''
123 for k, v in params.iteritems():
124 # Possibly replace default values.
125 params[k] = self.request.get(k, v)
126 return params
128 def post(self):
129 """Create a new review entity from the submitted information."""
130 self.createReview(self.parseParams())
132 def createReview(self, params):
133 """Create a new review entity from the information in the params dict."""
135 author = users.get_current_user()
136 comment = params['comment']
137 pid = params['pid']
138 pname = params['pname']
139 if not pid:
140 msg = 'Could not get pid; aborting creation of review.'
141 logging.error(msg)
142 url = '/'
143 linktext = 'Go to product search page.'
144 self.render_template(
145 'notification.html',
146 {'title': 'Error', 'msg': msg,
147 'goto_url': url, 'linktext': linktext})
148 return
149 if not comment:
150 logging.info('comment not provided')
151 self.redirect('/product?' + urllib.urlencode(params))
152 return
153 rstring = params['rating']
154 # confirm that the rating is an int in the allowed range.
155 try:
156 rating = int(rstring)
157 if rating < config.RATING_MIN or rating > config.RATING_MAX:
158 logging.warn('Rating %s out of allowed range', rating)
159 params['rating'] = ''
160 self.redirect('/product?' + urllib.urlencode(params))
161 return
162 except ValueError:
163 logging.error('bad rating: %s', rstring)
164 params['rating'] = ''
165 self.redirect('/product?' + urllib.urlencode(params))
166 return
167 review = self.createAndAddReview(pid, author, rating, comment)
168 prod_url = '/product?' + urllib.urlencode({'pid': pid, 'pname': pname})
169 if not review:
170 msg = 'Error creating review.'
171 logging.error(msg)
172 self.render_template(
173 'notification.html',
174 {'title': 'Error', 'msg': msg,
175 'goto_url': prod_url, 'linktext': 'Back to product.'})
176 return
177 rparams = {'prod_url': prod_url, 'pname': pname, 'review': review}
178 self.render_template('review.html', rparams)
180 def createAndAddReview(self, pid, user, rating, comment):
181 """Given review information, create the new review entity, pointing via key
182 to the associated 'parent' product entity. """
184 # get the account info of the user submitting the review. If the
185 # client is not logged in (which is okay), just make them 'anonymous'.
186 if user:
187 username = user.nickname().split('@')[0]
188 else:
189 username = 'anonymous'
191 prod = models.Product.get_by_id(pid)
192 if not prod:
193 error_message = 'could not get product for pid %s' % pid
194 logging.error(error_message)
195 return self.abort(404, error_message)
197 rid = models.Review.allocate_ids(size=1)[0]
198 key = ndb.Key(models.Review._get_kind(), rid)
200 def _tx():
201 review = models.Review(
202 key=key,
203 product_key=prod.key,
204 username=username, rating=rating,
205 comment=comment)
206 review.put()
207 # in a transactional task, update the parent product's average
208 # rating to include this review's rating, and flag the review as
209 # processed.
210 defer(utils.updateAverageRating, key, _transactional=True)
211 return review
212 return ndb.transaction(_tx)
215 class ProductSearchHandler(BaseHandler):
216 """The handler for doing a product search."""
218 _DEFAULT_DOC_LIMIT = 3 #default number of search results to display per page.
219 _OFFSET_LIMIT = 1000
221 def parseParams(self):
222 """Filter the param set to the expected params."""
223 params = {
224 'qtype': '',
225 'query': '',
226 'category': '',
227 'sort': '',
228 'rating': '',
229 'offset': '0'
231 for k, v in params.iteritems():
232 # Possibly replace default values.
233 params[k] = self.request.get(k, v)
234 return params
236 def post(self):
237 params = self.parseParams()
238 self.redirect('/psearch?' + urllib.urlencode(
239 dict([k, v.encode('utf-8')] for k, v in params.items())))
241 def _getDocLimit(self):
242 """if the doc limit is not set in the config file, use the default."""
243 doc_limit = self._DEFAULT_DOC_LIMIT
244 try:
245 doc_limit = int(config.DOC_LIMIT)
246 except ValueError:
247 logging.error('DOC_LIMIT not properly set in config file; using default.')
248 return doc_limit
250 def get(self):
251 """Handle a product search request."""
253 params = self.parseParams()
254 self.doProductSearch(params)
256 def doProductSearch(self, params):
257 """Perform a product search and display the results."""
259 # the defined product categories
260 cat_info = models.Category.getCategoryInfo()
261 # the product fields that we can sort on from the UI, and their mappings to
262 # search.SortExpression parameters
263 sort_info = docs.Product.getSortMenu()
264 sort_dict = docs.Product.getSortDict()
265 query = params.get('query', '')
266 user_query = query
267 doc_limit = self._getDocLimit()
269 categoryq = params.get('category')
270 if categoryq:
271 # add specification of the category to the query
272 # Because the category field is atomic, put the category string
273 # in quotes for the search.
274 query += ' %s:"%s"' % (docs.Product.CATEGORY, categoryq)
276 sortq = params.get('sort')
277 try:
278 offsetval = int(params.get('offset', 0))
279 except ValueError:
280 offsetval = 0
282 # Check to see if the query parameters include a ratings filter, and
283 # add that to the final query string if so. At the same time, generate
284 # 'ratings bucket' counts and links-- based on the query prior to addition
285 # of the ratings filter-- for sidebar display.
286 query, rlinks = self._generateRatingsInfo(
287 params, query, user_query, sortq, categoryq)
288 logging.debug('query: %s', query.strip())
290 try:
291 # build the query and perform the search
292 search_query = self._buildQuery(
293 query, sortq, sort_dict, doc_limit, offsetval)
294 search_results = docs.Product.getIndex().search(search_query)
295 returned_count = len(search_results.results)
297 except search.Error:
298 logging.exception("Search error:") # log the exception stack trace
299 msg = 'There was a search error (see logs).'
300 url = '/'
301 linktext = 'Go to product search page.'
302 self.render_template(
303 'notification.html',
304 {'title': 'Error', 'msg': msg,
305 'goto_url': url, 'linktext': linktext})
306 return
308 # cat_name = models.Category.getCategoryName(categoryq)
309 psearch_response = []
310 # For each document returned from the search
311 for doc in search_results:
312 # logging.info("doc: %s ", doc)
313 pdoc = docs.Product(doc)
314 # use the description field as the default description snippet, since
315 # snippeting is not supported on the dev app server.
316 description_snippet = pdoc.getDescription()
317 price = pdoc.getPrice()
318 # on the dev app server, the doc.expressions property won't be populated.
319 for expr in doc.expressions:
320 if expr.name == docs.Product.DESCRIPTION:
321 description_snippet = expr.value
322 # uncomment to use 'adjusted price', which should be
323 # defined in returned_expressions in _buildQuery() below, as the
324 # displayed price.
325 # elif expr.name == 'adjusted_price':
326 # price = expr.value
328 # get field information from the returned doc
329 pid = pdoc.getPID()
330 cat = catname = pdoc.getCategory()
331 pname = pdoc.getName()
332 avg_rating = pdoc.getAvgRating()
333 # for this result, generate a result array of selected doc fields, to
334 # pass to the template renderer
335 psearch_response.append(
336 [doc, urllib.quote_plus(pid), cat,
337 description_snippet, price, pname, catname, avg_rating])
338 if not query:
339 print_query = 'All'
340 else:
341 print_query = query
343 # Build the next/previous pagination links for the result set.
344 (prev_link, next_link) = self._generatePaginationLinks(
345 offsetval, returned_count,
346 search_results.number_found, params)
348 logging.debug('returned_count: %s', returned_count)
349 # construct the template values
350 template_values = {
351 'base_pquery': user_query, 'next_link': next_link,
352 'prev_link': prev_link, 'qtype': 'product',
353 'query': query, 'print_query': print_query,
354 'pcategory': categoryq, 'sort_order': sortq, 'category_name': categoryq,
355 'first_res': offsetval + 1, 'last_res': offsetval + returned_count,
356 'returned_count': returned_count,
357 'number_found': search_results.number_found,
358 'search_response': psearch_response,
359 'cat_info': cat_info, 'sort_info': sort_info,
360 'ratings_links': rlinks}
361 # render the result page.
362 self.render_template('index.html', template_values)
364 def _buildQuery(self, query, sortq, sort_dict, doc_limit, offsetval):
365 """Build and return a search query object."""
367 # computed and returned fields examples. Their use is not required
368 # for the application to function correctly.
369 computed_expr = search.FieldExpression(name='adjusted_price',
370 expression='price * 1.08')
371 returned_fields = [docs.Product.PID, docs.Product.DESCRIPTION,
372 docs.Product.CATEGORY, docs.Product.AVG_RATING,
373 docs.Product.PRICE, docs.Product.PRODUCT_NAME]
375 if sortq == 'relevance':
376 # If sorting on 'relevance', use the Match scorer.
377 sortopts = search.SortOptions(match_scorer=search.MatchScorer())
378 search_query = search.Query(
379 query_string=query.strip(),
380 options=search.QueryOptions(
381 limit=doc_limit,
382 offset=offsetval,
383 sort_options=sortopts,
384 snippeted_fields=[docs.Product.DESCRIPTION],
385 returned_expressions=[computed_expr],
386 returned_fields=returned_fields
388 else:
389 # Otherwise (not sorting on relevance), use the selected field as the
390 # first dimension of the sort expression, and the average rating as the
391 # second dimension, unless we're sorting on rating, in which case price
392 # is the second sort dimension.
393 # We get the sort direction and default from the 'sort_dict' var.
394 if sortq == docs.Product.AVG_RATING:
395 expr_list = [sort_dict.get(sortq), sort_dict.get(docs.Product.PRICE)]
396 else:
397 expr_list = [sort_dict.get(sortq), sort_dict.get(
398 docs.Product.AVG_RATING)]
399 sortopts = search.SortOptions(expressions=expr_list)
400 # logging.info("sortopts: %s", sortopts)
401 search_query = search.Query(
402 query_string=query.strip(),
403 options=search.QueryOptions(
404 limit=doc_limit,
405 offset=offsetval,
406 sort_options=sortopts,
407 snippeted_fields=[docs.Product.DESCRIPTION],
408 returned_expressions=[computed_expr],
409 returned_fields=returned_fields
411 return search_query
413 def _generateRatingsInfo(
414 self, params, query, user_query, sort, category):
415 """Add a ratings filter to the query as necessary, and build the
416 sidebar ratings buckets content."""
418 orig_query = query
419 try:
420 n = int(params.get('rating', 0))
421 # check that rating is not out of range
422 if n < config.RATING_MIN or n > config.RATING_MAX:
423 n = None
424 except ValueError:
425 n = None
426 if n:
427 if n < config.RATING_MAX:
428 query += ' %s >= %s %s < %s' % (docs.Product.AVG_RATING, n,
429 docs.Product.AVG_RATING, n+1)
430 else: # max rating
431 query += ' %s:%s' % (docs.Product.AVG_RATING, n)
432 query_info = {'query': user_query.encode('utf-8'), 'sort': sort,
433 'category': category}
434 rlinks = docs.Product.generateRatingsLinks(orig_query, query_info)
435 return (query, rlinks)
437 def _generatePaginationLinks(
438 self, offsetval, returned_count, number_found, params):
439 """Generate the next/prev pagination links for the query. Detect when we're
440 out of results in a given direction and don't generate the link in that
441 case."""
443 doc_limit = self._getDocLimit()
444 pcopy = params.copy()
445 if offsetval - doc_limit >= 0:
446 pcopy['offset'] = offsetval - doc_limit
447 prev_link = '/psearch?' + urllib.urlencode(pcopy)
448 else:
449 prev_link = None
450 if ((offsetval + doc_limit <= self._OFFSET_LIMIT)
451 and (returned_count == doc_limit)
452 and (offsetval + returned_count < number_found)):
453 pcopy['offset'] = offsetval + doc_limit
454 next_link = '/psearch?' + urllib.urlencode(pcopy)
455 else:
456 next_link = None
457 return (prev_link, next_link)
460 class ShowReviewsHandler(BaseHandler):
461 """Show the reviews for a given product. This information is pulled from the
462 datastore Review entities."""
464 def get(self):
465 """Show a list of reviews for the product indicated by the 'pid' request
466 parameter."""
468 pid = self.request.get('pid')
469 pname = self.request.get('pname')
470 if pid:
471 # find the product entity corresponding to that pid
472 prod = models.Product.get_by_id(pid)
473 if prod:
474 avg_rating = prod.avg_rating # get the product's average rating, over
475 # all its reviews
476 # get the list of review entities for the product
477 reviews = prod.reviews()
478 logging.debug('reviews: %s', reviews)
479 else:
480 error_message = 'could not get product for pid %s' % pid
481 logging.error(error_message)
482 return self.abort(404, error_message)
483 rlist = [[r.username, r.rating, str(r.comment)] for r in reviews]
485 # build a template dict with the review and product information
486 prod_url = '/product?' + urllib.urlencode({'pid': pid, 'pname': pname})
487 template_values = {
488 'rlist': rlist,
489 'prod_url': prod_url,
490 'pname': pname,
491 'avg_rating': avg_rating}
492 # render the template.
493 self.render_template('reviews.html', template_values)
495 class StoreLocationHandler(BaseHandler):
496 """Show the reviews for a given product. This information is pulled from the
497 datastore Review entities."""
499 def get(self):
500 """Show a list of reviews for the product indicated by the 'pid' request
501 parameter."""
503 query = self.request.get('location_query')
504 lat = self.request.get('latitude')
505 lon = self.request.get('longitude')
506 # the location query from the client will have this form:
507 # distance(store_location, geopoint(37.7899528, -122.3908226)) < 40000
508 # logging.info('location query: %s, lat %s, lon %s', query, lat, lon)
509 try:
510 index = search.Index(config.STORE_INDEX_NAME)
511 # search using simply the query string:
512 # results = index.search(query)
513 # alternately: sort results by distance
514 loc_expr = 'distance(store_location, geopoint(%s, %s))' % (lat, lon)
515 sortexpr = search.SortExpression(
516 expression=loc_expr,
517 direction=search.SortExpression.ASCENDING, default_value=0)
518 sortopts = search.SortOptions(expressions=[sortexpr])
519 search_query = search.Query(
520 query_string=query.strip(),
521 options=search.QueryOptions(
522 sort_options=sortopts,
524 results = index.search(search_query)
525 except search.Error:
526 logging.exception("There was a search error:")
527 self.render_json([])
528 return
529 # logging.info("geo search results: %s", results)
530 response_obj2 = []
531 for res in results:
532 gdoc = docs.Store(res)
533 geopoint = gdoc.getFirstFieldVal(gdoc.STORE_LOCATION)
534 resp = {'addr': gdoc.getFirstFieldVal(gdoc.STORE_ADDRESS),
535 'storename': gdoc.getFirstFieldVal(gdoc.STORE_NAME),
536 'lat': geopoint.latitude, 'lon': geopoint.longitude}
537 response_obj2.append(resp)
538 logging.info("resp: %s", response_obj2)
539 self.render_json(response_obj2)