From c36d85e1cdfec07d8aca1d4cd00779cd73b6ff46 Mon Sep 17 00:00:00 2001 From: Paul Wright Date: Tue, 6 Aug 2024 00:56:06 +0100 Subject: [PATCH] Initial Ordering System (#16) * working ordering through API * buggy streamlit app --- docker/docker-compose.yml | 2 +- streamlit_app/pages/create_stock.py | 63 ----------------- streamlit_app/pages/ingredients.py | 41 ----------- streamlit_app/pages/menu.py | 48 +++++++------ streamlit_app/pages/report_order.py | 4 +- streamlit_app/pages/report_stock.py | 2 +- weird_salads/api/app.py | 67 +++++++++++++++--- weird_salads/api/schemas.py | 6 ++ .../inventory/inventory_service/exceptions.py | 4 ++ .../inventory_service/inventory_service.py | 42 +++++++++++ .../repository/inventory_repository.py | 28 +++++++- .../orders/orders_service/orders_service.py | 70 ++++++++++++++++++- .../orders/repository/orders_repository.py | 3 - 13 files changed, 235 insertions(+), 145 deletions(-) delete mode 100644 streamlit_app/pages/create_stock.py delete mode 100644 streamlit_app/pages/ingredients.py diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 0f8ec18..b21e1a6 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -10,7 +10,7 @@ services: environment: - DATABASE_URL=sqlite:///data/orders.db - SEED_LOCATION_ID=1 # location id for DB seeding - - SEED_QUANTITY=7.4 # quantity of ingredients for DB seeding + - SEED_QUANTITY=1000 # quantity of ingredients for DB seeding streamlit: build: diff --git a/streamlit_app/pages/create_stock.py b/streamlit_app/pages/create_stock.py deleted file mode 100644 index 1281455..0000000 --- a/streamlit_app/pages/create_stock.py +++ /dev/null @@ -1,63 +0,0 @@ -from datetime import datetime - -import requests -import streamlit as st - - -def create_stock_item(payload): - try: - # Post data to FastAPI endpoint - response = requests.post("http://fastapi:8000/inventory", json=payload) - response.raise_for_status() - return response.json(), None - except requests.exceptions.RequestException as e: - return None, str(e) - - -def add_stock_page(): - st.title("Add Stock Item") - - # Define the form for stock creation - with st.form(key="add_stock_form"): - st.header("Stock Information") - - # Input fields for stock data - ingredient_id = st.number_input("Ingredient ID", min_value=1, step=1) - stock_quantity = st.number_input( - "Quantity", min_value=0.0, format="%.2f" - ) # Float with 2 decimal places - stock_unit = st.selectbox( - "Unit", ["liter", "deciliter", "centiliter", "milliliter"] - ) # Example units - cost = st.number_input( - "Cost per Unit", min_value=0.0, format="%.2f" - ) # Float with 2 decimal places - delivery_date = st.date_input( - "Delivery Date", min_value=datetime(2000, 1, 1) - ) # Date input - - # Create the stock data payload - payload = { - "stock": { - "ingredient_id": ingredient_id, - "unit": stock_unit, - "quantity": stock_quantity, - "cost": cost, - "delivery_date": delivery_date.isoformat(), - # Convert date to ISO format string - } - } - - # Submit button - submit_button = st.form_submit_button(label="Add Stock") - - if submit_button: - result, error = create_stock_item(payload) - if error: - st.error(f"Failed to add stock: {error}") - else: - st.success(f"Stock item added successfully: {result}") - - -if __name__ == "__main__": - add_stock_page() diff --git a/streamlit_app/pages/ingredients.py b/streamlit_app/pages/ingredients.py deleted file mode 100644 index 7829833..0000000 --- a/streamlit_app/pages/ingredients.py +++ /dev/null @@ -1,41 +0,0 @@ -import pandas as pd -import requests -import streamlit as st - - -def display_ingredient_items(): - st.title("Ingredient Items Report") - - ingredient_id = st.number_input("Enter Ingredient ID:", min_value=1, step=1) - - if st.button("Get Ingredient"): - try: - # Fetch ingredient data from the FastAPI endpoint - response = requests.get( - f"http://fastapi:8000/inventory/ingredient/{ingredient_id}" - ) - response.raise_for_status() - data = response.json() - - # Extract items from the response data - ingredient_items = data.get("items", []) - df_ingredient = pd.DataFrame(ingredient_items) - - # Check if the DataFrame is empty - if df_ingredient.empty: - st.write(f"No data found for Ingredient ID {ingredient_id}.") - else: - # Ensure 'delivery_date' is treated as a datetime object for sorting - df_ingredient["delivery_date"] = pd.to_datetime( - df_ingredient["delivery_date"] - ) - # Sort the DataFrame by 'delivery_date' - df_ingredient_sorted = df_ingredient.sort_values(by="delivery_date") - st.table(df_ingredient_sorted) - - except requests.exceptions.RequestException as e: - st.write("Failed to connect to FastAPI:", e) - - -if __name__ == "__main__": - display_ingredient_items() diff --git a/streamlit_app/pages/menu.py b/streamlit_app/pages/menu.py index cd47b98..f076440 100644 --- a/streamlit_app/pages/menu.py +++ b/streamlit_app/pages/menu.py @@ -3,8 +3,10 @@ import streamlit as st -# Function to fetch menu items def fetch_menu_items(): + """ + GET menu items + """ try: response = requests.get("http://fastapi:8000/menu/") response.raise_for_status() @@ -18,20 +20,32 @@ def fetch_menu_items(): return [] -# Function to fetch availability of a specific menu item -def fetch_item_availability(item_id): +def place_order(item_id): + """ + Place order, get order_id + """ try: - response = requests.get( - f"http://fastapi:8000/menu/{item_id}/availability" - ) # Update endpoint if necessary + response = requests.post( + "http://fastapi:8000/order/", json={"menu_id": item_id} + ) response.raise_for_status() - return response.json() + data = response.json() + order_id = data.get("id", "unknown") + return f"Success, your order number is {order_id}, don't forget it!" + except requests.exceptions.HTTPError as http_err: + if response.status_code == 400: + # Handle specific client error (e.g., insufficient stock) + error_mesage = response.json().get("detail", "Sorry, out of stock") + if "InsufficientStockError" in error_mesage: + return "Sorry, out of stock" + return f"Order failed: {error_mesage}" + return f"HTTP error occurred: {http_err}" except requests.exceptions.RequestException as e: - st.write("Failed to connect to FastAPI:", e) - return None + st.write("Failed to place the order:", e) + return "Failed to place the order." except Exception as e: st.write("An error occurred:", e) - return None + return "An error occurred while placing the order." # Function to display menu items @@ -53,24 +67,18 @@ def display_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']})") + st.write(f"{row['id']}. \t {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" + # Place the order and get the response + st.session_state.order_status = place_order(row["id"]) + st.session_state.current_order = row["name"] with cols[2]: if st.session_state.current_order == row["name"]: diff --git a/streamlit_app/pages/report_order.py b/streamlit_app/pages/report_order.py index dce3918..93775d2 100644 --- a/streamlit_app/pages/report_order.py +++ b/streamlit_app/pages/report_order.py @@ -4,9 +4,7 @@ def display_order_report(): - st.title("Weird Salads") - - st.header("Order Report") + st.title("Report: Orders") try: response = requests.get("http://fastapi:8000/order/") diff --git a/streamlit_app/pages/report_stock.py b/streamlit_app/pages/report_stock.py index 98bb56e..aa297ea 100644 --- a/streamlit_app/pages/report_stock.py +++ b/streamlit_app/pages/report_stock.py @@ -4,7 +4,7 @@ def display_stock_items(): - st.title("Stock Items Report") + st.title("Report: Stock") try: # Fetch stock data from the FastAPI endpoint diff --git a/weird_salads/api/app.py b/weird_salads/api/app.py index dedfe48..8dfa9f1 100644 --- a/weird_salads/api/app.py +++ b/weird_salads/api/app.py @@ -11,9 +11,11 @@ GetSimpleMenuSchema, GetStockItemSchema, GetStockSchema, + UpdateStockSchema, ) from weird_salads.inventory.inventory_service.exceptions import ( IngredientNotFoundError, + InsufficientStockError, MenuItemNotFoundError, StockItemNotFoundError, ) @@ -138,6 +140,44 @@ def create_stock(payload: CreateStockSchema): return return_payload +# Approach: +# - HTTP Requests Between Services +# +# Other Options: +# - Mediator Pattern: +# This provides a clean interface between the services and maintains +# loose coupling by introducing a mediator that handles interactions. +# - Event-Driven Architecture: +# This allows the OrdersService and MenuService to be completely decoupled, +# with interactions handled via events and event handlers. +# ----- + + +@app.post("/inventory/update", tags=["Inventory"]) +def update_stock(payload: UpdateStockSchema): + # UpdateStockSchema enforces quantity < 0 (only allows for deductions) + + try: + with UnitOfWork() as unit_of_work: + ingredient_id = payload.ingredient_id + quantity_to_deduct = abs(payload.quantity) + unit = payload.unit # Enum value, no need to convert + inventory_repo = MenuRepository(unit_of_work.session) + inventory_service = MenuService(inventory_repo) + + # Update stock quantity + total_deducted = inventory_service.deduct_stock( + ingredient_id, quantity_to_deduct, unit + ) + + unit_of_work.commit() + return {"status": "success", "total_deducted": total_deducted} + except InsufficientStockError as e: + raise HTTPException(status_code=500, detail=str(e)) + except Exception: + raise HTTPException(status_code=500, detail="An unexpected error occurred") + + # Orders @app.get( "/order", @@ -152,6 +192,7 @@ def get_orders(): return {"orders": [result.dict() for result in results]} +# Not sure if this is the best way or if we should be hitting endpoints. @app.post( "/order", status_code=status.HTTP_201_CREATED, @@ -159,11 +200,21 @@ def get_orders(): tags=["Order"], ) def create_order(payload: CreateOrderSchema): - with UnitOfWork() as unit_of_work: - repo = OrdersRepository(unit_of_work.session) - orders_service = OrdersService(repo) - order = payload.model_dump() - order = orders_service.place_order(order) - unit_of_work.commit() # this is when id and created are populated - return_payload = order.dict() - return return_payload + try: + with UnitOfWork() as unit_of_work: + orders_repo = OrdersRepository(unit_of_work.session) + orders_service = OrdersService(orders_repo) + + order_data = payload.model_dump() + order = orders_service.place_order(order_data) + + unit_of_work.commit() # Commit the order and stock deduction + + return_payload = order.dict() + return return_payload + except InsufficientStockError as e: + raise HTTPException(status_code=500, detail=str(e)) + except Exception as e: + raise HTTPException( + status_code=500, detail=f"An unexpected error occurred: {e}" + ) diff --git a/weird_salads/api/schemas.py b/weird_salads/api/schemas.py index 13b8f91..824c5d7 100644 --- a/weird_salads/api/schemas.py +++ b/weird_salads/api/schemas.py @@ -179,3 +179,9 @@ class CreateStockSchema(BaseModel): class Config: extra = "forbid" + + +class UpdateStockSchema(BaseModel): + ingredient_id: int + quantity: Annotated[float, Field(le=0.0, strict=True)] # negative values only + unit: UnitOfMeasure diff --git a/weird_salads/inventory/inventory_service/exceptions.py b/weird_salads/inventory/inventory_service/exceptions.py index 92d8d84..ad3b295 100644 --- a/weird_salads/inventory/inventory_service/exceptions.py +++ b/weird_salads/inventory/inventory_service/exceptions.py @@ -12,3 +12,7 @@ class StockItemNotFoundError(Exception): class IngredientNotFoundError(Exception): pass + + +class InsufficientStockError(Exception): + pass diff --git a/weird_salads/inventory/inventory_service/inventory_service.py b/weird_salads/inventory/inventory_service/inventory_service.py index 8903422..e76452a 100644 --- a/weird_salads/inventory/inventory_service/inventory_service.py +++ b/weird_salads/inventory/inventory_service/inventory_service.py @@ -7,6 +7,7 @@ from weird_salads.api.schemas import UnitOfMeasure from weird_salads.inventory.inventory_service.exceptions import ( IngredientNotFoundError, + InsufficientStockError, MenuItemNotFoundError, StockItemNotFoundError, ) @@ -153,3 +154,44 @@ def get_stock_item(self, stock_id: str): def list_stock(self): # needs options for filtering return self.menu_repository.list_stock() + + # -- stock deduction + # !TODO check units + def deduct_stock(self, ingredient_id: int, quantity: float, unit: UnitOfMeasure): + """ + Deduct a specific quantity of stock for a given item. + """ + # Fetch the stock item(s) for the given item_id + stock_items = self.menu_repository.get_ingredient(ingredient_id) + + if not stock_items: + raise StockItemNotFoundError( + f"No stock found for ingredient ID {ingredient_id}" + ) + + total_deducted = 0.0 + quantity_to_deduct = quantity + + for stock_item in stock_items: + if quantity_to_deduct <= 0: + break + + available_quantity = stock_item.quantity + # !TODO fix the assumption that the quantity is in the stocks unit + + quantity_deducted = min(quantity_to_deduct, available_quantity) + # Deduct the quantity + stock_item.quantity -= quantity_deducted + total_deducted += quantity_deducted + quantity_to_deduct -= quantity_deducted + + # !TODO If stock item is depleted, remove it + + if quantity_to_deduct > 0: + raise InsufficientStockError( + f"Not enough stock available to deduct {quantity_to_deduct} units" + ) + + self.menu_repository.update_ingredient(stock_items) + + return total_deducted diff --git a/weird_salads/inventory/repository/inventory_repository.py b/weird_salads/inventory/repository/inventory_repository.py index 83fb992..45cefa6 100644 --- a/weird_salads/inventory/repository/inventory_repository.py +++ b/weird_salads/inventory/repository/inventory_repository.py @@ -97,10 +97,10 @@ def delete(self, id): pass # - Stock-related - def _get_ingredient(self, id: int): + def _get_ingredient(self, ingredient_id: int): return ( self.session.query(StockModel) - .filter(StockModel.ingredient_id == int(id)) + .filter(StockModel.ingredient_id == int(ingredient_id)) .all() ) # noqa: E501 @@ -129,3 +129,27 @@ def add_stock(self, item): record = StockModel(**item) self.session.add(record) return StockItem(**record.dict(), order_=record) + + def _convert_to_model(self, stock_item: StockItem) -> StockModel: + return StockModel( + id=stock_item.id, + ingredient_id=stock_item.ingredient_id, + unit=stock_item.unit, + quantity=stock_item.quantity, + cost=stock_item.cost, + delivery_date=stock_item.delivery_date, + created_on=stock_item.created_on, + ) + + def update_ingredient(self, stock_items: List[StockItem]) -> None: + """ + Update stock items in the session. + """ + merged_records = [] + for stock_item in stock_items: + record = StockModel(**stock_item.dict()) + + # Merge record with the session + merged_records.append(self.session.merge(record)) + + return merged_records diff --git a/weird_salads/orders/orders_service/orders_service.py b/weird_salads/orders/orders_service/orders_service.py index 7d37664..dcfa6fc 100644 --- a/weird_salads/orders/orders_service/orders_service.py +++ b/weird_salads/orders/orders_service/orders_service.py @@ -2,6 +2,11 @@ Services """ +import requests +from fastapi import HTTPException + +from weird_salads.api.schemas import UnitOfMeasure +from weird_salads.inventory.inventory_service.exceptions import InsufficientStockError from weird_salads.orders.orders_service.exceptions import OrderNotFoundError from weird_salads.orders.repository.orders_repository import OrdersRepository @@ -12,11 +17,38 @@ class OrdersService: def __init__(self, orders_repository: OrdersRepository): self.orders_repository = orders_repository - def place_order(self, item): + def place_order(self, order_data): """ - place order + Place an order after checking inventory and updating stock levels. """ - return self.orders_repository.add(item) + menu_id = int(order_data["menu_id"]) + + # Fetch availability information for the menu item + available_to_order, availability_response = self._get_menu_item_availability( + menu_id + ) + + if not available_to_order: + raise InsufficientStockError("Sorry, this is out of stock.") + + # Place the order, then deduct stock (uow deals with the committing later) + order = self.orders_repository.add(order_data) + + for ingredient in availability_response["ingredient_availability"]: + ingredient_id = int(ingredient["ingredient"]["id"]) + required_quantity = float(ingredient["required_quantity"]) + unit_string = str(ingredient["unit"]) + + # Convert the unit string to the UnitOfMeasure Enum + try: + unit = UnitOfMeasure(unit_string) + except ValueError: + raise HTTPException( + status_code=400, detail=f"Invalid unit: {unit_string}" + ) + self._update_stock(ingredient_id, -1 * required_quantity, unit) + + return order def get_order(self, order_id: str): """ @@ -32,3 +64,35 @@ def list_orders(self): get all orders """ return self.orders_repository.list() + + def _get_menu_item_availability(self, menu_id: int): + """ + Fetch availability details for the given menu item, + including ingredient availability. + """ + response = requests.get(f"http://localhost:8000/menu/{menu_id}/availability") + if response.status_code == 200: + availability = response.json() + return availability["available_portions"] >= 0, response.json() + else: + raise HTTPException( + status_code=response.status_code, + detail=f"Failed to get availability for menu ID {menu_id}", + ) + + def _update_stock( + self, ingredient_id: int, quantity: float, unit: UnitOfMeasure + ) -> None: + response = requests.post( + "http://localhost:8000/inventory/update", + json={ + "ingredient_id": ingredient_id, + "quantity": quantity, + "unit": unit.value, + }, + ) + if response.status_code != 200: + raise HTTPException( + status_code=response.status_code, + detail=f"Failed to update stock for Ingredient ID {ingredient_id}", + ) diff --git a/weird_salads/orders/repository/orders_repository.py b/weird_salads/orders/repository/orders_repository.py index bb2c930..7f55bfc 100644 --- a/weird_salads/orders/repository/orders_repository.py +++ b/weird_salads/orders/repository/orders_repository.py @@ -32,8 +32,5 @@ def list(self): records = query.all() return [Order(**record.dict()) for record in records] - def update(self, id): - pass - def delete(self, id): pass