How I automatically generate grocery shopping lists with Trello & AWS Lambda
Tags: aws, serverless, tutorial, python
Published: 2022-04-26 09:16:42

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](https://www.eliasbrange.dev/posts/automating-grocery-shopping-list-with-trello-and-aws-lambda/trello.png "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"](https://www.eliasbrange.dev/posts/automating-grocery-shopping-list-with-trello-and-aws-lambda/animation.gif "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](https://www.eliasbrange.dev/posts/automating-grocery-shopping-list-with-trello-and-aws-lambda/card.png '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](https://www.eliasbrange.dev/posts/automating-grocery-shopping-list-with-trello-and-aws-lambda/shopping-list-1.png '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](https://www.eliasbrange.dev/posts/automating-grocery-shopping-list-with-trello-and-aws-lambda/shopping-list-2.png '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](https://developer.atlassian.com/cloud/trello/guides/rest-api/api-introduction/).

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](https://fastapi.tiangolo.com/) 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
```

##### requirements.txt

The Lambda function requires the following libraries.

```
mangum
fastapi
pydantic
py-trello
aws-lambda-powertools

```

##### \_\_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.

```python
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)

```

##### utils.py

This is a simple file that sets up a logger and tracer using [AWS Lambda Power Tools](https://awslabs.github.io/aws-lambda-powertools-python/latest/).

```python
from aws_lambda_powertools import Logger, Tracer


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


```


##### 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.

```python
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,
    },
    ...
}
```

##### 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](https://pypi.org/project/py-trello/), as well as instantiate objects for the board and lists.

The `generate` function then:

1. Archives all existing cards in the grocery shopping list.
1. 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`.
1. On this card, it looks for the checklist named `Ingredienser`.
1. 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.
1. The `ingredients` dict is then sorted by category.
1. For each ingredient in the `ingredients` dict, it creates a new card in the grocery shopping list.

```python
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")

```

##### template.yml

I deployed the Lambda function with [AWS SAM](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html). The SAM template looks like the following:

```yml
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


```

### 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](https://www.eliasbrange.dev/posts/automating-grocery-shopping-list-with-trello-and-aws-lambda/board-button.png "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"](https://www.eliasbrange.dev/posts/automating-grocery-shopping-list-with-trello-and-aws-lambda/animation.gif "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!**