From 74a1933c60c3313afced47d4b9beddf65e935d05 Mon Sep 17 00:00:00 2001 From: Nima Date: Fri, 29 May 2020 22:54:05 +0430 Subject: [PATCH] elasticsearch enabled. - elastic search configured -search function added - doc written for search --- api-documentation.md | 129 +++++++++++++++++++++++++++++++++++++++++++ foods/models.py | 108 ++++++++++++++++++++++++++++++++++-- foods/views.py | 35 +++++++++++- search.py | 3 + 4 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 search.py diff --git a/api-documentation.md b/api-documentation.md index 9cd6797..2c60f61 100644 --- a/api-documentation.md +++ b/api-documentation.md @@ -757,3 +757,132 @@ response code will be **200** ``` ---------- +---------- + +### `/foods/search` + +method: `GET` + +recipe and foods detailed information + +*input*: +GET method parameters: + +- query: text to search +- page:pagination page, default value is 1 +- page:items per page, default value is 10 + +*sample input*: + +``` +/foods/search?query=pasta&page=1&per_page=5 +``` + +*output*: + +response code will be **200** + +- total results count in the elasticsearch +- food sample view of foods found int the search ordered by relevance + +```json +{ + "results": [ "list views.."], + "total_results_count": "count..." +} +``` + +*sample input* +```json +{ + "results": [ + { + "category": "pasta", + "id": 384279, + "image": "https://images.eatthismuch.com/site_media/img/384279_erin_m_77a48297-f148-454d-aa02-fdd277e70edf.png", + "nutrition": { + "calories": 476, + "fat": 8.6, + "fiber": 1.6, + "protein": 17.7 + }, + "thumbnail": "https://images.eatthismuch.com/site_media/thmb/384279_erin_m_77a48297-f148-454d-aa02-fdd277e70edf.png", + "title": "Pasta, Corn & Artichoke Bowl" + }, + { + "category": "pasta", + "id": 1493432, + "image": "https://images.eatthismuch.com/site_media/img/1093241_Billie7_1975_f6db1d3f-2bed-4c82-bf10-e343b9dc8314.jpeg", + "nutrition": { + "calories": 591, + "fat": 15.8, + "fiber": 4.7, + "protein": 16.7 + }, + "thumbnail": "https://images.eatthismuch.com/site_media/thmb/1093241_Billie7_1975_f6db1d3f-2bed-4c82-bf10-e343b9dc8314.jpeg", + "title": "Spaghetti with Mushrooms, Garlic and Oil" + }, + { + "category": "other", + "id": 907167, + "image": "https://images.eatthismuch.com/site_media/img/907167_tabitharwheeler_915ad93b-213d-4b3d-bcc2-e0570b833af3.jpg", + "nutrition": { + "calories": 309, + "fat": 7.2, + "fiber": 8.6, + "protein": 16.1 + }, + "thumbnail": "https://images.eatthismuch.com/site_media/thmb/907167_tabitharwheeler_915ad93b-213d-4b3d-bcc2-e0570b833af3.jpg", + "title": "Pasta with Red Sauce and Mozzarella" + }, + { + "category": "pasta", + "id": 905979, + "image": "https://images.eatthismuch.com/site_media/img/905979_tabitharwheeler_82334d46-99b8-428d-aa16-4bdd9c3008cd.jpg", + "nutrition": { + "calories": 423, + "fat": 12.3, + "fiber": 4.0, + "protein": 24.2 + }, + "thumbnail": "https://images.eatthismuch.com/site_media/thmb/905979_tabitharwheeler_82334d46-99b8-428d-aa16-4bdd9c3008cd.jpg", + "title": "Spaghetti with Meat Sauce" + }, + { + "category": "pasta", + "id": 45500, + "image": "https://images.eatthismuch.com/site_media/img/45500_simmyras_43adc56f-d597-4778-a682-4ddfa9f394a3.png", + "nutrition": { + "calories": 285, + "fat": 18.0, + "fiber": 0.9, + "protein": 15.4 + }, + "thumbnail": "https://images.eatthismuch.com/site_media/thmb/45500_simmyras_43adc56f-d597-4778-a682-4ddfa9f394a3.png", + "title": "Rigatoni with Brie, Grape Tomatoes, Olives, and Basil" + } + ], + "total_results_count": 1211 +} +``` + +*in case of errors*: + +1- if you don't pass query parameter in the url **422** + +```json +{ + "error": "query should exist in the request" +} +``` + +2- if per_page value is more than 50 **404** + +```json +{ + "error": "per_page should not be more than 50" +} +``` + + + diff --git a/foods/models.py b/foods/models.py index c1b06a7..ecfb170 100644 --- a/foods/models.py +++ b/foods/models.py @@ -4,12 +4,86 @@ import json from flask_admin.contrib.sqla import ModelView from flask import jsonify -from extentions import db +from extentions import db, elastic from wtforms import SelectField -class Food(db.Model): +class SearchableMixin(object): + + @classmethod + def add_to_index(cls, instance): + if elastic is None: + return + if not hasattr(instance, 'elastic_document'): + raise Exception("model doesn't have 'elastic_document' attribute") + + payload = instance.elastic_document + if not hasattr(cls, '__indexname__'): + raise Exception("class doesn't have '__indexname__' attribute") + + elastic.index(index=cls.__indexname__, body=payload, id=instance.id) + + @classmethod + def remove_from_index(cls, instance): + if elastic is None: + return + if not hasattr(cls, '__indexname__'): + raise Exception("class doesn't have '__indexname__' attribute") + + elastic.delete(index=cls.__indexname__, id=instance.id) + + @classmethod + def query_index(cls, query, page=1, per_page=10): + if elastic is None: + return [], 0 + search = elastic.search( + index=cls.__indexname__, + body={'query': {'multi_match': {'query': query, 'fields': ['*']}}, + 'from': (page - 1) * per_page, 'size': per_page}) + ids = [int(hit['_id']) for hit in search['hits']['hits']] + return ids, search['hits']['total']['value'] + + @classmethod + def search(cls, expression, page=1, per_page=10): + ids, total = cls.query_index(expression, page, per_page) + if total == 0: + return cls.query.filter_by(id=0), 0 # just returning nothing + when = [] + for i in range(len(ids)): + when.append((ids[i], i)) + return cls.query.filter(cls.id.in_(ids)).order_by( + db.case(when, value=cls.id)), total + + @classmethod + def before_commit(cls, session): + session._changes = { + 'add': list(session.new), + 'update': list(session.dirty), + 'delete': list(session.deleted) + } + + @classmethod + def after_commit(cls, session): + for obj in session._changes['add']: + if isinstance(obj, SearchableMixin): + cls.add_to_index(obj) + for obj in session._changes['update']: + if isinstance(obj, SearchableMixin): + cls.add_to_index(obj) + for obj in session._changes['delete']: + if isinstance(obj, SearchableMixin): + cls.remove_from_index(obj) + session._changes = None + + @classmethod + def reindex(cls): + for obj in cls.query: + cls.add_to_index(obj) + + +class Food(db.Model,SearchableMixin): __tablename__ = 'foods' + __indexname__ = 'foods' id = Column('id', Integer(), primary_key=True) Calories = Column('calories', Integer()) @@ -36,7 +110,7 @@ class Food(db.Model): # self._Category = category @property - def recipe(self): + def recipe(self) -> dict: if self.Recipe is None or self.Recipe == '': return None else: @@ -49,7 +123,7 @@ def __repr__(self): return f"" @property - def simple_view(self): + def simple_view(self) -> dict: """ a simple view of food model """ @@ -68,12 +142,30 @@ def simple_view(self): def __str__(self): return json.dumps(self.simple_view) - def get_calorie(self): + def get_calorie(self) -> int: return self.Calories - def get_category(self): + def get_category(self) -> str: return self.Category.strip().lower() + @property + def elastic_document(self): + """ + :return: elastic search index document + """ + recipe = self.recipe + payload = { + 'author': self.author.FullName, + 'name': recipe['food_name'], + 'description': recipe['description'], + 'category': recipe['category'], + 'tag_cloud': recipe['tag_cloud'], + 'ingredients': [ingredient['food']['food_name'] for ingredient in recipe['ingredients']], + 'directions': [direction['text'] for direction in recipe['directions']] + } + + return payload + # for admin integration class FoodModelView(ModelView): @@ -99,3 +191,7 @@ class FoodModelView(ModelView): 'pasta' ]] } + + +db.event.listen(db.session, 'before_commit', SearchableMixin.before_commit) +db.event.listen(db.session, 'after_commit', SearchableMixin.after_commit) \ No newline at end of file diff --git a/foods/views.py b/foods/views.py index f4f262d..8a62e16 100644 --- a/foods/views.py +++ b/foods/views.py @@ -1,11 +1,12 @@ from foods import foods -from flask import jsonify +from flask import jsonify, request from foods.utils import get_foods_with_categories from foods.diet import sevade, yevade, dovade from .models import Food from flask_jwt_extended import (jwt_required) from utils.decorators import confirmed_only + @foods.route('/yevade/', methods=['GET']) def get_yevade(calorie): cat = ['breakfast, ''mostly_meat', 'pasta', 'main_dish', 'sandwich', 'appetizers', 'drink'] @@ -73,3 +74,35 @@ def get_food(id): return jsonify({"error": "food not found."}), 404 return jsonify(food.simple_view) + + +@foods.route('/search', methods=['GET']) +def food_search(): + """ + food full text search using elasticsearch + http parameters: + query: text to search + page: pagination page number + per_page: pagination per_page count + :return: + """ + query = request.args.get('query') + page = int(request.args.get('page', 1)) + per_page = int(request.args.get('per_page', 10)) + + if query == "" or query is None: + return jsonify({ + 'error': "query should exist in the request" + }), 422 # invalid input error + + if per_page > 50: + return jsonify({ + 'error': 'per_page should not be more than 50' + }), 422 + + results, count = Food.search(query, page, per_page) + + return jsonify({ + 'results': [result.simple_view for result in results.all()], + 'total_results_count': count + }) diff --git a/search.py b/search.py new file mode 100644 index 0000000..5a7ec41 --- /dev/null +++ b/search.py @@ -0,0 +1,3 @@ +from extentions import elastic + +