DEV Community

Bruno Oliveira
Bruno Oliveira

Posted on • Edited on

Flask series part 12: indexing user entered recipes in the main search page

Introduction

In the previous post, we saw how to allow users to add their own recipes to our application.
The work we did was mostly database related: we created relationships between some of our database entities: User and UserCreatedRecipe and, by leveraging those relationships, we managed to allow a user to add a personal recipe to our application.
However, all this hard work would be lost, if we didn't find a way to show these recipes in the application's main search view.
The high level idea here will be to have a new template dedicated to show the details of a UserCreatedRecipe while indexing together the recipes from both the Spoonacular API, as well as the ones entered by the users.

How the indexing will work

In order to ensure compatibility with the API methods we are using, essentially, entering ingredients and getting back recipes that use these ingredients, we will do the same for the user entered recipes: if we search for ingredient X and both Spoonacular recipes and user entered recipes contain ingredient X, we will return both to our Jinja template, so both types of recipes follow the same indexing logic.

Obviously, this poses another problem: currently, our recipes list endpoint only returns recipes that receive ingredients when entered from a dropdown: both components are too tightly coupled.

When components are tightly coupled, an application becomes too rigid, and it becomes harder to add new functionality.

Whole functionality of our app, hinges currently on a very poor connection of front-end components and our backend endpoint, and it assumes that the user will be selecting recipes from the dropdown.
This is going to be a major drawback, that will be addressed right after we have setup the basic machinery for indexing recipes.
Let's see how to set this up.

Enriching the main Jinja template to display user recipes

Let's see how we can enrich the current Jinja template in order to show the user entered recipes there:

<table>
      <tr>
         {% for entry in ans %}
     <div class='col-md-3' id="recipeSelectionComponent">
                    <img id="recipeImage" src="{{ entry['image'] }}">
          <button id="{{ entry['id'] }}" type=button class="btn btn-link"
    onclick="getRecipeDetails({{ entry['id'] }})">{{ entry['title'] }}
          </button>
    </div>
         {% endfor %}
            </tr>
     <tr>
         {% for entry in userRecipes %}
     <div class='col-md-3' id="userRecipeSelectionComponent">
                    <img id="userRecipeImage" src="{{ entry['image'] }}">
        <button id="{{ entry['user_recipe_id'] }}" type=button class="btn btn-link" onclick="getUserRecipeDetails({{ entry['user_recipe_id'] }})">{{ entry['name'] }}
        </button>
      </div>
        {% endfor %}
          </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

So, we see that the idea of iterating over the response is basically the same as we did before, with the main difference being the usage of the user_recipe_id attribute in order to identify the user entered recipes. Apart from that, everything can remain unchanged in the front-end code. This is definitely not very elegant, but, it will do pretty well for our current purposes.

When searching for a recipe with chicken as an ingredient (and assuming we have at least one user recipe which contains chicken as an ingredient), this is what we will get in the UI:

userrecipes

As we can see, two of the recipes we entered in the database as a logged in user, contained chicken as one of the ingredients and, as a result, we can see them in the UI, together with the "main" recipes which contained chicken as well. This means we already have a very crude integration in place between external data and "internal" data. Here "internal" means that is managed by our endusers as well as by us, or in other words, it's data that always stays within the domain of our database and data which we can control and manage as we please, as opposed to data coming from external sources that we can't control.

Before we delve deeper into how to handle the details of a user entered recipe, let's first focus on the changes made on the flask side to generate these additional list.

How to adapt our endpoint to accommodate user-entered recipes

The changes will need to occur on the get_recipe() endpoint which is now like this:

@app.route('/', methods=['GET', 'POST'])
def get_recipe():
    ingredients_searched = []
    chip_ingredients = []
    user_recipes = []
    suggestions_as_json = {"searches": ingredients_searched}
    chip_ingredients_as_json = {"chipIngredients": chip_ingredients}
    user_recipes_as_json = {"userRecipes": user_recipes}
    if request.method == 'POST':
        search_suggestions = select(prod for prod in SearchSuggestion).order_by(lambda prod: desc(prod.id))[:5]
        for entry in search_suggestions:
            ingredients_searched.append(entry.ingredient)
        suggestions_as_json = {"searches": ingredients_searched}

        ing_suggestions = select(prod for prod in IngredientChip)
        for entry in ing_suggestions:
            chip_ingredients.append(entry)
        chip_ingredients_as_json = {"chipIngredients": chip_ingredients}
        ingredients = ",".join(map(lambda db_entry: db_entry.ingredient, chip_ingredients))
        print "Ingredients ",ingredients
        recipes = UserCreatedRecipe.select(
            lambda recipe1: ingredients in recipe1.ingredients) # if ingredients in userRecipe.ingredients
        print "Recipes",recipes
        for recipe_from_user in recipes:
            print recipe_from_user.name
            user_recipes.append({"name":recipe_from_user.name,"ingredients":recipe_from_user.ingredients,
                                 "instructions":recipe_from_user.instructions,
                                 "user_recipe_id": recipe_from_user.id})
        user_recipes_as_json = {"userRecipes": user_recipes}
        print user_recipes_as_json
        content = requests.get(
            "https://api.spoonacular.com/recipes/findByIngredients?ingredients=" +
            convert_input(ingredients) +
            "&apiKey=" + API_KEY)
        json_response = json.loads(content.text)
        return render_template("recipes_list.html", ans=json_response, searchHistory=suggestions_as_json,
                               chipIngredients=chip_ingredients_as_json, userRecipes=user_recipes_as_json['userRecipes'])
    else:
        delete(ingredient for ingredient in IngredientChip)
        return render_template("recipes_list.html", searchHistory=suggestions_as_json,
                               chipIngredients=chip_ingredients_as_json, userRecipes=user_recipes_as_json['userRecipes'])
