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."""
26 from base_handler
import BaseHandler
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."""
42 cat_info
= models
.Category
.getCategoryInfo()
43 sort_info
= docs
.Product
.getSortMenu()
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.
65 for k
, v
in params
.iteritems():
66 # Possibly replace default values.
67 params
[k
] = self
.request
.get(k
, v
)
71 """Do a document search for the given product id,
72 and display the retrieved document fields."""
74 params
= self
.parseParams()
78 # we should not reach this
79 msg
= 'Error: do not have product id.'
81 linktext
= 'Go to product search page.'
84 {'title': 'Error', 'msg': msg
,
85 'goto_url': url
, 'linktext': linktext
})
87 doc
= docs
.Product
.getDocFromPid(pid
)
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
})
100 'review_link': rlink
,
101 'comment': params
['comment'],
102 'rating': params
['rating'],
103 'category': pdoc
.getCategory(),
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."""
119 'comment': 'this is a great product',
123 for k
, v
in params
.iteritems():
124 # Possibly replace default values.
125 params
[k
] = self
.request
.get(k
, v
)
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']
138 pname
= params
['pname']
140 msg
= 'Could not get pid; aborting creation of review.'
143 linktext
= 'Go to product search page.'
144 self
.render_template(
146 {'title': 'Error', 'msg': msg
,
147 'goto_url': url
, 'linktext': linktext
})
150 logging
.info('comment not provided')
151 self
.redirect('/product?' + urllib
.urlencode(params
))
153 rstring
= params
['rating']
154 # confirm that the rating is an int in the allowed range.
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
))
163 logging
.error('bad rating: %s', rstring
)
164 params
['rating'] = ''
165 self
.redirect('/product?' + urllib
.urlencode(params
))
167 review
= self
.createAndAddReview(pid
, author
, rating
, comment
)
168 prod_url
= '/product?' + urllib
.urlencode({'pid': pid
, 'pname': pname
})
170 msg
= 'Error creating review.'
172 self
.render_template(
174 {'title': 'Error', 'msg': msg
,
175 'goto_url': prod_url
, 'linktext': 'Back to product.'})
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'.
187 username
= user
.nickname().split('@')[0]
189 username
= 'anonymous'
191 prod
= models
.Product
.get_by_id(pid
)
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
)
201 review
= models
.Review(
203 product_key
=prod
.key
,
204 username
=username
, rating
=rating
,
207 # in a transactional task, update the parent product's average
208 # rating to include this review's rating, and flag the review as
210 defer(utils
.updateAverageRating
, key
, _transactional
=True)
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.
221 def parseParams(self
):
222 """Filter the param set to the expected params."""
231 for k
, v
in params
.iteritems():
232 # Possibly replace default values.
233 params
[k
] = self
.request
.get(k
, v
)
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
245 doc_limit
= int(config
.DOC_LIMIT
)
247 logging
.error('DOC_LIMIT not properly set in config file; using default.')
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', '')
267 doc_limit
= self
._getDocLimit
()
269 categoryq
= params
.get('category')
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')
278 offsetval
= int(params
.get('offset', 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())
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
)
298 logging
.exception("Search error:") # log the exception stack trace
299 msg
= 'There was a search error (see logs).'
301 linktext
= 'Go to product search page.'
302 self
.render_template(
304 {'title': 'Error', 'msg': msg
,
305 'goto_url': url
, 'linktext': linktext
})
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
325 # elif expr.name == 'adjusted_price':
328 # get field information from the returned doc
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
])
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
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(
383 sort_options
=sortopts
,
384 snippeted_fields
=[docs
.Product
.DESCRIPTION
],
385 returned_expressions
=[computed_expr
],
386 returned_fields
=returned_fields
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
)]
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(
406 sort_options
=sortopts
,
407 snippeted_fields
=[docs
.Product
.DESCRIPTION
],
408 returned_expressions
=[computed_expr
],
409 returned_fields
=returned_fields
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."""
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
:
427 if n
< config
.RATING_MAX
:
428 query
+= ' %s >= %s %s < %s' % (docs
.Product
.AVG_RATING
, n
,
429 docs
.Product
.AVG_RATING
, n
+1)
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
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
)
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
)
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."""
465 """Show a list of reviews for the product indicated by the 'pid' request
468 pid
= self
.request
.get('pid')
469 pname
= self
.request
.get('pname')
471 # find the product entity corresponding to that pid
472 prod
= models
.Product
.get_by_id(pid
)
474 avg_rating
= prod
.avg_rating
# get the product's average rating, over
476 # get the list of review entities for the product
477 reviews
= prod
.reviews()
478 logging
.debug('reviews: %s', reviews
)
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
})
489 'prod_url': prod_url
,
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."""
500 """Show a list of reviews for the product indicated by the 'pid' request
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)
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(
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
)
526 logging
.exception("There was a search error:")
529 # logging.info("geo search results: %s", results)
532 gdoc
= docs
.Store(res
)
533 geopoint
= gdoc
.getFieldVal(gdoc
.STORE_LOCATION
)
534 resp
= {'addr': gdoc
.getFieldVal(gdoc
.STORE_ADDRESS
),
535 'storename': gdoc
.getFieldVal(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
)