DEV Community

Cover image for How I automatically generate grocery shopping lists with Trello & AWS Lambda
Elias Brange for AWS Community Builders

Posted on • Updated on • Originally published at eliasbrange.dev

How I automatically generate grocery shopping lists with Trello & AWS Lambda

I use Trello for all kinds of personal stuff, one of which is keeping track of recipes and grocery lists. After planning which meals to cook and buy groceries for, a lot of manual work goes into checking every recipe and adding the ingredients to the grocery shopping list. Manual stuff is tedious, so I automated it with AWS Lambda. Read on to find out how!

The Trello board

NOTE: Our Trello board is in Swedish, so the screenshots will be in Swedish as well.

We have a Trello board that contains a lot of recipes grouped into lists, such as Pasta Dishes, Soups & Stews, and so on. We also have a list of groceries that needs to be bought, so that we always have the shopping list available when shopping.

Trello board with recipe lists for different types of dishes

In the above picture, you can get a glimpse of the Trello board, with each card containing a picture of the dish as well as a link to the external site where the recipe can be found.

What I wanted to automate was the following:

  • Add a button to each card, that when clicked, adds the recipe to the Recipes to buy list (named Recept att handla in my board).
  • Add a button to the board, that when clicked, reads through the Recipes to buy list and adds the ingredients (and amount of each) to the Grocery Shopping List (named AUTO Handlingslista in my board).

Below you can see the completed automation in action:

"Automatically generated grocery list"

Automation setup

Trello part 1

I started by adding two new lists to my board, one to hold recipes that should be bought, and one for the generated grocery shopping list.

I then proceeded to add a checklist to recipe cards. This checklist contains the ingredients required for a single serving of the recipe. For example, if a recipe requires one onion for four servings, I would add onion:0.25. Here I had to be consistent between recipes so that the units used for ingredients would be the same across all recipes.

The unit for onion here is number of onions, while for pasta I use gram and for cooking cream I use deciliter.

For ingredients where I do not care about the unit and just need to make sure that I buy it if needed, such as cooking oil and certain spices, I set the amount needed to 0.

With the checklist(s) completed, I went on and created a Card button in the Automation menu. When clicked, this button triggers an action that adds a new card to the recipes to buy list. The created card gets the following title ANTAL_PORTIONER:{cardidlong}:{cardname}. The ANTAL_PORTIONER (number of servings in Swedish) is a placeholder that needs to be changed to an actual number before generating the shopping list.

The cards now look like this:

A recipe card with an ingredient list and custom automation button

Clicking the button creates a new card in the recipes to buy list, and I then edit the text ANTAL_PORTIONER in the title to the number of servings I need to purchase.

Recipes added to the "Recipes to buy" list with a placeholder number of servings

Recipes added to the "Recipes to buy" list with a specified number of servings

I also needed to get an API key and token for the Trello API, as described here.

I also needed to fetch the IDs of both the recipes to buy list, the grocery shopping list, and the board itself. This was easiest done by creating a new card in each of the lists, clicking on them and then adding .json to the end of the URL in the browser. From there I could then extract the idList and idBoard variables.

AWS part

This whole thing really started with me wanting to find a use case for the newly released function URLs of AWS Lambda. So I decided to go with FastAPI in a Lambda function (as I usually do).

Since the API should be reachable from Trello I decided to keep the function URL open to the public. To secure it, I added code to the Lambda function that validates the X-Api-Key header in incoming requests.

Secrets

To not require hard-coded secrets in the Lambda function, I added the following parameters to the AWS Systems Manager Parameter Store:

  • /trellomat/api_key: Trello API Key
  • /trellomat/token: Trello token
  • /trellomat/board_id: ID of the Trello board
  • /trellomat/shopping_list_id: ID of the grocery shopping list
  • /trellomat/recipe_list_id: ID of the recipes to buy list
  • /trellomat/aws_api_key: The API key that is required in the X-Api-Key header

Lambda code

Folder structure

The code examples below are structured as follows:

TrelloMatApi/
├── template.yml
├── api-function
│   ├── requirements.txt
│   ├── src
│   │   ├── __init__.py
│   │   ├── models.py
│   │   ├── trello.py
│   │   ├── utils.py
Enter fullscreen mode Exit fullscreen mode
requirements.txt

