From 594de157e86ae8982dec8f5f58f8ee20f0c1efe6 Mon Sep 17 00:00:00 2001 From: "amyu@google.com" Date: Tue, 24 Jul 2012 02:28:04 +0000 Subject: [PATCH] add demonstration of geosearch git-svn-id: http://google-app-engine-samples.googlecode.com/svn/trunk@159 99225164-8649-0410-878b-2ba91e509939 --- search/product_search_python/README | 32 +-- search/product_search_python/admin_handlers.py | 42 +++- search/product_search_python/base_handler.py | 18 +- search/product_search_python/categories.py | 4 +- search/product_search_python/config.py | 2 + .../data/sample_data_books.csv | 2 +- .../product_search_python/data/sample_data_tvs.csv | 8 +- search/product_search_python/docs.py | 110 +++++---- search/product_search_python/handlers.py | 119 ++++++++-- search/product_search_python/main.py | 3 +- search/product_search_python/models.py | 54 +---- search/product_search_python/static/instrs.html | 69 +++--- .../static/js/StyledMarker.js | 264 +++++++++++++++++++++ search/product_search_python/stores.py | 45 ++++ .../templates/create_product.html | 2 +- search/product_search_python/templates/index.html | 12 +- .../product_search_python/templates/product.html | 171 ++++++++++++- 17 files changed, 767 insertions(+), 190 deletions(-) create mode 100644 search/product_search_python/static/js/StyledMarker.js create mode 100644 search/product_search_python/stores.py diff --git a/search/product_search_python/README b/search/product_search_python/README index 50bbd4f..b0d2a80 100644 --- a/search/product_search_python/README +++ b/search/product_search_python/README @@ -7,7 +7,7 @@ This Python App Engine application illustrates the use of the [Full-Text Search API](https://developers.google.com/appengine/docs/python/search) in a "Product Search" domain with two categories of sample products: *books* and -*televisions*. This README assumes that you are already familiar with how to +*hd televisions*. This README assumes that you are already familiar with how to configure and deploy an App Engine app. If not, first see the App Engine [documentation](https://developers.google.com/appengine/docs/python/overview) and [Getting Started guide](https://developers.google.com/appengine/docs/python/gettingstarted). @@ -73,20 +73,17 @@ restarts, for consistency **the batch addition of sample data first removes all existing index and datastore product data**. The second way to add sample data is via the admin's "Create new product" link -in the sidebar, which lets an admin add products (either "books" or -"televisions") one at a time. +in the sidebar, which lets an admin add sample products (either "books" or +"hd televisions") one at a time. ## Updating product documents with a new average rating -When a user creates a new review, the average rating for that product is updated -in the datastore. The app may be configured to update the associated product -`search.Document` at the same time, or do this at a later time in batch (which -is more efficient). A cron job has been defined (in `cron.yaml`) to -periodically check for any products that need a ratings update. An app admin can -also run the batch job directly from the admin page. This behaviour is -configured via `config.BATCH_RATINGS_UPDATE` - if `False`, documents are updated -right away. +When a user creates a new review, the average rating for that product is +updated in the datastore. The app may be configured to update the associated +product `search.Document` at the same time (the default), or do this at a +later time in batch (which is more efficient). See `cron.yaml` for an example +of how to do this update periodically in batch. ## Searches @@ -107,9 +104,8 @@ search. ### Some example searches -Below are some example queries, which assume the sample data has been loaded. -As discussed above, not all of these queries are supported by the dev app -server. +Below are some example product queries, which assume the sample data has been loaded. +As discussed above, not all of these queries are supported by the dev_appserver. `stories price < 10` `price > 10 price < 15` @@ -117,3 +113,11 @@ server. `Mega TVs` `name:tv1` `size > 30` + +## Geosearch + +This application includes an example of using the Search API to perform +location-based queries. Sample store location data is defined in `stores.py`, +and is loaded along with the product data. The product details page for a +product allows a search for stores within a given radius of the user's current +location. The user's location is obtained from the browser. diff --git a/search/product_search_python/admin_handlers.py b/search/product_search_python/admin_handlers.py index a0aa37f..10d542d 100644 --- a/search/product_search_python/admin_handlers.py +++ b/search/product_search_python/admin_handlers.py @@ -30,11 +30,13 @@ import config import docs import errors import models +import stores import utils from google.appengine.api import users from google.appengine.ext.deferred import defer from google.appengine.ext import ndb +from google.appengine.api import search def reinitAll(sample_data=True): @@ -51,14 +53,16 @@ def reinitAll(sample_data=True): ndb.delete_multi(review_keys) prod_keys = models.Product.query().fetch(keys_only=True) ndb.delete_multi(prod_keys) - # delete all the associated product documents in the doc index + # delete all the associated product documents in the doc and + # store indexes docs.Product.deleteAllInProductIndex() + docs.Store.deleteAllInIndex() # load in sample data if indicated if sample_data: logging.info('Loading product sample data') # Load from csv sample files. # The following are hardwired to the format of the sample data files - # for the two example product types ('books' and 'televisions')-- see + # for the two example product types ('books' and 'hd televisions')-- see # categories.py datafile = os.path.join('data', config.SAMPLE_DATA_BOOKS) # books @@ -76,8 +80,29 @@ def reinitAll(sample_data=True): 'size', 'brand', 'tv_type', 'description']) importData(reader) + + # next create docs from store location info + loadStoreLocationData() + logging.info('Re-initialization complete.') +def loadStoreLocationData(): + # create documents from store location info + # currently logs but otherwise swallows search errors. + slocs = stores.stores + for s in slocs: + logging.info("s: %s", s) + geopoint = search.GeoPoint(s[3][0], s[3][1]) + fields = [search.TextField(name='storename', value=s[1]), + search.TextField(name='address', value=s[2]), + search.GeoField(name='store_location', value=geopoint) + ] + d = search.Document(doc_id=s[0], fields=fields) + try: + add_result = search.Index(config.STORE_INDEX_NAME).add(d) + except search.Error: + logging.exception("Error adding document:") + def importData(reader): """Import via the csv reader iterator using the specified batch size as set in @@ -114,7 +139,7 @@ class AdminHandler(BaseHandler): tdict['notification'] = notification self.render_template('admin.html', tdict) - @BaseHandler.admin + @BaseHandler.logged_in def get(self): action = self.request.get('action') if action == 'reinit': @@ -161,7 +186,7 @@ class DeleteProductHandler(BaseHandler): """Remove data for the product with the given pid, including that product's reviews and its associated indexed document.""" - @BaseHandler.admin + @BaseHandler.logged_in def post(self): pid = self.request.get('pid') if not pid: # this should not be reached @@ -213,10 +238,7 @@ class CreateProductHandler(BaseHandler): if doc: # populate default params from the doc fields = doc.fields for f in fields: - if f.name == 'catname': - params['category'] = f.value - else: - params[f.name] = f.value + params[f.name] = f.value else: # start with the 'core' fields params = { @@ -238,12 +260,12 @@ class CreateProductHandler(BaseHandler): params[k] = self.request.get(k, v) return params - @BaseHandler.admin + @BaseHandler.logged_in def get(self): params = self.parseParams() self.render_template('create_product.html', params) - @BaseHandler.admin + @BaseHandler.logged_in def post(self): self.createProduct(self.parseParams()) diff --git a/search/product_search_python/base_handler.py b/search/product_search_python/base_handler.py index dc42f71..12078b7 100644 --- a/search/product_search_python/base_handler.py +++ b/search/product_search_python/base_handler.py @@ -20,6 +20,7 @@ import webapp2 from webapp2_extras import jinja2 +import json from google.appengine.api import users @@ -29,12 +30,12 @@ class BaseHandler(webapp2.RequestHandler): for rendering a template and generating template links.""" @classmethod - def admin(cls, handler_method): + def logged_in(cls, handler_method): """ - This decorator requires an admin user, and returns 403 otherwise. + This decorator requires a logged-in user, and returns 403 otherwise. """ def auth_required(self, *args, **kwargs): - if (users.is_current_user_admin() or + if (users.get_current_user() or self.request.headers.get('X-AppEngine-Cron')): handler_method(self, *args, **kwargs) else: @@ -49,6 +50,10 @@ class BaseHandler(webapp2.RequestHandler): template_args.update(self.generateSidebarLinksDict()) self.response.write(self.jinja2.render_template(filename, **template_args)) + def render_json(self, response): + self.response.write("%s(%s);" % (self.request.GET['callback'], + json.dumps(response))) + def getLoginLink(self): """Generate login or logout link and text, depending upon the logged-in status of the client.""" @@ -61,16 +66,15 @@ class BaseHandler(webapp2.RequestHandler): return (url, url_linktext) def getAdminManageLink(self): - """Build link to the admin management page, only if the loggged-in user - is an admin.""" - if users.is_current_user_admin(): + """Build link to the admin management page, if the user is logged in.""" + if users.get_current_user(): admin_url = '/admin/manage' return (admin_url, 'Admin/Add sample data') else: return (None, None) def createProductAdminLink(self): - if users.is_current_user_admin(): + if users.get_current_user(): admin_create_url = '/admin/create_product' return (admin_create_url, 'Create new product (admin)') else: diff --git a/search/product_search_python/categories.py b/search/product_search_python/categories.py index c9ed0d0..91520a9 100644 --- a/search/product_search_python/categories.py +++ b/search/product_search_python/categories.py @@ -20,7 +20,7 @@ are two categories: books, and televisions. from google.appengine.api import search -televisions = {'name': 'televisions', 'children': []} +televisions = {'name': 'hd televisions', 'children': []} books = {'name': 'books', 'children': []} ctree = {'name': 'root', 'children': [books, televisions]} @@ -29,7 +29,7 @@ ctree = {'name': 'root', 'children': [books, televisions]} # category, category name, and price] # Define the non-'core' (differing) product fields for each category # above, and their types. -product_dict = {'televisions': {'size': search.NumberField, +product_dict = {'hd televisions': {'size': search.NumberField, 'brand': search.TextField, 'tv_type': search.TextField}, 'books': {'publisher': search.TextField, diff --git a/search/product_search_python/config.py b/search/product_search_python/config.py index 103a142..922e8ac 100644 --- a/search/product_search_python/config.py +++ b/search/product_search_python/config.py @@ -23,6 +23,8 @@ PRODUCT_INDEX_NAME = 'productsearch1' # The document index name. # ASCII string not starting with '!'. Whitespace characters are # excluded. +STORE_INDEX_NAME = 'stores1' + # set BATCH_RATINGS_UPDATE to False to update documents with changed ratings # info right away. If True, updates will only occur when triggered by # an admin request or a cron job. See cron.yaml for an example. diff --git a/search/product_search_python/data/sample_data_books.csv b/search/product_search_python/data/sample_data_books.csv index ce41771..1a6b5f9 100644 --- a/search/product_search_python/data/sample_data_books.csv +++ b/search/product_search_python/data/sample_data_books.csv @@ -1,4 +1,4 @@ -testbookprod0,The Epicure's Lament,books,11.18,Anchor,The Epicure's Lament,368,Kate Christensen,"Christensen's two previous novels (Jeremy Thrane; In the Drink) were delightfully believable, sympathetic contemporary narratives filled with wry humor and appealing protagonists. Here she ups the ante, with loftier literary aspirations and succeeds masterfully.",038572098X +testbookprod0,The Epicure's Lament,books,12.24,Anchor,The Epicure's Lament,368,Kate Christensen,"Christensen's two previous novels (Jeremy Thrane; In the Drink) were delightfully believable, sympathetic contemporary narratives filled with wry humor and appealing protagonists. Here she ups the ante, with loftier literary aspirations and succeeds masterfully.",038572098X testbookprod1,Otherwise Known as the Human Condition,books,12.24,Graywolf Press,Otherwise Known as the Human Condition: Selected Essays and Reviews,432,Geoff Dyer,"Starred Review. In this new collection of previously published writings, Dyer (Jeff in Venice, Death in Varanasi) traverses a broad territory stretching from photographers such as Richard Avedon and William Gedney (His gaze is neither penetrating nor alert but, on reflection, we would amend that verdict to accepting); musicians Miles Davis and Def Leppard; writers like D.H. Lawrence, Ian McEwan, and Richard Ford; as well as personal ruminations on, say, reader's block.",1555975798 diff --git a/search/product_search_python/data/sample_data_tvs.csv b/search/product_search_python/data/sample_data_tvs.csv index 1cb3028..018054a 100644 --- a/search/product_search_python/data/sample_data_tvs.csv +++ b/search/product_search_python/data/sample_data_tvs.csv @@ -1,4 +1,4 @@ -testtvprod0,tv0,televisions,1999.99,32,Mega TVs,plasma,Mega TV Plasma 32" -testtvprod1,tv1,televisions,1299.99,28,Mega TVs,lcd,Mega TV LCD 28" -testtvprod2,tv2,televisions,899.99,26,Mega TVs,plasma,Mega TV Plasma 26" -testtvprod3,tv3,televisions,1899.99,32,Mega TVs,lcd,Mega TV LCD 32" +testtvprod0,tv0,hd televisions,1999.99,32,Mega TVs,plasma,Mega TV Plasma 32" +testtvprod1,tv1,hd televisions,1299.99,28,Mega TVs,lcd,Mega TV LCD 28" +testtvprod2,tv2,hd televisions,899.99,26,Mega TVs,plasma,Mega TV Plasma 26" +testtvprod3,tv3,hd televisions,1899.99,32,Mega TVs,lcd,Mega TV LCD 32" diff --git a/search/product_search_python/docs.py b/search/product_search_python/docs.py index ccf939f..ae0ce01 100644 --- a/search/product_search_python/docs.py +++ b/search/product_search_python/docs.py @@ -21,6 +21,7 @@ adds some Product-document-specific helper methods. import collections import copy +import datetime import logging import re import string @@ -107,7 +108,7 @@ class BaseDocumentManager(object): def getDoc(cls, doc_id): """Return the document with the given doc id. One way to do this is via the list_documents method, as shown here. If the doc id is not in the - index, the first doc in the list will be returned instead, so we need + index, the first doc in the index will be returned instead, so we need to check for that case.""" if not doc_id: return None @@ -138,6 +139,11 @@ class BaseDocumentManager(object): logging.exception("Error adding documents.") +class Store(BaseDocumentManager): + + _INDEX_NAME = config.STORE_INDEX_NAME + + class Product(BaseDocumentManager): """Provides helper methods to manage Product documents. All Product documents built using these methods will include a core set of fields (see the @@ -153,24 +159,34 @@ class Product(BaseDocumentManager): # 'core' product document field names PID = 'pid' DESCRIPTION = 'description' - CAT = 'cat' - CATNAME = 'catname' - PNAME = 'name' + CATEGORY = 'category' + PRODUCT_NAME = 'name' PRICE = 'price' - AR = 'ar' #average rating + AVG_RATING = 'ar' #average rating + UPDATED = 'modified' _SORT_OPTIONS = [ - [AR, 'average rating', search.SortExpression( - expression=AR, + [AVG_RATING, 'average rating', search.SortExpression( + expression=AVG_RATING, direction=search.SortExpression.DESCENDING, default_value=1)], [PRICE, 'price', search.SortExpression( + # other examples: + # expression='max(price, 14.99)' + # If you access _score in your sort expressions, + # your SortOptions should include a scorer. + # e.g. search.SortOptions(match_scorer=search.MatchScorer(),...) + # Then, you can access the score to build expressions like: + # expression='price * _score' expression=PRICE, direction=search.SortExpression.ASCENDING, default_value=1)], - [CATNAME, 'category', search.SortExpression( - expression=CATNAME, + [UPDATED, 'modified', search.SortExpression( + expression=UPDATED, + direction=search.SortExpression.DESCENDING, default_value=1)], + [CATEGORY, 'category', search.SortExpression( + expression=CATEGORY, direction=search.SortExpression.ASCENDING, default_value='')], - [PNAME, 'product name', search.SortExpression( - expression=PNAME, + [PRODUCT_NAME, 'product name', search.SortExpression( + expression=PRODUCT_NAME, direction=search.SortExpression.ASCENDING, default_value='')] ] @@ -228,10 +244,6 @@ class Product(BaseDocumentManager): doc = cls.getDoc(doc_id) if doc: pdoc = cls(doc) - cat = pdoc.getCategory() - # The category cast to int is to avoid a current dev appserver issue when - # reindexing. Not an issue for a deployed app. - pdoc.setCategory(int(cat)) pdoc.setAvgRating(avg_rating) # The use of the same id will cause the existing doc to be reindexed. return doc @@ -248,7 +260,7 @@ class Product(BaseDocumentManager): # reindex the returned updated doc return cls.add(ndoc) -# 'accessor' methods +# 'accessor' convenience methods def getPID(self): """Get the value of the 'pid' field of a Product doc.""" @@ -256,7 +268,7 @@ class Product(BaseDocumentManager): def getName(self): """Get the value of the 'name' field of a Product doc.""" - return self.getFirstFieldVal(self.PNAME) + return self.getFirstFieldVal(self.PRODUCT_NAME) def getDescription(self): """Get the value of the 'description' field of a Product doc.""" @@ -264,23 +276,19 @@ class Product(BaseDocumentManager): def getCategory(self): """Get the value of the 'cat' field of a Product doc.""" - return self.getFirstFieldVal(self.CAT) - - def getCategoryName(self): - """Get the value of the 'catname' field of a Product doc.""" - return self.getFirstFieldVal(self.CATNAME) + return self.getFirstFieldVal(self.CATEGORY) def setCategory(self, cat): """Set the value of the 'cat' (category) field of a Product doc.""" - return self.setFirstField(search.NumberField(name=self.CAT, value=cat)) + return self.setFirstField(search.NumberField(name=self.CATEGORY, value=cat)) def getAvgRating(self): """Get the value of the 'ar' (average rating) field of a Product doc.""" - return self.getFirstFieldVal(self.AR) + return self.getFirstFieldVal(self.AVG_RATING) def setAvgRating(self, ar): """Set the value of the 'ar' field of a Product doc.""" - return self.setFirstField(search.NumberField(name=self.AR, value=ar)) + return self.setFirstField(search.NumberField(name=self.AVG_RATING, value=ar)) def getPrice(self): """Get the value of the 'price' field of a Product doc.""" @@ -349,7 +357,10 @@ class Product(BaseDocumentManager): may add additional specialized fields; these will be appended to this core list. (see _buildProductFields).""" fields = [search.TextField(name=cls.PID, value=pid), - search.TextField(name=cls.PNAME, value=name), + # The 'updated' field is always set to the current date. + search.DateField(name=cls.UPDATED, + value=datetime.datetime.now().date()), + search.TextField(name=cls.PRODUCT_NAME, value=name), # strip the markup from the description value, which can # potentially come from user input. We do this so that # we don't need to sanitize the description in the @@ -363,9 +374,8 @@ class Product(BaseDocumentManager): search.TextField( name=cls.DESCRIPTION, value=re.sub(r'<[^>]*?>', '', description)), - search.NumberField(name=cls.CAT, value=category), - search.TextField(name=cls.CATNAME, value=category_name), - search.NumberField(name=cls.AR, value=0.0), + search.AtomField(name=cls.CATEGORY, value=category), + search.NumberField(name=cls.AVG_RATING, value=0.0), search.NumberField(name=cls.PRICE, value=price) ] return fields @@ -403,8 +413,8 @@ class Product(BaseDocumentManager): elif field_type == search.TextField: fields.append(search.TextField(name=k, value=str(v))) else: - # TODO -- add handling of other field types for generality. Not - # needed for our current sample data. + # you may want to add handling of other field types for generality. + # Not needed for our current sample data. logging.warn('not processed: %s, %s, of type %s', k, v, field_type) else: error_message = ('value not given for field "%s" of field type "%s"' @@ -448,12 +458,11 @@ class Product(BaseDocumentManager): """Normalize the submitted params for building a product.""" params = copy.deepcopy(params) - chash = models.Category.getCategoryDict() try: params['pid'] = params['pid'].strip() params['name'] = params['name'].strip() params['category_name'] = params['category'] - params['category'] = int(chash.get(params['category'])) + params['category'] = params['category'] try: params['price'] = float(params['price']) except ValueError: @@ -462,6 +471,7 @@ class Product(BaseDocumentManager): raise errors.OperationFailedError(error_message) return params except KeyError as e1: + logging.exception("key error") raise errors.OperationFailedError(e1) except errors.Error as e2: logging.debug( @@ -472,7 +482,9 @@ class Product(BaseDocumentManager): def buildProductBatch(cls, rows): """Build product documents and their related datastore entities, in batch, given a list of params dicts. Should be used for new products, as does not - handle updates of existing product entities.""" + handle updates of existing product entities. This method does not require + that the doc ids be tied to the product ids, and obtains the doc ids from + the results of the document add.""" docs = [] dbps = [] @@ -488,14 +500,20 @@ class Product(BaseDocumentManager): dbps.append(dbp) except errors.OperationFailedError: logging.error('error creating document from data: %s', row) - doc_ids = cls.add(docs) - if len(doc_ids) != len(dbps): + try: + add_results = cls.add(docs) + except search.Error: + logging.exception('Add failed') + return + if len(add_results) != len(dbps): + # this case should not be reached; if there was an issue, + # search.Error should have been thrown, above. raise errors.OperationFailedError( - 'Error: wrong number of doc ids returned from indexing operation') + 'Error: wrong number of results returned from indexing operation') # now set the entities with the doc ids, the list of which are returned in # the same order as the list of docs given to the indexers for i, dbp in enumerate(dbps): - dbp.doc_id = doc_ids[i].document_id + dbp.doc_id = add_results[i].id # persist the entities ndb.put_multi(dbps) @@ -505,21 +523,19 @@ class Product(BaseDocumentManager): product id and the field values are taken from the params dict. """ params = cls._normalizeParams(params) - # check to see if doc already exists + # check to see if doc already exists. We do this because we need to retain + # some information from the existing doc. We could skip the fetch if this + # were not the case. curr_doc = cls.getDocFromPid(params['pid']) d = cls._createDocument(**params) - if curr_doc: #don't overwrite ratings info from existing doc - try: - avg_rating = cls(curr_doc).getAvgRating() - cls(d).setAvgRating(avg_rating) - except TypeError: - # catch potential issue with 0-valued numeric fields in older SDK - logging.exception("catch 0-valued field error:") + if curr_doc: # retain ratings info from existing doc + avg_rating = cls(curr_doc).getAvgRating() + cls(d).setAvgRating(avg_rating) # This will reindex if a doc with that doc id already exists doc_ids = cls.add(d) try: - doc_id = doc_ids[0].document_id + doc_id = doc_ids[0].object_id except IndexError: doc_id = None raise errors.OperationFailedError('could not index document') diff --git a/search/product_search_python/handlers.py b/search/product_search_python/handlers.py index 754b3ae..bae0664 100644 --- a/search/product_search_python/handlers.py +++ b/search/product_search_python/handlers.py @@ -21,6 +21,7 @@ import logging import time import traceback import urllib +import wsgiref from base_handler import BaseHandler import config @@ -90,8 +91,10 @@ class ShowProductHandler(BaseHandler): logging.error(error_message) pdoc = docs.Product(doc) pname = pdoc.getName() + app_url = wsgiref.util.application_uri(self.request.environ) rlink = '/reviews?' + urllib.urlencode({'pid': pid, 'pname': pname}) template_values = { + 'app_url': app_url, 'pid': pid, 'pname': pname, 'review_link': rlink, @@ -99,7 +102,8 @@ class ShowProductHandler(BaseHandler): 'rating': params['rating'], 'category': pdoc.getCategory(), 'prod_doc': doc, - 'user_is_admin': users.is_current_user_admin()} + # for this demo, 'admin' status simply equates to being logged in + 'user_is_admin': users.get_current_user()} self.render_template('product.html', template_values) @@ -231,7 +235,8 @@ class ProductSearchHandler(BaseHandler): def post(self): params = self.parseParams() - self.redirect('/psearch?' + urllib.urlencode(dict([k, v.encode('utf-8')] for k, v in params.items()))) + self.redirect('/psearch?' + urllib.urlencode( + dict([k, v.encode('utf-8')] for k, v in params.items()))) def _getDocLimit(self): """if the doc limit is not set in the config file, use the default.""" @@ -264,7 +269,9 @@ class ProductSearchHandler(BaseHandler): categoryq = params.get('category') if categoryq: # add specification of the category to the query - query += ' %s:%s' % (docs.Product.CAT, long(categoryq)) + # Because the category field is atomic, put the category string + # in quotes for the search. + query += ' %s:"%s"' % (docs.Product.CATEGORY, categoryq) sortq = params.get('sort') try: @@ -298,31 +305,36 @@ class ProductSearchHandler(BaseHandler): 'goto_url': url, 'linktext': linktext}) return - cat_name = models.Category.getCategoryName(categoryq) + # cat_name = models.Category.getCategoryName(categoryq) psearch_response = [] # For each document returned from the search for doc in search_results: + # logging.info("doc: %s ", doc) pdoc = docs.Product(doc) # use the description field as the default description snippet, since # snippeting is not supported on the dev app server. - desc_snippet = pdoc.getDescription() - # now see if we can get the actual snippet + description_snippet = pdoc.getDescription() + price = pdoc.getPrice() + # on the dev app server, the doc.expressions property won't be populated. for expr in doc.expressions: if expr.name == docs.Product.DESCRIPTION: - desc_snippet = expr.value - break + description_snippet = expr.value + # uncomment to use 'adjusted price', which should be + # defined in returned_expressions in _buildQuery() below, as the + # displayed price. + # elif expr.name == 'adjusted_price': + # price = expr.value + # get field information from the returned doc pid = pdoc.getPID() - cat = pdoc.getCategory() - catname = pdoc.getCategoryName() - price = pdoc.getPrice() + cat = catname = pdoc.getCategory() pname = pdoc.getName() avg_rating = pdoc.getAvgRating() # for this result, generate a result array of selected doc fields, to # pass to the template renderer psearch_response.append( [doc, urllib.quote_plus(pid), cat, - desc_snippet, price, pname, catname, avg_rating]) + description_snippet, price, pname, catname, avg_rating]) if not query: print_query = 'All' else: @@ -339,7 +351,7 @@ class ProductSearchHandler(BaseHandler): 'base_pquery': user_query, 'next_link': next_link, 'prev_link': prev_link, 'qtype': 'product', 'query': query, 'print_query': print_query, - 'pcategory': categoryq, 'sort_order': sortq, 'category_name': cat_name, + 'pcategory': categoryq, 'sort_order': sortq, 'category_name': categoryq, 'first_res': offsetval + 1, 'last_res': offsetval + returned_count, 'returned_count': returned_count, 'number_found': search_results.number_found, @@ -351,6 +363,15 @@ class ProductSearchHandler(BaseHandler): def _buildQuery(self, query, sortq, sort_dict, doc_limit, offsetval): """Build and return a search query object.""" + + # computed and returned fields examples. Their use is not required + # for the application to function correctly. + computed_expr = search.FieldExpression(name='adjusted_price', + expression='price * 1.08') + returned_fields = [docs.Product.PID, docs.Product.DESCRIPTION, + docs.Product.CATEGORY, docs.Product.AVG_RATING, + docs.Product.PRICE, docs.Product.PRODUCT_NAME] + if sortq == 'relevance': # If sorting on 'relevance', use the Match scorer. sortopts = search.SortOptions(match_scorer=search.MatchScorer()) @@ -360,20 +381,32 @@ class ProductSearchHandler(BaseHandler): limit=doc_limit, offset=offsetval, sort_options=sortopts, - snippeted_fields=[docs.Product.DESCRIPTION] + snippeted_fields=[docs.Product.DESCRIPTION], + returned_expressions=[computed_expr], + returned_fields=returned_fields )) else: - # Otherwise, use the selected field as the sort expression, and get - # the sort direction and default from the 'sort_dict' var. - expr_list = [sort_dict.get(sortq)] + # Otherwise (not sorting on relevance), use the selected field as the + # first dimension of the sort expression, and the average rating as the + # second dimension, unless we're sorting on rating, in which case price + # is the second sort dimension. + # We get the sort direction and default from the 'sort_dict' var. + if sortq == docs.Product.AVG_RATING: + expr_list = [sort_dict.get(sortq), sort_dict.get(docs.Product.PRICE)] + else: + expr_list = [sort_dict.get(sortq), sort_dict.get( + docs.Product.AVG_RATING)] sortopts = search.SortOptions(expressions=expr_list) + # logging.info("sortopts: %s", sortopts) search_query = search.Query( query_string=query.strip(), options=search.QueryOptions( limit=doc_limit, offset=offsetval, sort_options=sortopts, - snippeted_fields=[docs.Product.DESCRIPTION] + snippeted_fields=[docs.Product.DESCRIPTION], + returned_expressions=[computed_expr], + returned_fields=returned_fields )) return search_query @@ -392,10 +425,10 @@ class ProductSearchHandler(BaseHandler): n = None if n: if n < config.RATING_MAX: - query += ' %s >= %s %s < %s' % (docs.Product.AR, n, - docs.Product.AR, n+1) + query += ' %s >= %s %s < %s' % (docs.Product.AVG_RATING, n, + docs.Product.AVG_RATING, n+1) else: # max rating - query += ' %s:%s' % (docs.Product.AR, n) + query += ' %s:%s' % (docs.Product.AVG_RATING, n) query_info = {'query': user_query.encode('utf-8'), 'sort': sort, 'category': category} rlinks = docs.Product.generateRatingsLinks(orig_query, query_info) @@ -459,4 +492,48 @@ class ShowReviewsHandler(BaseHandler): # render the template. self.render_template('reviews.html', template_values) +class StoreLocationHandler(BaseHandler): + """Show the reviews for a given product. This information is pulled from the + datastore Review entities.""" + + def get(self): + """Show a list of reviews for the product indicated by the 'pid' request + parameter.""" + query = self.request.get('location_query') + lat = self.request.get('latitude') + lon = self.request.get('longitude') + # the location query from the client will have this form: + # distance(store_location, geopoint(37.7899528, -122.3908226)) < 40000 + # logging.info('location query: %s, lat %s, lon %s', query, lat, lon) + try: + index = search.Index(config.STORE_INDEX_NAME) + # search using simply the query string: + # results = index.search(query) + # alternately: sort results by distance + loc_expr = 'distance(store_location, geopoint(%s, %s))' % (lat, lon) + sortexpr = search.SortExpression( + expression=loc_expr, + direction=search.SortExpression.ASCENDING, default_value=0) + sortopts = search.SortOptions(expressions=[sortexpr]) + search_query = search.Query( + query_string=query.strip(), + options=search.QueryOptions( + sort_options=sortopts, + )) + results = index.search(search_query) + except search.Error: + logging.exception("There was a search error:") + self.render_json([]) + return + # logging.info("geo search results: %s", results) + response_obj2 = [] + for res in results: + gdoc = docs.BaseDocumentManager(res) + geopoint = gdoc.getFirstFieldVal('store_location') + resp = {'addr': gdoc.getFirstFieldVal('address'), + 'storename': gdoc.getFirstFieldVal('storename'), + 'lat': geopoint.latitude, 'lon': geopoint.longitude} + response_obj2.append(resp) + logging.info("resp: %s", response_obj2) + self.render_json(response_obj2) diff --git a/search/product_search_python/main.py b/search/product_search_python/main.py index 8927c18..f475515 100644 --- a/search/product_search_python/main.py +++ b/search/product_search_python/main.py @@ -26,7 +26,8 @@ application = webapp2.WSGIApplication( ('/psearch', ProductSearchHandler), ('/product', ShowProductHandler), ('/reviews', ShowReviewsHandler), - ('/create_review', CreateReviewHandler) + ('/create_review', CreateReviewHandler), + ('/get_store_locations', StoreLocationHandler) ], debug=True) diff --git a/search/product_search_python/models.py b/search/product_search_python/models.py index 6e1a123..9287f2a 100644 --- a/search/product_search_python/models.py +++ b/search/product_search_python/models.py @@ -42,9 +42,12 @@ class Category(ndb.Model): _RCATEGORY_DICT = None _ROOT = 'root' # the 'root' category of the category tree - category_name = ndb.StringProperty() parent_category = ndb.KeyProperty() + @property + def category_name(self): + return self.key.id() + @classmethod def buildAllCategories(cls): """ build the category instances from the provided static data, if category @@ -67,9 +70,9 @@ class Category(ndb.Model): logging.warn('no category name for %s', category) return if parent_key: - cat = cls(category_name=cname, parent_category=parent_key) + cat = cls(id=cname, parent_category=parent_key) else: - cat = cls(category_name=cname) + cat = cls(id=cname) cat.put() children = category_data.get('children') @@ -84,16 +87,6 @@ class Category(ndb.Model): cls.buildCategory(cat, parent_key) @classmethod - def getCategoryName(cls, cid): - """get a category name given its id.""" - if cid: - try: - return cls.getRCategoryDict()[long(cid)] - except ValueError: - return None - return None - - @classmethod def getCategoryInfo(cls): """Build and cache a list of category id/name correspondences. This info is used to populate html select menus.""" @@ -101,39 +94,10 @@ class Category(ndb.Model): cls.buildAllCategories() #first build categories from data file # if required cats = cls.query().fetch() - cls._CATEGORY_INFO = [(str(c.key.id()), c.category_name) for c in cats - if c.category_name != cls._ROOT] + cls._CATEGORY_INFO = [(c.key.id(), c.key.id()) for c in cats + if c.key.id() != cls._ROOT] return cls._CATEGORY_INFO - @classmethod - def getCategoryDict(cls): - """Generate and cache a dict that maps category names to their ids.""" - - if not cls._CATEGORY_DICT: - cls.buildAllCategories() #first build categories from data file - # if required - cdict = {} - category_entities = cls.query().fetch() - for c in category_entities: - cdict[c.category_name] = c.key.id() - cls._CATEGORY_DICT = cdict - return cls._CATEGORY_DICT - - @classmethod - def getRCategoryDict(cls): - """Generate and cache a dict that maps category ids to names.""" - - if not cls._RCATEGORY_DICT: - cls.buildAllCategories() #first build categories from data file - # if required - cdict = {} - category_entities = cls.query().fetch() - for c in category_entities: - cdict[c.key.id()] = c.category_name - cls._RCATEGORY_DICT = cdict - return cls._RCATEGORY_DICT - - class Product(ndb.Model): """Model for Product data. A Product entity will be built for each product, and have an associated search.Document. The product entity does not include @@ -142,7 +106,7 @@ class Product(ndb.Model): doc_id = ndb.StringProperty() # the id of the associated document price = ndb.FloatProperty() - category = ndb.IntegerProperty() + category = ndb.StringProperty() # average rating of the product over all its reviews avg_rating = ndb.FloatProperty(default=0) # the number of reviews of that product diff --git a/search/product_search_python/static/instrs.html b/search/product_search_python/static/instrs.html index ddfcb15..a94a77f 100644 --- a/search/product_search_python/static/instrs.html +++ b/search/product_search_python/static/instrs.html @@ -1,10 +1,17 @@ - - - - - - - + + + + + + + + README + + + +
+

