added bound checking to van invintory

This commit is contained in:
2026-03-09 07:20:26 -04:00
parent 58b25f2e89
commit 75a67294ea
7 changed files with 49 additions and 24 deletions

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -22,7 +22,7 @@ class ItemCreate(BaseModel):
"""Schema for creating an item.""" """Schema for creating an item."""
name: str name: str
quantity: float = 0 quantity: float = Field(default=0, ge=0)
unit: str unit: str
category: str | None = None category: str | None = None
@@ -31,7 +31,7 @@ class ItemUpdate(BaseModel):
"""Schema for updating an item.""" """Schema for updating an item."""
name: str | None = None name: str | None = None
quantity: float | None = None quantity: float | None = Field(default=None, ge=0)
unit: str | None = None unit: str | None = None
category: str | None = None category: str | None = None
@@ -52,7 +52,7 @@ class IngredientCreate(BaseModel):
"""Schema for adding an ingredient to a meal.""" """Schema for adding an ingredient to a meal."""
item_id: int item_id: int
quantity_needed: float quantity_needed: float = Field(gt=0)
class MealCreate(BaseModel): class MealCreate(BaseModel):
@@ -192,6 +192,9 @@ def delete_item(item_id: int, db: DbSession) -> dict[str, bool]:
@router.post("/meals", response_model=MealResponse) @router.post("/meals", response_model=MealResponse)
def create_meal(meal: MealCreate, db: DbSession) -> MealResponse: def create_meal(meal: MealCreate, db: DbSession) -> MealResponse:
"""Create a new meal with optional ingredients.""" """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_meal = Meal(name=meal.name, instructions=meal.instructions)
db.add(db_meal) db.add(db_meal)
db.flush() db.flush()
@@ -217,6 +220,15 @@ def list_meals(db: DbSession) -> list[MealResponse]:
return [MealResponse.from_meal(m) for m in meals] 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) @router.get("/meals/{meal_id}", response_model=MealResponse)
def get_meal(meal_id: int, db: DbSession) -> MealResponse: def get_meal(meal_id: int, db: DbSession) -> MealResponse:
"""Get a meal by ID with ingredients.""" """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) meal = db.get(Meal, meal_id)
if not meal: if not meal:
raise HTTPException(status_code=404, detail="Meal not found") 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.add(MealIngredient(meal_id=meal_id, item_id=ingredient.item_id, quantity_needed=ingredient.quantity_needed))
db.commit() db.commit()
meal = db.scalar( meal = db.scalar(
@@ -264,18 +283,6 @@ def remove_ingredient(meal_id: int, item_id: int, db: DbSession) -> dict[str, bo
return {"deleted": True} 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) @router.get("/meals/{meal_id}/availability", response_model=MealAvailability)
def check_meal(meal_id: int, db: DbSession) -> MealAvailability: def check_meal(meal_id: int, db: DbSession) -> MealAvailability:
"""Check if a specific meal can be made and what's missing.""" """Check if a specific meal can be made and what's missing."""

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Form, Request from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import select from sqlalchemy import select
@@ -43,6 +43,8 @@ def htmx_create_item(
category: Annotated[str | None, Form()] = None, category: Annotated[str | None, Form()] = None,
) -> HTMLResponse: ) -> HTMLResponse:
"""Create an item and return updated item rows.""" """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.add(Item(name=name, quantity=quantity, unit=unit, category=category or None))
db.commit() db.commit()
items = list(db.scalars(select(Item).order_by(Item.name)).all()) items = list(db.scalars(select(Item).order_by(Item.name)).all())
@@ -57,6 +59,8 @@ def htmx_update_item(
quantity: Annotated[float, Form()], quantity: Annotated[float, Form()],
) -> HTMLResponse: ) -> HTMLResponse:
"""Update an item's quantity and return updated item rows.""" """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) item = db.get(Item, item_id)
if item: if item:
item.quantity = quantity 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: def meal_detail_page(request: Request, meal_id: int, db: DbSession) -> HTMLResponse:
"""Render the meal detail page.""" """Render the meal detail page."""
meal = _load_meal(db, meal_id) 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()) items = list(db.scalars(select(Item).order_by(Item.name)).all())
return templates.TemplateResponse(request, "meal_detail.html", {"meal": meal, "items": items}) return templates.TemplateResponse(request, "meal_detail.html", {"meal": meal, "items": items})
@@ -145,6 +151,18 @@ def htmx_add_ingredient(
quantity_needed: Annotated[float, Form()], quantity_needed: Annotated[float, Form()],
) -> HTMLResponse: ) -> HTMLResponse:
"""Add an ingredient to a meal and return updated ingredient rows.""" """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.add(MealIngredient(meal_id=meal_id, item_id=item_id, quantity_needed=quantity_needed))
db.commit() db.commit()
meal = _load_meal(db, meal_id) meal = _load_meal(db, meal_id)

View File

@@ -8,7 +8,7 @@
--bg-input: #111128; --bg-input: #111128;
--border: #1a1a3e; --border: #1a1a3e;
--text: #c0c0d0; --text: #c0c0d0;
--text-dim: #666680; --text-dim: #8e8ea0;
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }

View File

@@ -3,9 +3,9 @@
{% block content %} {% block content %}
<h1>Van Inventory</h1> <h1>Van Inventory</h1>
<form hx-post="/items" hx-target="#item-list" hx-swap="innerHTML" hx-on::after-request="this.reset()"> <form hx-post="/items" hx-target="#item-list" hx-swap="innerHTML" hx-on::after-request="if(event.detail.successful) this.reset()">
<label>Name <input type="text" name="name" required></label> <label>Name <input type="text" name="name" required></label>
<label>Qty <input type="number" name="quantity" step="any" value="0" required></label> <label>Qty <input type="number" name="quantity" step="any" value="0" min="0" required></label>
<label>Unit <input type="text" name="unit" required placeholder="lbs, cans, etc"></label> <label>Unit <input type="text" name="unit" required placeholder="lbs, cans, etc"></label>
<label>Category <input type="text" name="category" placeholder="optional"></label> <label>Category <input type="text" name="category" placeholder="optional"></label>
<button type="submit">Add Item</button> <button type="submit">Add Item</button>

View File

@@ -5,7 +5,7 @@
{% if meal.instructions %}<p>{{ meal.instructions }}</p>{% endif %} {% if meal.instructions %}<p>{{ meal.instructions }}</p>{% endif %}
<h2>Ingredients</h2> <h2>Ingredients</h2>
<form hx-post="/meals/{{ meal.id }}/ingredients" hx-target="#ingredient-list" hx-swap="innerHTML" hx-on::after-request="this.reset()"> <form hx-post="/meals/{{ meal.id }}/ingredients" hx-target="#ingredient-list" hx-swap="innerHTML" hx-on::after-request="if(event.detail.successful) this.reset()">
<label>Item <label>Item
<select name="item_id" required> <select name="item_id" required>
<option value="">--</option> <option value="">--</option>
@@ -14,7 +14,7 @@
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
<label>Qty needed <input type="number" name="quantity_needed" step="any" required></label> <label>Qty needed <input type="number" name="quantity_needed" step="any" min="0.01" required></label>
<button type="submit">Add</button> <button type="submit">Add</button>
</form> </form>

View File

@@ -3,7 +3,7 @@
{% block content %} {% block content %}
<h1>Meals</h1> <h1>Meals</h1>
<form hx-post="/meals" hx-target="#meal-list" hx-swap="innerHTML" hx-on::after-request="this.reset()"> <form hx-post="/meals" hx-target="#meal-list" hx-swap="innerHTML" hx-on::after-request="if(event.detail.successful) this.reset()">
<label>Name <input type="text" name="name" required></label> <label>Name <input type="text" name="name" required></label>
<label>Instructions <input type="text" name="instructions" placeholder="optional"></label> <label>Instructions <input type="text" name="instructions" placeholder="optional"></label>
<button type="submit">Add Meal</button> <button type="submit">Add Meal</button>

View File

@@ -8,7 +8,7 @@
<td>{{ item.name }}</td> <td>{{ item.name }}</td>
<td> <td>
<form hx-patch="/items/{{ item.id }}" hx-target="#item-list" hx-swap="innerHTML" style="display:inline; margin:0;"> <form hx-patch="/items/{{ item.id }}" hx-target="#item-list" hx-swap="innerHTML" style="display:inline; margin:0;">
<input type="number" name="quantity" value="{{ item.quantity }}" step="any" style="width:5rem"> <input type="number" name="quantity" value="{{ item.quantity }}" step="any" min="0" style="width:5rem">
<button type="submit" style="padding:0.2rem 0.5rem; font-size:0.8rem;">Update</button> <button type="submit" style="padding:0.2rem 0.5rem; font-size:0.8rem;">Update</button>
</form> </form>
</td> </td>