The Lambda function requires the following libraries.

mangum
fastapi
pydantic
py-trello
aws-lambda-powertools

Enter fullscreen mode Exit fullscreen mode
__init__.py

The handler function is a FastAPI application wrapped by a Mangum adapter. The API includes a single route POST /generate, that neither requires nor returns any payload.

Authorization is done with the X-Api-Key header, which is validated by the auth method. The valid key is fetched from AWS Systems Manager Parameter Store and cached between invocations.

from fastapi import FastAPI, HTTPException, Header, Depends
from mangum import Mangum
from aws_lambda_powertools.utilities import parameters
from src.utils import logger, tracer
from src import trello

app = FastAPI()
API_KEY = parameters.get_parameter("/trellomat/aws_api_key")


async def auth(x_api_key: str = Header(...)):
    if x_api_key != API_KEY:
        raise HTTPException(status_code=401, detail="Unauthorized")


@app.post("/generate", status_code=204, dependencies=[Depends(auth)])
def generate():
    trello.generate()


handler = Mangum(app)
handler.__name__ = "handler"
handler = tracer.capture_lambda_handler(handler)
handler = logger.inject_lambda_context(handler, clear_state=True)

Enter fullscreen mode Exit fullscreen mode
utils.py

This is a simple file that sets up a logger and tracer using AWS Lambda Power Tools.

from aws_lambda_powertools import Logger, Tracer


logger: Logger = Logger()
tracer: Tracer = Tracer()


Enter fullscreen mode Exit fullscreen mode
models.py

This is where I define my ingredients.

In the INGREDIENTS dict, I specify ingredients their units as well as which category the ingredient is part of.

The Category decides which label an ingredient gets, and it is also used for sorting the generated shopping list. The default values in the Ingredient class are there to not break the API whenever an ingredient is added on the Trello side before adding it to the Lambda function itself.

from enum import Enum
from pydantic import BaseModel


class Category(Enum):
    GREEN = 0
    FRIDGE = 1
    BREAD = 2
    FROZEN = 3
    DRY = 4
    OTHER = 5
    NONE = 6


class Ingredient(BaseModel):
    name: str
    unit: str = "NOUNIT"
    cat: Category = Category.NONE
    amount: int = 0


INGREDIENTS = {
    "vitlök": {  # garlic
        "unit": "klyftor",  # cloves 
        "cat": Category.GREEN,
    },
    "gul lök": {  # onion
        "unit": "st",  #  pcs
        "cat": Category.GREEN,
    },
    "matlagningsgrädde": {  # cooking cream
        "unit": "dl",
        "cat": Category.FRIDGE,
    },
    ...
}
Enter fullscreen mode Exit fullscreen mode
trello.py

This is where the magic happens. First, all required information is fetched from AWS Systems Manager Parameter Store.

We then initialize a TrelloClient from the py-trello library, as well as instantiate objects for the board and lists.

The generate function then:

  1. Archives all existing cards in the grocery shopping list.
  2. For each card in the recipes to buy list (which has the format {servings}:{recipe_card_id}:{recipe_name}), it fetches the card with the recipe_card_id.
  3. On this card, it looks for the checklist named Ingredienser.
  4. For each checklist item, it adds the required ingredient amount to the ingredients dict. It also adds the ingredient unit and category from the model defined earlier.
  5. The ingredients dict is then sorted by category.
  6. For each ingredient in the ingredients dict, it creates a new card in the grocery shopping list.
from aws_lambda_powertools.utilities import parameters
from trello import TrelloClient
from src.utils import logger
from src.models import INGREDIENTS, Ingredient, Category


TOKEN = parameters.get_parameter("/trellomat/token")
API_KEY = parameters.get_parameter("/trellomat/api_key")
BOARD_ID = parameters.get_parameter("/trellomat/board_id")
RECIPE_LIST_ID = parameters.get_parameter("/trellomat/recipe_list_id")
SHOPPING_LIST_ID = parameters.get_parameter("/trellomat/shopping_list_id")
CHECKLIST_KEY = "Ingredienser"

