mirror of
https://github.com/RichieCahill/dotfiles.git
synced 2026-04-17 04:58:19 -04:00
added van api and front end
This commit is contained in:
46
python/orm/van_inventory/models.py
Normal file
46
python/orm/van_inventory/models.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Van inventory ORM models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from python.orm.van_inventory.base import VanTableBase
|
||||||
|
|
||||||
|
|
||||||
|
class Item(VanTableBase):
|
||||||
|
"""A food item in the van."""
|
||||||
|
|
||||||
|
__tablename__ = "items"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(unique=True)
|
||||||
|
quantity: Mapped[float] = mapped_column(default=0)
|
||||||
|
unit: Mapped[str]
|
||||||
|
category: Mapped[str | None]
|
||||||
|
|
||||||
|
meal_ingredients: Mapped[list[MealIngredient]] = relationship(back_populates="item")
|
||||||
|
|
||||||
|
|
||||||
|
class Meal(VanTableBase):
|
||||||
|
"""A meal that can be made from items in the van."""
|
||||||
|
|
||||||
|
__tablename__ = "meals"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(unique=True)
|
||||||
|
instructions: Mapped[str | None]
|
||||||
|
|
||||||
|
ingredients: Mapped[list[MealIngredient]] = relationship(back_populates="meal")
|
||||||
|
|
||||||
|
|
||||||
|
class MealIngredient(VanTableBase):
|
||||||
|
"""Links a meal to the items it requires, with quantities."""
|
||||||
|
|
||||||
|
__tablename__ = "meal_ingredients"
|
||||||
|
__table_args__ = (UniqueConstraint("meal_id", "item_id"),)
|
||||||
|
|
||||||
|
meal_id: Mapped[int] = mapped_column(ForeignKey("meals.id"))
|
||||||
|
item_id: Mapped[int] = mapped_column(ForeignKey("items.id"))
|
||||||
|
quantity_needed: Mapped[float]
|
||||||
|
|
||||||
|
meal: Mapped[Meal] = relationship(back_populates="ingredients")
|
||||||
|
item: Mapped[Item] = relationship(back_populates="meal_ingredients")
|
||||||
1
python/van_inventory/__init__.py
Normal file
1
python/van_inventory/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Van inventory FastAPI application."""
|
||||||
16
python/van_inventory/dependencies.py
Normal file
16
python/van_inventory/dependencies.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""FastAPI dependencies for van inventory."""
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
|
def get_db(request: Request) -> Iterator[Session]:
|
||||||
|
"""Get database session from app state."""
|
||||||
|
with Session(request.app.state.engine) as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
51
python/van_inventory/main.py
Normal file
51
python/van_inventory/main.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""FastAPI app for van inventory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import TYPE_CHECKING, Annotated
|
||||||
|
|
||||||
|
import typer
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from python.common import configure_logger
|
||||||
|
from python.orm.common import get_postgres_engine
|
||||||
|
from python.van_inventory.routers import api_router, frontend_router
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
"""Create and configure the FastAPI application."""
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
app.state.engine = get_postgres_engine(name="VAN_INVENTORY")
|
||||||
|
yield
|
||||||
|
app.state.engine.dispose()
|
||||||
|
|
||||||
|
app = FastAPI(title="Van Inventory", lifespan=lifespan)
|
||||||
|
app.include_router(api_router)
|
||||||
|
app.include_router(frontend_router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def serve(
|
||||||
|
# Intentionally binds all interfaces — this is a LAN-only van server
|
||||||
|
host: Annotated[str, typer.Option("--host", "-h", help="Host to bind to")] = "0.0.0.0", # noqa: S104
|
||||||
|
port: Annotated[int, typer.Option("--port", "-p", help="Port to bind to")] = 8001,
|
||||||
|
log_level: Annotated[str, typer.Option("--log-level", "-l", help="Log level")] = "INFO",
|
||||||
|
) -> None:
|
||||||
|
"""Start the Van Inventory server."""
|
||||||
|
configure_logger(log_level)
|
||||||
|
app = create_app()
|
||||||
|
uvicorn.run(app, host=host, port=port)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
typer.run(serve)
|
||||||
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})
|
||||||
30
python/van_inventory/templates/availability.html
Normal file
30
python/van_inventory/templates/availability.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}What Can I Make? - Van{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>What Can I Make?</h1>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Meal</th><th>Status</th><th>Missing</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for meal in availability %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/meals/{{ meal.meal_id }}">{{ meal.meal_name }}</a></td>
|
||||||
|
<td>
|
||||||
|
{% if meal.can_make %}
|
||||||
|
<span class="badge yes">Ready</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge no">Missing items</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="missing-list">
|
||||||
|
{% for m in meal.missing %}
|
||||||
|
{{ m.item_name }}: need {{ m.short }} more {{ m.unit }}{% if not loop.last %}, {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
42
python/van_inventory/templates/base.html
Normal file
42
python/van_inventory/templates/base.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Van Inventory{% endblock %}</title>
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 900px; margin: 0 auto; padding: 1rem; background: #1a1a2e; color: #e0e0e0; }
|
||||||
|
h1, h2, h3 { margin-bottom: 0.5rem; color: #e94560; }
|
||||||
|
a { color: #e94560; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
nav { display: flex; gap: 1.5rem; padding: 1rem 0; border-bottom: 1px solid #333; margin-bottom: 1.5rem; }
|
||||||
|
nav a { font-weight: 600; font-size: 1.1rem; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
|
||||||
|
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #333; }
|
||||||
|
th { color: #e94560; font-size: 0.85rem; text-transform: uppercase; }
|
||||||
|
form { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: end; margin: 1rem 0; }
|
||||||
|
input, select { padding: 0.4rem 0.6rem; border: 1px solid #444; border-radius: 4px; background: #16213e; color: #e0e0e0; }
|
||||||
|
input:focus, select:focus { outline: none; border-color: #e94560; }
|
||||||
|
button { padding: 0.4rem 1rem; border: none; border-radius: 4px; background: #e94560; color: white; cursor: pointer; font-weight: 600; }
|
||||||
|
button:hover { background: #c73651; }
|
||||||
|
button.danger { background: #666; }
|
||||||
|
button.danger:hover { background: #e94560; }
|
||||||
|
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 12px; font-size: 0.8rem; font-weight: 600; }
|
||||||
|
.badge.yes { background: #0f3460; color: #4ecca3; }
|
||||||
|
.badge.no { background: #3a0a0a; color: #e94560; }
|
||||||
|
.missing-list { font-size: 0.85rem; color: #aaa; }
|
||||||
|
label { font-size: 0.85rem; color: #aaa; display: flex; flex-direction: column; gap: 0.2rem; }
|
||||||
|
.flash { padding: 0.5rem 1rem; margin: 0.5rem 0; border-radius: 4px; background: #0f3460; color: #4ecca3; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="/">Inventory</a>
|
||||||
|
<a href="/meals">Meals</a>
|
||||||
|
<a href="/availability">What Can I Make?</a>
|
||||||
|
</nav>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
python/van_inventory/templates/items.html
Normal file
17
python/van_inventory/templates/items.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Inventory - Van{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Van Inventory</h1>
|
||||||
|
|
||||||
|
<form hx-post="/items" hx-target="#item-list" hx-swap="innerHTML" hx-on::after-request="this.reset()">
|
||||||
|
<label>Name <input type="text" name="name" required></label>
|
||||||
|
<label>Qty <input type="number" name="quantity" step="any" value="0" required></label>
|
||||||
|
<label>Unit <input type="text" name="unit" required placeholder="lbs, cans, etc"></label>
|
||||||
|
<label>Category <input type="text" name="category" placeholder="optional"></label>
|
||||||
|
<button type="submit">Add Item</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="item-list">
|
||||||
|
{% include "partials/item_rows.html" %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
24
python/van_inventory/templates/meal_detail.html
Normal file
24
python/van_inventory/templates/meal_detail.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ meal.name }} - Van{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ meal.name }}</h1>
|
||||||
|
{% if meal.instructions %}<p>{{ meal.instructions }}</p>{% endif %}
|
||||||
|
|
||||||
|
<h2>Ingredients</h2>
|
||||||
|
<form hx-post="/meals/{{ meal.id }}/ingredients" hx-target="#ingredient-list" hx-swap="innerHTML" hx-on::after-request="this.reset()">
|
||||||
|
<label>Item
|
||||||
|
<select name="item_id" required>
|
||||||
|
<option value="">--</option>
|
||||||
|
{% for item in items %}
|
||||||
|
<option value="{{ item.id }}">{{ item.name }} ({{ item.unit }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Qty needed <input type="number" name="quantity_needed" step="any" required></label>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="ingredient-list">
|
||||||
|
{% include "partials/ingredient_rows.html" %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
15
python/van_inventory/templates/meals.html
Normal file
15
python/van_inventory/templates/meals.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Meals - Van{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Meals</h1>
|
||||||
|
|
||||||
|
<form hx-post="/meals" hx-target="#meal-list" hx-swap="innerHTML" hx-on::after-request="this.reset()">
|
||||||
|
<label>Name <input type="text" name="name" required></label>
|
||||||
|
<label>Instructions <input type="text" name="instructions" placeholder="optional"></label>
|
||||||
|
<button type="submit">Add Meal</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="meal-list">
|
||||||
|
{% include "partials/meal_rows.html" %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
16
python/van_inventory/templates/partials/ingredient_rows.html
Normal file
16
python/van_inventory/templates/partials/ingredient_rows.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Item</th><th>Needed</th><th>Have</th><th>Unit</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for mi in meal.ingredients %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ mi.item.name }}</td>
|
||||||
|
<td>{{ mi.quantity_needed }}</td>
|
||||||
|
<td>{{ mi.item.quantity }}</td>
|
||||||
|
<td>{{ mi.item.unit }}</td>
|
||||||
|
<td><button class="danger" hx-delete="/meals/{{ meal.id }}/ingredients/{{ mi.item_id }}" hx-target="#ingredient-list" hx-swap="innerHTML" hx-confirm="Remove {{ mi.item.name }}?">X</button></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
21
python/van_inventory/templates/partials/item_rows.html
Normal file
21
python/van_inventory/templates/partials/item_rows.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Qty</th><th>Unit</th><th>Category</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.name }}</td>
|
||||||
|
<td>
|
||||||
|
<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">
|
||||||
|
<button type="submit" style="padding:0.2rem 0.5rem; font-size:0.8rem;">Update</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>{{ item.unit }}</td>
|
||||||
|
<td>{{ item.category or "" }}</td>
|
||||||
|
<td><button class="danger" hx-delete="/items/{{ item.id }}" hx-target="#item-list" hx-swap="innerHTML" hx-confirm="Delete {{ item.name }}?">X</button></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
15
python/van_inventory/templates/partials/meal_rows.html
Normal file
15
python/van_inventory/templates/partials/meal_rows.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Ingredients</th><th>Instructions</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for meal in meals %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/meals/{{ meal.id }}">{{ meal.name }}</a></td>
|
||||||
|
<td>{{ meal.ingredients | length }}</td>
|
||||||
|
<td>{{ (meal.instructions or "")[:50] }}</td>
|
||||||
|
<td><button class="danger" hx-delete="/meals/{{ meal.id }}" hx-target="#meal-list" hx-swap="innerHTML" hx-confirm="Delete {{ meal.name }}?">X</button></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
Reference in New Issue
Block a user