Full-Text Search Demo App: Product Search

Introduction

@@ -12,7 +19,7 @@

This Python App Engine application illustrates the use of the Full-Text Search API in a “Product Search” domain with two categories of sample products: books and -televisions. This README assumes that you are already familiar with how to +hd televisions. This README assumes that you are already familiar with how to configure and deploy an App Engine app. If not, first see the App Engine documentation and Getting Started guide.

@@ -28,7 +35,7 @@ reviews yet, its rating will be 0).

A user must be logged in as an admin of the app to add or modify product data. The sidebar admin links are not displayed for non-admin users.

-

Information About Running the App Locally

+

Information About Running the App Locally

Log in as an app admin to add and modify the app’s product data.

@@ -51,9 +58,9 @@ app data upon each restart, as described below.

When running the app locally, not all features of the search API are supported. So, not all search queries will give the same results during local testing as when run with the deployed app. As one example, numeric comparison queries are -not currently supported with the dev_appserver. -This means that filtering on product ratings is not supported -locally. Be sure to test on a deployed version of your app as well as locally.

+not currently supported with the dev_appserver. This means that filtering on +product ratings is not supported locally. +Be sure to test on a deployed version of your app as well as locally.

Administering the deployed app

@@ -79,19 +86,16 @@ restarts, for consistency the batch addition of sample data first remove existing index and datastore product data.