Enter fullscreen mode Exit fullscreen mode

Before we even proceed with the analysis of the logic, something is already wrong here: this code is impossible to understand, it's effectively a 38-line beast!! No programmer can effectively keep 38-lines in their heads at all times while coding, so let's split this endpoint into more logic functions:

  • retrieve search suggestions: handle the search suggestions logic
  • retrieve ingredients: does the same for ingredients
  • handle user recipes: takes care of the logic to handle the user recipes

After doing this and identifying these building blocks, our endpoint looks now like this:

@app.route('/', methods=['GET', 'POST'])
def get_recipe():
    ingredients_searched = []
    chip_ingredients = []
    user_recipes = []
    suggestions_as_json = {"searches": ingredients_searched}
    chip_ingredients_as_json = {"chipIngredients": chip_ingredients}
    user_recipes_as_json = {"userRecipes": user_recipes}
    if request.method == 'POST':
        suggestions_as_json = handle_searches(ingredients_searched, suggestions_as_json)
        chip_ingredients_as_json, ingredients, recipes = retrieve_ingredients_from_api_and_users(chip_ingredients,                                                                                       chip_ingredients_as_json)
        json_response, user_recipes_as_json = build_user_plus_api_json_responses(ingredients, recipes, user_recipes, user_recipes_as_json)
        return render_template("recipes_list.html", ans=json_response, searchHistory=suggestions_as_json,
                               chipIngredients=chip_ingredients_as_json, userRecipes=user_recipes_as_json['userRecipes'])
    else:
        delete(ingredient for ingredient in IngredientChip)
        return render_template("recipes_list.html", searchHistory=suggestions_as_json,
                               chipIngredients=chip_ingredients_as_json, userRecipes=user_recipes_as_json['userRecipes'])
Enter fullscreen mode Exit fullscreen mode

From 38 lines, we cut down to 14, and more importantly, we gained a lot of readability: a quick glance over the small functions inside the endpoint, immediately provide more information regarding what is happening in each of them: this increases our codebase maintainability. A new developer who would first see this code, is now more in control of what he can learn from reading the code itself, and will be more confident when the time to make changes comes. It's not about cutting lines entirely, it's about keeping the code's internal structure under our control so it doesn't get out of hand: this process needs to be done constantly.

Now, looking inside the function responsible to handle the retrieval of ingredients from user entered recipes:

def retrieve_ingredients_from_api_and_users(chip_ingredients):
    ing_suggestions = select(prod for prod in IngredientChip)
    for entry in ing_suggestions:
        chip_ingredients.append(entry)
    chip_ingredients_as_json = {"chipIngredients": chip_ingredients}
    ingredients = ",".join(map(lambda db_entry: db_entry.ingredient, chip_ingredients))
    print "Ingredients ", ingredients
    recipes = UserCreatedRecipe.select(
        lambda recipe1: ingredients in recipe1.ingredients)  # if ingredients in userRecipe.ingredients
    print "Recipes", recipes
    return chip_ingredients_as_json, ingredients, recipes
Enter fullscreen mode Exit fullscreen mode

We see that, similarly to before, we do a select, via Pony, which in this case will filter the user entered recipes by those which contain the entered ingredient as input in their list of ingredients, and we just return it as a result of this function.

Once we have the list of recipes, we can delegate it to the function responsible for building the response:

def build_user_plus_api_json_responses(ingredients, recipes, user_recipes, user_recipes_as_json):
    for recipe_from_user in recipes:
        print recipe_from_user.name
        user_recipes.append({"name": recipe_from_user.name, "ingredients": recipe_from_user.ingredients,
                             "instructions": recipe_from_user.instructions,
                             "user_recipe_id": recipe_from_user.id})
    user_recipes_as_json = {"userRecipes": user_recipes}
    print user_recipes_as_json
    content = requests.get(
        "https://api.spoonacular.com/recipes/findByIngredients?ingredients=" +
        convert_input(ingredients) +
        "&apiKey=" + API_KEY)
    json_response = json.loads(content.text)
    return json_response, user_recipes_as_json
Enter fullscreen mode Exit fullscreen mode

This function iterates over the user entered recipes and retrieves recipes from the API to build JSON responses, and returns both of them as a tuple, and, once that is returned, we can simply use it to pass these results to our Jinja template which will give us exactly the results we first explored in the beginning of the post.
Like this, the idea of making the feature of a user being able to enter its own recipes into something more actionable is completed!

Conclusion

With this feature now turned into something more actionable, next, we will focus on re-working the search we currently have in-place via a static array on the JavaScript side, into something more dynamic, handled on the Flask side. That will enable us to search for recipes that contain specific ingredients that were only entered by users in order to make our typeahead more useful and well integrated with both external API data, and user-entered data.

Top comments (0)