mirror of
https://github.com/RichieCahill/dotfiles.git
synced 2026-04-17 13:08:19 -04:00
315 lines
9.1 KiB
Python
315 lines
9.1 KiB
Python
"""Van inventory API router."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from python.orm.van_inventory.models import Item, Meal, MealIngredient
|
|
|
|
if TYPE_CHECKING:
|
|
from python.van_inventory.dependencies import DbSession
|
|
|
|
|
|
# --- Schemas ---
|
|
|
|
|
|
class ItemCreate(BaseModel):
|
|
"""Schema for creating an item."""
|
|
|
|
name: str
|
|
quantity: float = Field(default=0, ge=0)
|
|
unit: str
|
|
category: str | None = None
|
|
|
|
|
|
class ItemUpdate(BaseModel):
|
|
"""Schema for updating an item."""
|
|
|
|
name: str | None = None
|
|
quantity: float | None = Field(default=None, ge=0)
|
|
unit: str | None = None
|
|
category: str | None = None
|
|
|
|
|
|
class ItemResponse(BaseModel):
|
|
"""Schema for item response."""
|
|
|
|
id: int
|
|
name: str
|
|
quantity: float
|
|
unit: str
|
|
category: str | None
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class IngredientCreate(BaseModel):
|
|
"""Schema for adding an ingredient to a meal."""
|
|
|
|
item_id: int
|
|
quantity_needed: float = Field(gt=0)
|
|
|
|
|
|
class MealCreate(BaseModel):
|
|
"""Schema for creating a meal."""
|
|
|
|
name: str
|
|
instructions: str | None = None
|
|
ingredients: list[IngredientCreate] = []
|
|
|
|
|
|
class MealUpdate(BaseModel):
|
|
"""Schema for updating a meal."""
|
|
|
|
name: str | None = None
|
|
instructions: str | None = None
|
|
|
|
|
|
class IngredientResponse(BaseModel):
|
|
"""Schema for ingredient response."""
|
|
|
|
item_id: int
|
|
item_name: str
|
|
quantity_needed: float
|
|
unit: str
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class MealResponse(BaseModel):
|
|
"""Schema for meal response."""
|
|
|
|
id: int
|
|
name: str
|
|
instructions: str | None
|
|
ingredients: list[IngredientResponse] = []
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
@classmethod
|
|
def from_meal(cls, meal: Meal) -> MealResponse:
|
|
"""Build a MealResponse from an ORM Meal with loaded ingredients."""
|
|
return cls(
|
|
id=meal.id,
|
|
name=meal.name,
|
|
instructions=meal.instructions,
|
|
ingredients=[
|
|
IngredientResponse(
|
|
item_id=mi.item_id,
|
|
item_name=mi.item.name,
|
|
quantity_needed=mi.quantity_needed,
|
|
unit=mi.item.unit,
|
|
)
|
|
for mi in meal.ingredients
|
|
],
|
|
)
|
|
|
|
|
|
class ShoppingItem(BaseModel):
|
|
"""An item needed for a meal that is short on stock."""
|
|
|
|
item_name: str
|
|
unit: str
|
|
needed: float
|
|
have: float
|
|
short: float
|
|
|
|
|
|
class MealAvailability(BaseModel):
|
|
"""Availability status for a meal."""
|
|
|
|
meal_id: int
|
|
meal_name: str
|
|
can_make: bool
|
|
missing: list[ShoppingItem] = []
|
|
|
|
|
|
# --- Routes ---
|
|
|
|
router = APIRouter(prefix="/api", tags=["van_inventory"])
|
|
|
|
|
|
# Items
|
|
|
|
|
|
@router.post("/items", response_model=ItemResponse)
|
|
def create_item(item: ItemCreate, db: DbSession) -> Item:
|
|
"""Create a new inventory item."""
|
|
db_item = Item(**item.model_dump())
|
|
db.add(db_item)
|
|
db.commit()
|
|
db.refresh(db_item)
|
|
return db_item
|
|
|
|
|
|
@router.get("/items", response_model=list[ItemResponse])
|
|
def list_items(db: DbSession) -> list[Item]:
|
|
"""List all inventory items."""
|
|
return list(db.scalars(select(Item).order_by(Item.name)).all())
|
|
|
|
|
|
@router.get("/items/{item_id}", response_model=ItemResponse)
|
|
def get_item(item_id: int, db: DbSession) -> Item:
|
|
"""Get an item by ID."""
|
|
item = db.get(Item, item_id)
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Item not found")
|
|
return item
|
|
|
|
|
|
@router.patch("/items/{item_id}", response_model=ItemResponse)
|
|
def update_item(item_id: int, item: ItemUpdate, db: DbSession) -> Item:
|
|
"""Update an item by ID."""
|
|
db_item = db.get(Item, item_id)
|
|
if not db_item:
|
|
raise HTTPException(status_code=404, detail="Item not found")
|
|
for key, value in item.model_dump(exclude_unset=True).items():
|
|
setattr(db_item, key, value)
|
|
db.commit()
|
|
db.refresh(db_item)
|
|
return db_item
|
|
|
|
|
|
@router.delete("/items/{item_id}")
|
|
def delete_item(item_id: int, db: DbSession) -> dict[str, bool]:
|
|
"""Delete an item by ID."""
|
|
item = db.get(Item, item_id)
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Item not found")
|
|
db.delete(item)
|
|
db.commit()
|
|
return {"deleted": True}
|
|
|
|
|
|
# Meals
|
|
|
|
|
|
@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()
|
|
for ing in meal.ingredients:
|
|
db.add(MealIngredient(meal_id=db_meal.id, item_id=ing.item_id, quantity_needed=ing.quantity_needed))
|
|
db.commit()
|
|
db_meal = db.scalar(
|
|
select(Meal)
|
|
.where(Meal.id == db_meal.id)
|
|
.options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
|
|
)
|
|
return MealResponse.from_meal(db_meal)
|
|
|
|
|
|
@router.get("/meals", response_model=list[MealResponse])
|
|
def list_meals(db: DbSession) -> list[MealResponse]:
|
|
"""List all meals with ingredients."""
|
|
meals = list(
|
|
db.scalars(
|
|
select(Meal).options(selectinload(Meal.ingredients).selectinload(MealIngredient.item)).order_by(Meal.name)
|
|
).all()
|
|
)
|
|
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."""
|
|
meal = db.scalar(
|
|
select(Meal).where(Meal.id == meal_id).options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
|
|
)
|
|
if not meal:
|
|
raise HTTPException(status_code=404, detail="Meal not found")
|
|
return MealResponse.from_meal(meal)
|
|
|
|
|
|
@router.delete("/meals/{meal_id}")
|
|
def delete_meal(meal_id: int, db: DbSession) -> dict[str, bool]:
|
|
"""Delete a meal by ID."""
|
|
meal = db.get(Meal, meal_id)
|
|
if not meal:
|
|
raise HTTPException(status_code=404, detail="Meal not found")
|
|
db.delete(meal)
|
|
db.commit()
|
|
return {"deleted": True}
|
|
|
|
|
|
@router.post("/meals/{meal_id}/ingredients", response_model=MealResponse)
|
|
def add_ingredient(meal_id: int, ingredient: IngredientCreate, db: DbSession) -> MealResponse:
|
|
"""Add an ingredient to a meal."""
|
|
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(
|
|
select(Meal).where(Meal.id == meal_id).options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
|
|
)
|
|
return MealResponse.from_meal(meal)
|
|
|
|
|
|
@router.delete("/meals/{meal_id}/ingredients/{item_id}")
|
|
def remove_ingredient(meal_id: int, item_id: int, db: DbSession) -> dict[str, bool]:
|
|
"""Remove an ingredient from a meal."""
|
|
mi = db.scalar(select(MealIngredient).where(MealIngredient.meal_id == meal_id, MealIngredient.item_id == item_id))
|
|
if not mi:
|
|
raise HTTPException(status_code=404, detail="Ingredient not found")
|
|
db.delete(mi)
|
|
db.commit()
|
|
return {"deleted": True}
|
|
|
|
|
|
@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."""
|
|
meal = db.scalar(
|
|
select(Meal).where(Meal.id == meal_id).options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
|
|
)
|
|
if not meal:
|
|
raise HTTPException(status_code=404, detail="Meal not found")
|
|
return _check_meal(meal)
|
|
|
|
|
|
def _check_meal(meal: Meal) -> MealAvailability:
|
|
missing = [
|
|
ShoppingItem(
|
|
item_name=mi.item.name,
|
|
unit=mi.item.unit,
|
|
needed=mi.quantity_needed,
|
|
have=mi.item.quantity,
|
|
short=mi.quantity_needed - mi.item.quantity,
|
|
)
|
|
for mi in meal.ingredients
|
|
if mi.item.quantity < mi.quantity_needed
|
|
]
|
|
return MealAvailability(
|
|
meal_id=meal.id,
|
|
meal_name=meal.name,
|
|
can_make=len(missing) == 0,
|
|
missing=missing,
|
|
)
|