mirror of
https://github.com/RichieCahill/dotfiles.git
synced 2026-04-17 13:08:19 -04:00
added van api and front end
This commit is contained in:
6
python/van_inventory/routers/__init__.py
Normal file
6
python/van_inventory/routers/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Van inventory API routers."""
|
||||
|
||||
from python.van_inventory.routers.api import router as api_router
|
||||
from python.van_inventory.routers.frontend import router as frontend_router
|
||||
|
||||
__all__ = ["api_router", "frontend_router"]
|
||||
319
python/van_inventory/routers/api.py
Normal file
319
python/van_inventory/routers/api.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""Van inventory API router."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
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 = 0
|
||||
unit: str
|
||||
category: str | None = None
|
||||
|
||||
|
||||
class ItemUpdate(BaseModel):
|
||||
"""Schema for updating an item."""
|
||||
|
||||
name: str | None = None
|
||||
quantity: float | None = None
|
||||
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
|
||||
|
||||
|
||||
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."""
|
||||
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/{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")
|
||||
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}
|
||||
|
||||
|
||||
# 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."""
|
||||
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,
|
||||
)
|
||||
188
python/van_inventory/routers/frontend.py
Normal file
188
python/van_inventory/routers/frontend.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""HTMX frontend routes for van inventory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Annotated
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from python.orm.van_inventory.models import Item, Meal, MealIngredient
|
||||
from python.van_inventory.routers.api import _check_meal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from python.van_inventory.dependencies import DbSession
|
||||
|
||||
TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=TEMPLATE_DIR)
|
||||
|
||||
router = APIRouter(tags=["frontend"])
|
||||
|
||||
|
||||
# --- Items ---
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def items_page(request: Request, db: DbSession) -> HTMLResponse:
|
||||
"""Render the inventory page."""
|
||||
items = list(db.scalars(select(Item).order_by(Item.name)).all())
|
||||
return templates.TemplateResponse(request, "items.html", {"items": items})
|
||||
|
||||
|
||||
@router.post("/items", response_class=HTMLResponse)
|
||||
def htmx_create_item(
|
||||
request: Request,
|
||||
db: DbSession,
|
||||
name: Annotated[str, Form()],
|
||||
quantity: Annotated[float, Form()] = 0,
|
||||
unit: Annotated[str, Form()] = "",
|
||||
category: Annotated[str | None, Form()] = None,
|
||||
) -> HTMLResponse:
|
||||
"""Create an item and return updated item rows."""
|
||||
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())
|
||||
return templates.TemplateResponse(request, "partials/item_rows.html", {"items": items})
|
||||
|
||||
|
||||
@router.patch("/items/{item_id}", response_class=HTMLResponse)
|
||||
def htmx_update_item(
|
||||
request: Request,
|
||||
item_id: int,
|
||||
db: DbSession,
|
||||
quantity: Annotated[float, Form()],
|
||||
) -> HTMLResponse:
|
||||
"""Update an item's quantity and return updated item rows."""
|
||||
item = db.get(Item, item_id)
|
||||
if item:
|
||||
item.quantity = quantity
|
||||
db.commit()
|
||||
items = list(db.scalars(select(Item).order_by(Item.name)).all())
|
||||
return templates.TemplateResponse(request, "partials/item_rows.html", {"items": items})
|
||||
|
||||
|
||||
@router.delete("/items/{item_id}", response_class=HTMLResponse)
|
||||
def htmx_delete_item(request: Request, item_id: int, db: DbSession) -> HTMLResponse:
|
||||
"""Delete an item and return updated item rows."""
|
||||
item = db.get(Item, item_id)
|
||||
if item:
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
items = list(db.scalars(select(Item).order_by(Item.name)).all())
|
||||
return templates.TemplateResponse(request, "partials/item_rows.html", {"items": items})
|
||||
|
||||
|
||||
# --- Meals ---
|
||||
|
||||
|
||||
def _load_meals(db: DbSession) -> list[Meal]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(Meal)
|
||||
.options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
|
||||
.order_by(Meal.name)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/meals", response_class=HTMLResponse)
|
||||
def meals_page(request: Request, db: DbSession) -> HTMLResponse:
|
||||
"""Render the meals page."""
|
||||
meals = _load_meals(db)
|
||||
return templates.TemplateResponse(request, "meals.html", {"meals": meals})
|
||||
|
||||
|
||||
@router.post("/meals", response_class=HTMLResponse)
|
||||
def htmx_create_meal(
|
||||
request: Request,
|
||||
db: DbSession,
|
||||
name: Annotated[str, Form()],
|
||||
instructions: Annotated[str | None, Form()] = None,
|
||||
) -> HTMLResponse:
|
||||
"""Create a meal and return updated meal rows."""
|
||||
db.add(Meal(name=name, instructions=instructions or None))
|
||||
db.commit()
|
||||
meals = _load_meals(db)
|
||||
return templates.TemplateResponse(request, "partials/meal_rows.html", {"meals": meals})
|
||||
|
||||
|
||||
@router.delete("/meals/{meal_id}", response_class=HTMLResponse)
|
||||
def htmx_delete_meal(request: Request, meal_id: int, db: DbSession) -> HTMLResponse:
|
||||
"""Delete a meal and return updated meal rows."""
|
||||
meal = db.get(Meal, meal_id)
|
||||
if meal:
|
||||
db.delete(meal)
|
||||
db.commit()
|
||||
meals = _load_meals(db)
|
||||
return templates.TemplateResponse(request, "partials/meal_rows.html", {"meals": meals})
|
||||
|
||||
|
||||
# --- Meal detail ---
|
||||
|
||||
|
||||
def _load_meal(db: DbSession, meal_id: int) -> Meal | None:
|
||||
return db.scalar(
|
||||
select(Meal)
|
||||
.where(Meal.id == meal_id)
|
||||
.options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
|
||||
)
|
||||
|
||||
|
||||
@router.get("/meals/{meal_id}", response_class=HTMLResponse)
|
||||
def meal_detail_page(request: Request, meal_id: int, db: DbSession) -> HTMLResponse:
|
||||
"""Render the meal detail page."""
|
||||
meal = _load_meal(db, meal_id)
|
||||
items = list(db.scalars(select(Item).order_by(Item.name)).all())
|
||||
return templates.TemplateResponse(request, "meal_detail.html", {"meal": meal, "items": items})
|
||||
|
||||
|
||||
@router.post("/meals/{meal_id}/ingredients", response_class=HTMLResponse)
|
||||
def htmx_add_ingredient(
|
||||
request: Request,
|
||||
meal_id: int,
|
||||
db: DbSession,
|
||||
item_id: Annotated[int, Form()],
|
||||
quantity_needed: Annotated[float, Form()],
|
||||
) -> HTMLResponse:
|
||||
"""Add an ingredient to a meal and return updated ingredient rows."""
|
||||
db.add(MealIngredient(meal_id=meal_id, item_id=item_id, quantity_needed=quantity_needed))
|
||||
db.commit()
|
||||
meal = _load_meal(db, meal_id)
|
||||
return templates.TemplateResponse(request, "partials/ingredient_rows.html", {"meal": meal})
|
||||
|
||||
|
||||
@router.delete("/meals/{meal_id}/ingredients/{item_id}", response_class=HTMLResponse)
|
||||
def htmx_remove_ingredient(
|
||||
request: Request,
|
||||
meal_id: int,
|
||||
item_id: int,
|
||||
db: DbSession,
|
||||
) -> HTMLResponse:
|
||||
"""Remove an ingredient from a meal and return updated ingredient rows."""
|
||||
mi = db.scalar(
|
||||
select(MealIngredient).where(MealIngredient.meal_id == meal_id, MealIngredient.item_id == item_id)
|
||||
)
|
||||
if mi:
|
||||
db.delete(mi)
|
||||
db.commit()
|
||||
meal = _load_meal(db, meal_id)
|
||||
return templates.TemplateResponse(request, "partials/ingredient_rows.html", {"meal": meal})
|
||||
|
||||
|
||||
# --- Availability ---
|
||||
|
||||
|
||||
@router.get("/availability", response_class=HTMLResponse)
|
||||
def availability_page(request: Request, db: DbSession) -> HTMLResponse:
|
||||
"""Render the meal availability page."""
|
||||
meals = list(
|
||||
db.scalars(
|
||||
select(Meal).options(selectinload(Meal.ingredients).selectinload(MealIngredient.item))
|
||||
).all()
|
||||
)
|
||||
availability = [_check_meal(m) for m in meals]
|
||||
return templates.TemplateResponse(request, "availability.html", {"availability": availability})
|
||||
Reference in New Issue
Block a user