The second way to add sample data is via the admin’s “Create new product” link -in the sidebar, which lets an admin add products (either “books” or -“televisions”) one at a time.

+in the sidebar, which lets an admin add sample products (either “books” or +“hd televisions”) one at a time.

Updating product documents with a new average rating

-

When a user creates a new review, the average rating for that product is updated -in the datastore. The app may be configured to update the associated product -search.Document at the same time, or do this at a later time in batch (which -is more efficient). A cron job has been defined (in cron.yaml) to -periodically check for any products that need a ratings update. An app admin can -also run the batch job directly from the admin page. This behaviour is -configured via config.BATCH_RATINGS_UPDATE - if False, documents are updated -right away.

+

When a user creates a new review, the average rating for that product is +updated in the datastore. The app may be configured to update the associated +product search.Document at the same time (the default), or do this at a +later time in batch (which is more efficient). See cron.yaml for an example +of how to do this update periodically in batch.

Searches

@@ -112,18 +116,25 @@ search.

Some example searches

-

Below are some example queries, which assume the sample data has been loaded. -As discussed above, not all of these queries are supported by the dev app -server.

+

Below are some example product queries, which assume the sample data has been loaded. +As discussed above, not all of these queries are supported by the dev_appserver.

stories price < 10
price > 10 price < 15
publisher:Vintage
Mega TVs
name:tv1
-size > 30 -

