diff --git a/python/van_inventory/routers/api.py b/python/van_inventory/routers/api.py index edbbb1c..2dfaeaa 100644 --- a/python/van_inventory/routers/api.py +++ b/python/van_inventory/routers/api.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import TYPE_CHECKING from fastapi import APIRouter, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.orm import selectinload @@ -22,7 +22,7 @@ class ItemCreate(BaseModel): """Schema for creating an item.""" name: str - quantity: float = 0 + quantity: float = Field(default=0, ge=0) unit: str category: str | None = None @@ -31,7 +31,7 @@ class ItemUpdate(BaseModel): """Schema for updating an item.""" name: str | None = None - quantity: float | None = None + quantity: float | None = Field(default=None, ge=0) unit: str | None = None category: str | None = None @@ -52,7 +52,7 @@ class IngredientCreate(BaseModel): """Schema for adding an ingredient to a meal.""" item_id: int - quantity_needed: float + quantity_needed: float = Field(gt=0) class MealCreate(BaseModel): @@ -192,6 +192,9 @@ def delete_item(item_id: int, db: DbSession) -> dict[str, bool]: @router.post("/meals", response_model=MealResponse) def create_meal(meal: MealCreate, db: DbSession) -> MealResponse: """Create a new meal with optional ingredients.""" + for ing in meal.ingredients: + if not db.get(Item, ing.item_id): + raise HTTPException(status_code=422, detail=f"Item {ing.item_id} not found") db_meal = Meal(name=meal.name, instructions=meal.instructions) db.add(db_meal) db.flush() @@ -217,6 +220,15 @@ def list_meals(db: DbSession) -> list[MealResponse]: return [MealResponse.from_meal(m) for m in meals] +@router.get("/meals/availability", response_model=list[MealAvailability]) +def check_all_meals(db: DbSession) -> list[MealAvailability]: + """Check which meals can be made with current inventory.""" + meals = list( + db.scalars(select(Meal).options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))).all() + ) + return [_check_meal(m) for m in meals] + + @router.get("/meals/{meal_id}", response_model=MealResponse) def get_meal(meal_id: int, db: DbSession) -> MealResponse: """Get a meal by ID with ingredients.""" @@ -245,6 +257,13 @@ def add_ingredient(meal_id: int, ingredient: IngredientCreate, db: DbSession) -> meal = db.get(Meal, meal_id) if not meal: raise HTTPException(status_code=404, detail="Meal not found") + if not db.get(Item, ingredient.item_id): + raise HTTPException(status_code=422, detail="Item not found") + existing = db.scalar( + select(MealIngredient).where(MealIngredient.meal_id == meal_id, MealIngredient.item_id == ingredient.item_id) + ) + if existing: + raise HTTPException(status_code=409, detail="Ingredient already exists for this meal") db.add(MealIngredient(meal_id=meal_id, item_id=ingredient.item_id, quantity_needed=ingredient.quantity_needed)) db.commit() meal = db.scalar( @@ -264,18 +283,6 @@ def remove_ingredient(meal_id: int, item_id: int, db: DbSession) -> dict[str, bo return {"deleted": True} -# What can I make / what do I need - - -@router.get("/meals/availability", response_model=list[MealAvailability]) -def check_all_meals(db: DbSession) -> list[MealAvailability]: - """Check which meals can be made with current inventory.""" - meals = list( - db.scalars(select(Meal).options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))).all() - ) - return [_check_meal(m) for m in meals] - - @router.get("/meals/{meal_id}/availability", response_model=MealAvailability) def check_meal(meal_id: int, db: DbSession) -> MealAvailability: """Check if a specific meal can be made and what's missing.""" diff --git a/python/van_inventory/routers/frontend.py b/python/van_inventory/routers/frontend.py index 3a8a013..5c9109a 100644 --- a/python/van_inventory/routers/frontend.py +++ b/python/van_inventory/routers/frontend.py @@ -5,7 +5,7 @@ from __future__ import annotations from pathlib import Path from typing import Annotated -from fastapi import APIRouter, Form, Request +from fastapi import APIRouter, Form, HTTPException, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from sqlalchemy import select @@ -43,6 +43,8 @@ def htmx_create_item( category: Annotated[str | None, Form()] = None, ) -> HTMLResponse: """Create an item and return updated item rows.""" + if quantity < 0: + raise HTTPException(status_code=422, detail="Quantity must not be negative") db.add(Item(name=name, quantity=quantity, unit=unit, category=category or None)) db.commit() items = list(db.scalars(select(Item).order_by(Item.name)).all()) @@ -57,6 +59,8 @@ def htmx_update_item( quantity: Annotated[float, Form()], ) -> HTMLResponse: """Update an item's quantity and return updated item rows.""" + if quantity < 0: + raise HTTPException(status_code=422, detail="Quantity must not be negative") item = db.get(Item, item_id) if item: item.quantity = quantity @@ -132,6 +136,8 @@ def _load_meal(db: DbSession, meal_id: int) -> Meal | None: def meal_detail_page(request: Request, meal_id: int, db: DbSession) -> HTMLResponse: """Render the meal detail page.""" meal = _load_meal(db, meal_id) + if not meal: + raise HTTPException(status_code=404, detail="Meal not found") items = list(db.scalars(select(Item).order_by(Item.name)).all()) return templates.TemplateResponse(request, "meal_detail.html", {"meal": meal, "items": items}) @@ -145,6 +151,18 @@ def htmx_add_ingredient( quantity_needed: Annotated[float, Form()], ) -> HTMLResponse: """Add an ingredient to a meal and return updated ingredient rows.""" + if quantity_needed <= 0: + raise HTTPException(status_code=422, detail="Quantity must be positive") + meal = db.get(Meal, meal_id) + if not meal: + raise HTTPException(status_code=404, detail="Meal not found") + if not db.get(Item, item_id): + raise HTTPException(status_code=422, detail="Item not found") + existing = db.scalar( + select(MealIngredient).where(MealIngredient.meal_id == meal_id, MealIngredient.item_id == item_id) + ) + if existing: + raise HTTPException(status_code=409, detail="Ingredient already exists for this meal") db.add(MealIngredient(meal_id=meal_id, item_id=item_id, quantity_needed=quantity_needed)) db.commit() meal = _load_meal(db, meal_id) diff --git a/python/van_inventory/static/style.css b/python/van_inventory/static/style.css index 40ef037..396e9fa 100644 --- a/python/van_inventory/static/style.css +++ b/python/van_inventory/static/style.css @@ -8,7 +8,7 @@ --bg-input: #111128; --border: #1a1a3e; --text: #c0c0d0; - --text-dim: #666680; + --text-dim: #8e8ea0; } * { box-sizing: border-box; margin: 0; padding: 0; } diff --git a/python/van_inventory/templates/items.html b/python/van_inventory/templates/items.html index f82be9d..ca20bdb 100644 --- a/python/van_inventory/templates/items.html +++ b/python/van_inventory/templates/items.html @@ -3,9 +3,9 @@ {% block content %}