diff --git a/docker/Dockerfile b/docker/Dockerfile index cf761d6..ca44632 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -18,10 +18,11 @@ COPY ../database/ /app/database # https://setuptools-scm.readthedocs.io/en/latest/usage/ # https://stackoverflow.com/questions/77572077/using-setuptools-scm-pretend-version-for-package-version-inside-docker-with-git ENV SETUPTOOLS_SCM_PRETEND_VERSION_FOR_MY_PACKAGE=0.0 -RUN pip install --root-user-action=ignore --no-cache-dir . +RUN pip install --root-user-action=ignore . -ARG LOCATION_ID +ARG SEED_LOCATION_ID +ARG SEED_QUANTITY # Command to run FastAPI using Uvicorn # CMD ["uvicorn", "weird_salads.api.app:app", "--host", "0.0.0.0", "--port", "8000"] -CMD ["sh", "-c", "alembic upgrade head && python weird_salads/utils/database/seed_db.py --location_id ${LOCATION_ID} --base_path data/ && uvicorn weird_salads.api.app:app --host 0.0.0.0 --port 8000"] +CMD ["sh", "-c", "alembic upgrade head && python weird_salads/utils/database/seed_db.py --location_id ${SEED_LOCATION_ID} --quantity ${SEED_QUANTITY} --base_path data/ && uvicorn weird_salads.api.app:app --host 0.0.0.0 --port 8000"] # CMD ["sleep", "365d"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index b6b28c5..0f8ec18 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -9,7 +9,8 @@ services: - ../data/:/app/data/ # Mount the data environment: - DATABASE_URL=sqlite:///data/orders.db - - LOCATION_ID=1 # put as an environment variable + - SEED_LOCATION_ID=1 # location id for DB seeding + - SEED_QUANTITY=7.4 # quantity of ingredients for DB seeding streamlit: build: diff --git a/streamlit_app/pages/menu.py b/streamlit_app/pages/menu.py new file mode 100644 index 0000000..cd47b98 --- /dev/null +++ b/streamlit_app/pages/menu.py @@ -0,0 +1,85 @@ +import pandas as pd +import requests +import streamlit as st + + +# Function to fetch menu items +def fetch_menu_items(): + try: + response = requests.get("http://fastapi:8000/menu/") + response.raise_for_status() + data = response.json() + return data.get("items", []) + except requests.exceptions.RequestException as e: + st.write("Failed to connect to FastAPI:", e) + return [] + except Exception as e: + st.write("An error occurred:", e) + return [] + + +# Function to fetch availability of a specific menu item +def fetch_item_availability(item_id): + try: + response = requests.get( + f"http://fastapi:8000/menu/{item_id}/availability" + ) # Update endpoint if necessary + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + st.write("Failed to connect to FastAPI:", e) + return None + except Exception as e: + st.write("An error occurred:", e) + return None + + +# Function to display menu items +def display_menu(): + st.header("Menu") + + menu_items = fetch_menu_items() + + # Initialize session state for tracking the current order and status + if "current_order" not in st.session_state: + st.session_state.current_order = None + st.session_state.order_status = "" + + if menu_items: + # Create a DataFrame to display menu items + df = pd.DataFrame(menu_items) + + # Filter out items that are not on the menu + df = df[df["on_menu"]] + df["price"] = df["price"].apply(lambda x: f"${x:.2f}") + + st.write("### Menu Items") + + for idx, row in df.iterrows(): + cols = st.columns([3, 1, 2]) + + with cols[0]: + st.write(f"{row['name']} ({row['price']})") + + with cols[1]: + button_key = f"order_{row['id']}" + if st.button("Order", key=button_key): + # Fetch availability when button is clicked + availability = fetch_item_availability(row["id"]) + if availability and availability.get("available_portions", 0) >= 1: + st.session_state.current_order = row["name"] + st.session_state.order_status = "Order success!" + else: + st.session_state.order_status = "Sorry, that's out of stock" + + with cols[2]: + if st.session_state.current_order == row["name"]: + st.write(f"Ordered: {row['name']}") + st.write(st.session_state.order_status) + + else: + st.write("No menu items found.") + + +if __name__ == "__main__": + display_menu() diff --git a/weird_salads/api/app.py b/weird_salads/api/app.py index d0c5a70..a4313b3 100644 --- a/weird_salads/api/app.py +++ b/weird_salads/api/app.py @@ -1,8 +1,17 @@ -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from starlette import status -from weird_salads.api.schemas import CreateOrderSchema # noqa -from weird_salads.api.schemas import GetOrderSchema, GetOrdersSchema +from weird_salads.api.schemas import ( + CreateOrderSchema, + GetMenuItemAvailabilitySchema, + GetMenuItemSchema, + GetOrderSchema, + GetOrdersSchema, + GetSimpleMenuSchema, +) +from weird_salads.inventory.inventory_service.exceptions import MenuItemNotFoundError +from weird_salads.inventory.inventory_service.inventory_service import MenuService +from weird_salads.inventory.repository.inventory_repository import MenuRepository from weird_salads.orders.orders_service.orders_service import OrdersService from weird_salads.orders.repository.orders_repository import OrdersRepository from weird_salads.utils.unit_of_work import UnitOfWork @@ -10,6 +19,49 @@ app = FastAPI() +# Menu +@app.get("/menu", response_model=GetSimpleMenuSchema, tags=["Menu"]) +def get_menu(): + with UnitOfWork() as unit_of_work: + repo = MenuRepository(unit_of_work.session) + inventory_service = MenuService(repo) + results = inventory_service.list_menu() + return {"items": [result.dict() for result in results]} + + +@app.get("/menu/{item_id}", response_model=GetMenuItemSchema, tags=["Menu"]) +def get_order(item_id: int): + try: + with UnitOfWork() as unit_of_work: + repo = MenuRepository(unit_of_work.session) + inventory_service = MenuService(repo) + order = inventory_service.get_item(item_id=item_id) + return order + except MenuItemNotFoundError: + raise HTTPException( + status_code=404, detail=f"Menu Item with ID {item_id} not found" + ) + + +@app.get( + "/menu/{item_id}/availability", + response_model=GetMenuItemAvailabilitySchema, + tags=["Menu"], +) +def get_availability(item_id: int): + try: + with UnitOfWork() as unit_of_work: + repo = MenuRepository(unit_of_work.session) + inventory_service = MenuService(repo) + order = inventory_service.get_recipe_item_availability(item_id=item_id) + return order # Ensure `order` is an instance of `GetRecipeItemSchema` + except MenuItemNotFoundError: + raise HTTPException( + status_code=404, detail=f"Menu Item with ID {item_id} not found" + ) + + +# Orders @app.get( "/order", response_model=GetOrdersSchema, diff --git a/weird_salads/api/schemas.py b/weird_salads/api/schemas.py index d159885..821529e 100644 --- a/weird_salads/api/schemas.py +++ b/weird_salads/api/schemas.py @@ -2,15 +2,134 @@ Pydantic Schemas for API """ -from datetime import datetime -from typing import List +from datetime import datetime, timezone +from enum import Enum +from typing import List, Optional -from pydantic import BaseModel +from pydantic import BaseModel, Field, field_validator +from typing_extensions import Annotated # from typing_extensions import Annotated +# ================================= +# Menu-related Schema for the API +# ================================= +class UnitOfMeasure(str, Enum): + """ + Enum for units of measure + """ + + liter = "liter" + deciliter = "deciliter" + centiliter = "centiliter" + milliliter = "milliliter" + + +class SimpleMenuItemSchema(BaseModel): + """ + Simple Menu Item (an overview). + """ + + name: str + description: Optional[str] = None + price: Annotated[float, Field(ge=0.0, strict=True)] + created_on: datetime = datetime.now(timezone.utc) + on_menu: bool = True + + class Config: + extra = "forbid" + + @field_validator("price") + def quantity_non_nullable(cls, value): + assert value is not None, "price may not be None" + return value + + +# GET, now includes id... +class GetSimpleMenuItemSchema(SimpleMenuItemSchema): + id: int + + +# GET Menu (list of Menu items) +class GetSimpleMenuSchema(BaseModel): + """ + Menu (GET) + """ + + items: List[GetSimpleMenuItemSchema] + + class Config: + extra = "forbid" + + +# Ingredient-related things +# ------------------------- + + +class IngredientItemSchema(BaseModel): + name: str + description: Optional[str] = None + + class Config: + extra = "forbid" + + +class GetIngredientItemSchema(IngredientItemSchema): + id: int + + class Config: + extra = "forbid" + + +# Schema for MenuItemIngredient +class MenuItemIngredientSchema(BaseModel): + quantity: float + unit: UnitOfMeasure + ingredient: GetIngredientItemSchema + + class Config: + extra = "forbid" + + +# GET, now includes id and ingredients... +class GetMenuItemSchema(GetSimpleMenuItemSchema): + """ + Menu Item detail (GET) + """ + + id: int + ingredients: List[ + MenuItemIngredientSchema + ] # Include ingredients with the menu item + + +# Stock-related things +# -------------------- + + +class MenuItemAvailabilitySchema(BaseModel): + ingredient: GetIngredientItemSchema + required_quantity: Annotated[float, Field(ge=0.0, strict=True)] + available_quantity: Annotated[float, Field(ge=0.0, strict=True)] + unit: UnitOfMeasure + + class Config: + extra = "forbid" + + +# Schema for menu item availability +class GetMenuItemAvailabilitySchema(GetSimpleMenuItemSchema): + available_portions: int = 0 + ingredient_availability: List[MenuItemAvailabilitySchema] + + class Config: + extra = "forbid" + + +# ================================= # Orders-related Schema for the API +# ================================= class CreateOrderSchema(BaseModel): menu_id: int diff --git a/weird_salads/inventory/inventory_service/exceptions.py b/weird_salads/inventory/inventory_service/exceptions.py new file mode 100644 index 0000000..baafa43 --- /dev/null +++ b/weird_salads/inventory/inventory_service/exceptions.py @@ -0,0 +1,6 @@ +class MenuItemNotFoundError(Exception): + pass + + +class UnitConversionError(Exception): + pass diff --git a/weird_salads/inventory/inventory_service/inventory.py b/weird_salads/inventory/inventory_service/inventory.py new file mode 100644 index 0000000..a7ddaa5 --- /dev/null +++ b/weird_salads/inventory/inventory_service/inventory.py @@ -0,0 +1,129 @@ +""" +Classes +""" + +__all__ = [ + "SimpleMenuItem", + "MenuItem", + "MenuItemIngredient", + "IngredientItem", + "StockItem", +] + + +# - MenuItem holds MenuItemIngredients +# - SimpleMenuItem <- a simplified version of MenuItem sans ingredients +# - MenuAvailabilityItem <- more complex MenuItem with availability info +class SimpleMenuItem: + def __init__(self, id, name, description, price, created_on, on_menu): + self.name = name + self.id = id + self.description = description + self.price = price + self.created_on = created_on + self.on_menu = on_menu + + def dict(self): + return { + "id": self.id, + "name": self.name, + "description": self.description, + "price": self.price, + "created_on": self.created_on, + "on_menu": self.on_menu, + } + + +# RecipeItem holds a set of RecipeIngredient objects +class MenuItem: + def __init__( + self, id, name, description, price, created_on, on_menu, ingredients=None + ): + self.id = id + self.name = name + self.description = description + self.price = price + self.created_on = created_on + self.on_menu = on_menu + # Initialize ingredients as MenuItemIngredient instances from dictionaries + self.ingredients = [MenuItemIngredient(**item) for item in (ingredients or [])] + + def dict(self): + result = { + "id": self.id, + "name": self.name, + "description": self.description, + "price": self.price, + "created_on": self.created_on.isoformat() if self.created_on else None, + "on_menu": self.on_menu, + "ingredients": [ingredient.dict() for ingredient in self.ingredients], + } + return result + + +class MenuItemIngredient: + def __init__(self, quantity, unit, ingredient=None): + self.quantity = quantity + self.unit = unit + # Initialize ingredient as IngredientItem from dictionary + self.ingredient = IngredientItem(**(ingredient or {})) + + def dict(self): + result = { + "quantity": self.quantity, + "unit": self.unit, + "ingredient": self.ingredient.dict() if self.ingredient else None, + } + return result + + +class IngredientItem: + def __init__(self, id, name, description): + self.id = id + self.name = name + self.description = description + + def dict(self): + result = { + "id": self.id, + "name": self.name, + "description": self.description, + } + return result + + +class StockItem: + def __init__( + self, + id, + ingredient_id, + unit, + quantity, + cost, + delivery_date, + created_on, + order_=None, + ): + self._order = order_ + self._id = id + self.ingredient_id = ingredient_id + self.unit = unit + self.quantity = quantity + self.cost = cost + self.delivery_date = delivery_date + self.created_on = created_on + + @property + def id(self): + return self._id or self._order.id + + def dict(self): + return { + "id": self.id, + "ingredient_id": self.ingredient_id, + "unit": self.unit, + "quantity": self.quantity, + "cost": self.cost, + "delivery_date": self.delivery_date, + "created_on": self.created_on, + } diff --git a/weird_salads/inventory/inventory_service/inventory_service.py b/weird_salads/inventory/inventory_service/inventory_service.py new file mode 100644 index 0000000..aded268 --- /dev/null +++ b/weird_salads/inventory/inventory_service/inventory_service.py @@ -0,0 +1,151 @@ +""" +Services +""" + +from typing import Any, Dict, List + +from weird_salads.api.schemas import UnitOfMeasure +from weird_salads.inventory.inventory_service.exceptions import MenuItemNotFoundError +from weird_salads.inventory.inventory_service.inventory import ( + MenuItem, + MenuItemIngredient, +) +from weird_salads.inventory.repository.inventory_repository import MenuRepository + +__all__ = ["MenuService", "UNIT_CONVERSIONS_TO_LITRE"] + +# How many of unit in one litre +UNIT_CONVERSIONS_TO_LITRE = { + UnitOfMeasure.liter: 1, + UnitOfMeasure.deciliter: 10, + UnitOfMeasure.centiliter: 100, + UnitOfMeasure.milliliter: 1000, +} + + +class MenuService: + def __init__(self, menu_repository: MenuRepository): + self.menu_repository = menu_repository + + # for a simple representation + def get(self, item_id): + menu_item = self.menu_repository.get(item_id) + if menu_item is not None: + return menu_item + raise MenuItemNotFoundError(f"Menu item with id {item_id} not found") + + def get_item(self, item_id: int) -> MenuItem: + menu_item = self.menu_repository.get_tree(item_id) + if menu_item is not None: + return menu_item + raise MenuItemNotFoundError(f"Menu item with id {item_id} not found") + + def list_menu(self): + return self.menu_repository.list() + + # Fetch stock data for an ingredient + def _fetch_stock_data( + self, ingredient_id: int, required_unit: UnitOfMeasure + ) -> float: + """ + Fetch the stock data for an ingredient_id and convert to the required unit. + """ + try: + stock_items = self.get_ingredient(ingredient_id) + total_quantity_in_required_unit = sum( + self._convert_to_unit(item.quantity, item.unit, required_unit) + for item in stock_items + ) + return total_quantity_in_required_unit + except Exception as e: + print(f"Error fetching stock data: {e}") + return 0 + + # Convert quantity from one unit to another + def _convert_to_unit( + self, quantity: float, from_unit: UnitOfMeasure, to_unit: UnitOfMeasure + ) -> float: + if from_unit == to_unit: + return quantity + quantity_in_litres = quantity / UNIT_CONVERSIONS_TO_LITRE[from_unit] + return quantity_in_litres * UNIT_CONVERSIONS_TO_LITRE[to_unit] + + # Calculate available portions based on recipe ingredients + def _calculate_available_portions( + self, recipe_ingredients: List[MenuItemIngredient] + ) -> float: + available_portions = float("inf") + for ri in recipe_ingredients: + ingredient_id = ri.ingredient.id + required_quantity = ri.quantity + required_unit = ri.unit + try: + # !TODO duplicated code + total_quantity_in_stock = self._fetch_stock_data( + ingredient_id, required_unit + ) + if required_quantity > 0: + portions_based_on_ingredient = ( + total_quantity_in_stock // required_quantity + ) + available_portions = min( + available_portions, portions_based_on_ingredient + ) + except Exception as e: + print(f"Error processing ingredient ID {ingredient_id}: {e}") + available_portions = 0 + break + return available_portions + + # Get availability of a recipe item + def get_recipe_item_availability(self, item_id: int) -> Dict[str, Any]: + menu_item_with_ingredients = self.get_item(item_id) + ingredients = menu_item_with_ingredients.ingredients + + ingredient_availability = [ + { + "ingredient": { + "id": ri.ingredient.id, + "name": ri.ingredient.name, + "description": ri.ingredient.description, + }, + "required_quantity": ri.quantity, + "available_quantity": self._fetch_stock_data(ri.ingredient.id, ri.unit), + "unit": ri.unit, + } + for ri in ingredients + ] + + available_portions = self._calculate_available_portions(ingredients) + + return { + "id": menu_item_with_ingredients.id, + "name": menu_item_with_ingredients.name, + "description": menu_item_with_ingredients.description or "", + "price": menu_item_with_ingredients.price, + "created_on": menu_item_with_ingredients.created_on, + "on_menu": menu_item_with_ingredients.on_menu, + "available_portions": int(available_portions), + "ingredient_availability": ingredient_availability, + } + + # - Stock-related + # ---- ingredient_id queries + def get_ingredient(self, ingredient_id: int): + ingredient_item = self.menu_repository.get_ingredient(ingredient_id) + if ingredient_item is not None: + return ingredient_item + raise ValueError(f"items with id {ingredient_id} not found") # fix + + def ingest_stock(self, item): + return self.stock_repository.add_stock(item) + + # ---- stock_id queries + def get_stock_item(self, stock_id: str): + stock_item = self.menu_repository.get_stock(stock_id) + if stock_item is not None: + return stock_item + raise ValueError(f"stock with id {stock_id} not found") # fix + + def list_stock(self): # needs options for filtering + return self.menu_repository.list_stock() diff --git a/weird_salads/inventory/repository/inventory_repository.py b/weird_salads/inventory/repository/inventory_repository.py new file mode 100644 index 0000000..de5c3da --- /dev/null +++ b/weird_salads/inventory/repository/inventory_repository.py @@ -0,0 +1,131 @@ +""" +Building on a Repository Pattern +""" + + +from typing import List + +from sqlalchemy.orm import joinedload + +from weird_salads.inventory.inventory_service.inventory import ( + MenuItem, + SimpleMenuItem, + StockItem, +) +from weird_salads.inventory.repository.models import ( + MenuModel, + RecipeIngredientModel, + StockModel, +) + +__all__ = ["MenuRepository"] + + +class MenuRepository: + def __init__(self, session): + self.session = session + + def add(self): + pass + + def _get(self, id): + return ( + self.session.query(MenuModel).filter(MenuModel.id == str(id)).first() + ) # noqa: E501 + + def get(self, id): + order = self._get(id) + if order is not None: + # do we want to return an instance of a SQLalchemy model? + return SimpleMenuItem(**order.dict()) + + def _get_tree(self, id): + return ( + self.session.query(MenuModel) + .options( + joinedload(MenuModel.ingredients).joinedload( + RecipeIngredientModel.ingredient + ) + ) + .filter(MenuModel.id == id) + .first() + ) + + def get_tree(self, id: int) -> MenuItem: + # Fetch the tree data + tree = self._get_tree(id) + + # !TODO tidy this up, it's not elegant + if tree is not None: + # Prepare ingredients data as dictionaries for MenuItemIngredient instances + ingredients = [ + { + "quantity": ri.quantity, + "unit": ri.unit, + "ingredient": { + "id": ri.ingredient.id, + "name": ri.ingredient.name, + "description": ri.ingredient.description or "", + }, + } + for ri in tree.ingredients + ] + + # Create a MenuItem instance + menu_item = MenuItem( + id=tree.id, + name=tree.name, + description=tree.description, + price=tree.price, + created_on=tree.created_on, + on_menu=tree.on_menu, + ingredients=ingredients, + ) + + # Convert MenuItem to a dictionary and return it + return menu_item + + def list(self, limit=None): + query = self.session.query(MenuModel) + records = query.limit(limit).all() + return [SimpleMenuItem(**record.dict()) for record in records] + + def update(self, id): + pass + + def delete(self, id): + pass + + # - Stock-related + def _get_ingredient(self, id: int): + return ( + self.session.query(StockModel) + .filter(StockModel.ingredient_id == int(id)) + .all() + ) # noqa: E501 + + def get_ingredient(self, id: int) -> List[StockItem]: + ingredients = self._get_ingredient(id) + if ingredients is not None: + return [StockItem(**ingredient.dict()) for ingredient in ingredients] + + def _get_stock(self, id: str): + return self.session.query(StockModel).filter(StockModel.id == id).first() + + def get_stock(self, id_: str): + order = self._get(id) + if order is not None: + return StockItem(**order.dict()) + + def list_stock(self, limit=None): + query = self.session.query(StockModel) + records = query.all() + return [StockItem(**record.dict()) for record in records] + # return [Ingredient(**record.dict()) for record in records] + # should this return an IngredientItemsModel? + + def add_stock(self, item): + print(item) + record = StockModel(**item) + self.session.add(record) + return StockItem(**record.dict(), order_=record) diff --git a/weird_salads/utils/database/seed_db.py b/weird_salads/utils/database/seed_db.py index ce96f02..5d86d25 100644 --- a/weird_salads/utils/database/seed_db.py +++ b/weird_salads/utils/database/seed_db.py @@ -211,7 +211,7 @@ def main(location_id: int, quantity: int, base_path: Path) -> None: ) logger.info( - f"Seeding completed successfully for location {location_id}." + f"Seeding completed successfully for location {location_id} and quantity {quantity}." # noqa: E501 ) else: logger.info("Database already contains data. Skipping seeding.") @@ -229,7 +229,7 @@ def main(location_id: int, quantity: int, base_path: Path) -> None: ) parser.add_argument( "--quantity", - type=int, + type=float, default=0, help="The quantity of data to seed (in respective units).", )