+size > 30

- - +

Geosearch

+

This application includes an example of using the Search API to perform +location-based queries. Sample store location data is defined in stores.py, +and is loaded along with the product data. The product details page for a +product allows a search for stores within a given radius of the user’s current +location. The user’s location is obtained from the browser.

+ +
+
+ + diff --git a/search/product_search_python/static/js/StyledMarker.js b/search/product_search_python/static/js/StyledMarker.js new file mode 100644 index 0000000..1b86421 --- /dev/null +++ b/search/product_search_python/static/js/StyledMarker.js @@ -0,0 +1,264 @@ +/** + * @name StyledMarkerMaker + * @version 0.5 + * @author Gabriel Schneider + * @copyright (c) 2010 Gabriel Schneider + * @fileoverview This gives you static functions for creating dynamically + * styled markers using Charts API outputs as well as an ability to + * extend with custom types. + */ + +/** + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +var StyledIconTypes = {}; +var StyledMarker, StyledIcon; + +(function() { + var bu_ = 'http://chart.apis.google.com/chart?chst='; + var gm_ = google.maps; + var gp_ = gm_.Point; + var ge_ = gm_.event; + var gmi_ = gm_.MarkerImage; + + + /** + * This class is an extended version of google.maps.Marker. It allows + * styles to be applied that change it's appearance. + * @extends google.maps.Marker + * @param {StyledMarkerOptions} StyledMarkerOptions The options for the Marker + */ + StyledMarker = function(styledMarkerOptions) { + var me=this; + var ci = me.styleIcon = styledMarkerOptions.styleIcon; + me.bindTo('icon',ci); + me.bindTo('shadow',ci); + me.bindTo('shape',ci); + me.setOptions(styledMarkerOptions); + }; + StyledMarker.prototype = new gm_.Marker(); + + /** + * This class stores style information that can be applied to StyledMarkers. + * @extends google.maps.MVCObject + * @param {StyledIconType} styledIconType The type of style this icon is. + * @param {StyledIconOptions} styledIconOptions The options for this StyledIcon. + * @param {StyledIcon} styleClass A class to apply extended style information. + */ + StyledIcon = function(styledIconType,styledIconOptions,styleClass) { + var k; + var me=this; + var i_ = 'icon'; + var sw_ = 'shadow'; + var s_ = 'shape'; + var a_ = []; + + function gs_() { + var image_ = document.createElement('img'); + var simage_ = document.createElement('img'); + ge_.addDomListenerOnce(simage_, 'load', function() { + var w = simage_.width, h = simage_.height; + me.set(sw_,new gmi_(styledIconType.getShadowURL(me),null,null,styledIconType.getShadowAnchor(me,w,h))); + simage = null; + }); + ge_.addDomListenerOnce(image_, 'load', function() { + var w = image_.width, h = image_.height; + me.set(i_,new gmi_(styledIconType.getURL(me),null,null,styledIconType.getAnchor(me,w,h))); + me.set(s_,styledIconType.getShape(me,w,h)); + image_ = null; + }); + image_.src = styledIconType.getURL(me); + simage_.src = styledIconType.getShadowURL(me); + } + + /** + * set: + * This function sets a given style property to the given value. + * @param {String} name The name of the property to set. + * @param {Object} value The value to set the property to. + * get: + * This function gets a given style property. + * @param {String} name The name of the property to get. + * @return {Object} + */ + me.as_ = function(v) { + a_.push(v); + for(k in styledIconOptions) { + v.set(k, styledIconOptions[k]); + } + } + + if (styledIconType !== StyledIconTypes.CLASS) { + for (k in styledIconType.defaults) { + me.set(k, styledIconType.defaults[k]); + } + me.setValues(styledIconOptions); + me.set(i_,styledIconType.getURL(me)); + me.set(sw_,styledIconType.getShadowURL(me)); + if (styleClass) styleClass.as_(me); + gs_(); + me.changed = function(k) { + if (k!==i_&&k!==s_&&k!==sw_) { + gs_(); + } + }; + } else { + me.setValues(styledIconOptions); + me.changed = function(v) { + styledIconOptions[v] = me.get(v); + for (k = 0; k < a_.length; k++) { + a_[k].set(v,me.get(v)); + } + }; + if (styleClass) styleClass.as_(me); + } + }; + StyledIcon.prototype = new gm_.MVCObject(); + + /** + * StyledIconType + * This class holds functions for building the information needed to style markers. + * getURL: + * This function builds and returns a URL to use for the Marker icon property. + * @param {StyledIcon} icon The StyledIcon that holds style information + * @return {String} + * getShadowURL: + * This function builds and returns a URL to use for the Marker shadow property. + * @param {StyledIcon} icon The StyledIcon that holds style information + * @return {String{ + * getAnchor: + * This function builds and returns a Point to indicate where the marker is placed. + * @param {StyledIcon} icon The StyledIcon that holds style information + * @param {Number} width The width of the icon image. + * @param {Number} height The height of the icon image. + * @return {google.maps.Point} + * getShadowAnchor: + * This function builds and returns a Point to indicate where the shadow is placed. + * @param {StyledIcon} icon The StyledIcon that holds style information + * @param {Number} width The width of the shadow image. + * @param {Number} height The height of the shadow image. + * @return {google.maps.Point} + * getShape: + * This function builds and returns a MarkerShape to indicate where the Marker is clickable. + * @param {StyledIcon} icon The StyledIcon that holds style information + * @param {Number} width The width of the icon image. + * @param {Number} height The height of the icon image. + * @return {google.maps.MarkerShape} + */ + + StyledIconTypes.CLASS = {}; + + StyledIconTypes.MARKER = { + defaults: { + text:'', + color:'00ff00', + fore:'000000', + starcolor:null + }, + getURL: function(props){ + var _url; + var starcolor_=props.get('starcolor'); + var text_=props.get('text'); + var color_=props.get('color').replace(/#/,''); + var fore_=props.get('fore').replace(/#/,''); + if (starcolor_) { + _url = bu_ + 'd_map_xpin_letter&chld=pin_star|'; + } else { + _url = bu_ + 'd_map_pin_letter&chld='; + } + if (text_) { + text_ = text_.substr(0,2); + } + _url+=text_+'|'; + _url+=color_+'|'; + _url+=fore_; + if (starcolor_) { + _url+='|'+starcolor_.replace(/#/,''); + } + return _url; + }, + getShadowURL: function(props){ + if (props.get('starcolor')) { + return bu_ + 'd_map_xpin_shadow&chld=pin_star'; + } else { + return bu_ + 'd_map_pin_shadow'; + } + }, + getAnchor: function(props,width,height){ + return new gp_(width / 2,height); + }, + getShadowAnchor: function(props,width,height){ + return new gp_(width / 4,height); + }, + getShape: function(props,width,height){ + var _iconmap = {}; + _iconmap.coord = [ + width / 2, height, + (7 / 16) * width, (5 / 8) * height, + (5 / 16) * width, (7 / 16) * height, + (7 / 32) * width, (5 / 16) * height, + (5 / 16) * width, (1 / 8) * height, + (1 / 2) * width, 0, + (11 / 16) * width, (1 / 8) * height, + (25 / 32) * width, (5 / 16) * height, + (11 / 16) * width, (7 / 16) * height, + (9 / 16) * width, (5 / 8) * height + ]; + for (var i = 0; i < _iconmap.coord.length; i++) { + _iconmap.coord[i] = Math.round(_iconmap.coord[i]); + } + _iconmap.type = 'poly'; + return _iconmap; + } + }; + StyledIconTypes.BUBBLE = { + defaults: { + text:'', + color:'00ff00', + fore:'000000' + }, + getURL: function(props){ + var _url = bu_ + 'd_bubble_text_small&chld=bb|'; + _url+=props.get('text')+'|'; + _url+=props.get('color').replace(/#/,'')+'|'; + _url+=props.get('fore').replace(/#/,''); + return _url; + }, + getShadowURL: function(props){ + return bu_ + 'd_bubble_text_small_shadow&chld=bb|' + props.get('text'); + }, + getAnchor: function(props,width,height){ + return new google.maps.Point(0,42); + }, + getShadowAnchor: function(props,width,height){ + return new google.maps.Point(0,44); + }, + getShape: function(props,width,height){ + var _iconmap = {}; + _iconmap.coord = [ + 0,44, + 13,26, + 13,6, + 17,1, + width - 4,1, + width,6, + width,21, + width - 4,26, + 21,26 + ]; + _iconmap.type = 'poly'; + return _iconmap; + } + }; +})(); \ No newline at end of file diff --git a/search/product_search_python/stores.py b/search/product_search_python/stores.py new file mode 100644 index 0000000..69137fb --- /dev/null +++ b/search/product_search_python/stores.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python +# +# Copyright 2012 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# A set of example retail store locations, specified in terms +# of latitude and longitude. + +stores = [('gosford', 'Gosford', '123 Main St.', + [-33.4282627126087, 151.341658830643]), + ('sydney','Sydney', '123 Main St.', [-33.873038, 151.20563]), + ('marrickville', 'Marrickville', '123 Main St.', + [-33.8950341379958, 151.156479120255]), + ('armidale', 'Armidale', '123 Main St.', [-30.51683, 151.648041]), + ('ashfield', 'Ashfield', '123 Main St.', [-33.888424, 151.124329]), + ('bathurst', 'Bathurst', '123 Main St.', [-33.43528, 149.608887]), + ('blacktown', 'Blacktown', '123 Main St.', [-33.771873, 150.908234]), + ('botany', 'Botany Bay', '123 Main St.', [-33.925842, 151.196564]), + ('london', 'London', '123 Main St.', [51.5000,-0.1167]), + ('paris', 'Paris', '123 Main St.', [48.8667,2.3333]), + ('newyork', 'New York', '123 Main St.', [40.7619,-73.9763]), + ('sanfrancisco', 'San Francisco', '123 Main St.', [37.62, -122.38]), + ('tokyo', 'Tokyo', '123 Main St.', [35.6850, 139.7514]), + ('beijing', 'Beijing', '123 Main St.', [39.9289, 116.3883]), + ('newdelhi', 'New Delhi', '123 Main St.', [28.6000, 77.2000]), + ('lawrence', 'Lawrence', '123 Main St.', [39.0393, -95.2087]), + ('baghdad', 'Baghdad', '123 Main St.', [33.3386, 44.3939]), + ('oakland', 'Oakland', '123 Main St.', [37.73, -122.22]), + ('sancarlos', 'San Carlos', '123 Main St.', [37.52, -122.25]), + ('sanjose', 'San Jose', '123 Main St.', [37.37, -121.92]), + ('hayward', 'Hayward', '123 Main St.', [37.65, -122.12]), + ('monterey', 'Monterey', '123 Main St.', [36.58, -121.85]) + ] + diff --git a/search/product_search_python/templates/create_product.html b/search/product_search_python/templates/create_product.html index 966a536..14c6e7c 100644 --- a/search/product_search_python/templates/create_product.html +++ b/search/product_search_python/templates/create_product.html @@ -100,7 +100,7 @@

Televisions

- +
diff --git a/search/product_search_python/templates/index.html b/search/product_search_python/templates/index.html index 629d2a4..09da221 100644 --- a/search/product_search_python/templates/index.html +++ b/search/product_search_python/templates/index.html @@ -28,7 +28,7 @@

 

-

Click for information about the demo app. Log in as an app admin to load sample data.

+

Click for information about the demo app. Log in to add sample data.

 

@@ -115,11 +115,11 @@ {% for result in search_response %}

- Product Description: {{result.3|safe}}
- Product name: {{result.5}}
- Category: {{result.6}}
- Price: {{result.4}}
- Average Rating: + Product Description: {{result.3|safe}}
+ Product name: {{result.5}}
+ Category: {{result.6}}
+ Price: {{result.4}}
+ Average Rating: {% if result.7 < 1 %} None yet {% else %} diff --git a/search/product_search_python/templates/product.html b/search/product_search_python/templates/product.html index aee24e7..7e47ede 100644 --- a/search/product_search_python/templates/product.html +++ b/search/product_search_python/templates/product.html @@ -1,14 +1,181 @@ {% extends "base.html" %} {% block head %} Product Information for {{pname}} + + + + + + {% endblock %} {% block content %} + + +

Product Information for {{pname}}

-


(For expository purposes, all document fields are listed below with their actual names; - a real customer-facing app would expose this information differently.)

+ + + +
+ + + +
+
+ +
+   units: + +
+
+
+ +
+
+
+
+
+


{% for field in prod_doc.fields %} -- 2.11.4.GIT