client = TrelloClient(api_key=API_KEY, token=TOKEN)
board = client.get_board(board_id=BOARD_ID)
recipe_list = client.get_list(list_id=RECIPE_LIST_ID)
shopping_list = client.get_list(list_id=SHOPPING_LIST_ID)
labels = board.get_labels()


def _get_label(cat: Category) -> list:
    try:
        return [labels[cat.value]]
    except IndexError:
        return []


def _archive_old_cards():
    logger.info("Clearing AUTO shopping list")
    shopping_list.archive_all_cards()


def _get_ingredient_list() -> list[Ingredient]:
    logger.info("Getting ingredients")

    cards = {card.id: card for card in board.get_cards()}
    ingredients = {}

    for card in recipe_list.list_cards_iter():
        servings, ref_card_id, name = card.name.split(":")
        ref_card = cards[ref_card_id]

        logger.info(f"Reading recipe for {name}")

        card_ingredients = next(
            (cl for cl in ref_card.checklists if cl.name == CHECKLIST_KEY), None
        )

        if not card_ingredients:
            logger.warning(f"No checklist named {CHECKLIST_KEY} found on card {name}")
            continue

        for item in card_ingredients.items:
            name, amount = item["name"].split(":")

            if name not in ingredients:
                ingredients[name] = Ingredient(name=name, **INGREDIENTS.get(name, {}))

            ingredients[name].amount += int(servings) * float(amount)

    # Return list of Ingredients sorted by category
    return sorted(ingredients.values(), key=lambda item: item.cat.value)


def _create_cards(ingredients: list[Ingredient]):
    for ingredient in ingredients:
        if ingredient.amount == 0:
            title = f"{ingredient.name}"
        elif ingredient.amount == int(ingredient.amount):
            title = f"{int(ingredient.amount)} {ingredient.unit} {ingredient.name}"
        else:
            title = f"{ingredient.amount:.2f} {ingredient.unit} {ingredient.name}"

        logger.info("Adding card: %s", title)
        shopping_list.add_card(title, labels=_get_label(ingredient.cat))


def generate():
    _archive_old_cards()
    ingredients = _get_ingredient_list()
    _create_cards(ingredients)
    logger.info("Grocery list generated successfully")

Enter fullscreen mode Exit fullscreen mode
template.yml

I deployed the Lambda function with AWS SAM. The SAM template looks like the following:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Trello Mat API

Resources:
  ApiFunction:
    Type: AWS::Serverless::Function
    Properties:
      MemorySize: 256
      Timeout: 180
      Tracing: Active
      FunctionUrlConfig:
        AuthType: NONE
      CodeUri: api-function
      Handler: src.handler
      Runtime: python3.9
      Policies:
        - SSMParameterReadPolicy:
            ParameterName: "trellomat/aws_api_key"
        - SSMParameterReadPolicy:
            ParameterName: "trellomat/token"
        - SSMParameterReadPolicy:
            ParameterName: "trellomat/api_key"
        - SSMParameterReadPolicy:
            ParameterName: "trellomat/board_id"
        - SSMParameterReadPolicy:
            ParameterName: "trellomat/recipe_list_id"
        - SSMParameterReadPolicy:
            ParameterName: "trellomat/shopping_list_id"
      Environment:
        Variables:
          POWERTOOLS_SERVICE_NAME: TrelloMatAPI

Outputs:
  FunctionUrl:
    Description: URL of the Function
    Value: !GetAtt ApiFunctionUrl.FunctionUrl


Enter fullscreen mode Exit fullscreen mode

Trello part 2

With the API complete, I created a new Board button in the Automation menu. When clicked, a post request is sent to the Lambda function URL, with the API key specified in the X-Api-Key header. The Lambda function then reads the recipes to buy list and generates a grocery shopping list.

Board button to call AWS Lambda

Final result

Lo and behold, the final result! After adding a few recipes to the recipes to buy list, I can now automatically generate a grocery shopping list.

"Automatically generated grocery list"

Have you built anything similar to aid you in your regular day-to-day tasks? Let me know in the comments!

Discussion (1)

Collapse
aihaddad profile image
Ahmed Elhaddad

Great idea and execution.