From bd490334f548cf501b11c3152f3c86556463ec37 Mon Sep 17 00:00:00 2001 From: Richie Cahill Date: Sat, 7 Mar 2026 16:19:39 -0500 Subject: [PATCH] added van api and front end --- python/orm/van_inventory/models.py | 46 +++ python/van_inventory/__init__.py | 1 + python/van_inventory/dependencies.py | 16 + python/van_inventory/main.py | 51 +++ python/van_inventory/routers/__init__.py | 6 + python/van_inventory/routers/api.py | 319 ++++++++++++++++++ python/van_inventory/routers/frontend.py | 188 +++++++++++ .../van_inventory/templates/availability.html | 30 ++ python/van_inventory/templates/base.html | 42 +++ python/van_inventory/templates/items.html | 17 + .../van_inventory/templates/meal_detail.html | 24 ++ python/van_inventory/templates/meals.html | 15 + .../templates/partials/ingredient_rows.html | 16 + .../templates/partials/item_rows.html | 21 ++ .../templates/partials/meal_rows.html | 15 + 15 files changed, 807 insertions(+) create mode 100644 python/orm/van_inventory/models.py create mode 100644 python/van_inventory/__init__.py create mode 100644 python/van_inventory/dependencies.py create mode 100644 python/van_inventory/main.py create mode 100644 python/van_inventory/routers/__init__.py create mode 100644 python/van_inventory/routers/api.py create mode 100644 python/van_inventory/routers/frontend.py create mode 100644 python/van_inventory/templates/availability.html create mode 100644 python/van_inventory/templates/base.html create mode 100644 python/van_inventory/templates/items.html create mode 100644 python/van_inventory/templates/meal_detail.html create mode 100644 python/van_inventory/templates/meals.html create mode 100644 python/van_inventory/templates/partials/ingredient_rows.html create mode 100644 python/van_inventory/templates/partials/item_rows.html create mode 100644 python/van_inventory/templates/partials/meal_rows.html diff --git a/python/orm/van_inventory/models.py b/python/orm/van_inventory/models.py new file mode 100644 index 0000000..2672285 --- /dev/null +++ b/python/orm/van_inventory/models.py @@ -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") diff --git a/python/van_inventory/__init__.py b/python/van_inventory/__init__.py new file mode 100644 index 0000000..ea8dd57 --- /dev/null +++ b/python/van_inventory/__init__.py @@ -0,0 +1 @@ +"""Van inventory FastAPI application.""" diff --git a/python/van_inventory/dependencies.py b/python/van_inventory/dependencies.py new file mode 100644 index 0000000..2d085f3 --- /dev/null +++ b/python/van_inventory/dependencies.py @@ -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)] diff --git a/python/van_inventory/main.py b/python/van_inventory/main.py new file mode 100644 index 0000000..594f018 --- /dev/null +++ b/python/van_inventory/main.py @@ -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) diff --git a/python/van_inventory/routers/__init__.py b/python/van_inventory/routers/__init__.py new file mode 100644 index 0000000..39ef29b --- /dev/null +++ b/python/van_inventory/routers/__init__.py @@ -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"] diff --git a/python/van_inventory/routers/api.py b/python/van_inventory/routers/api.py new file mode 100644 index 0000000..f9e97eb --- /dev/null +++ b/python/van_inventory/routers/api.py @@ -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, + ) diff --git a/python/van_inventory/routers/frontend.py b/python/van_inventory/routers/frontend.py new file mode 100644 index 0000000..e38c074 --- /dev/null +++ b/python/van_inventory/routers/frontend.py @@ -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}) diff --git a/python/van_inventory/templates/availability.html b/python/van_inventory/templates/availability.html new file mode 100644 index 0000000..da77be8 --- /dev/null +++ b/python/van_inventory/templates/availability.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}What Can I Make? - Van{% endblock %} +{% block content %} +

What Can I Make?

+ + + + + + + {% for meal in availability %} + + + + + + {% endfor %} + +
MealStatusMissing
{{ meal.meal_name }} + {% if meal.can_make %} + Ready + {% else %} + Missing items + {% endif %} + + {% for m in meal.missing %} + {{ m.item_name }}: need {{ m.short }} more {{ m.unit }}{% if not loop.last %}, {% endif %} + {% endfor %} +
+{% endblock %} diff --git a/python/van_inventory/templates/base.html b/python/van_inventory/templates/base.html new file mode 100644 index 0000000..54ecf59 --- /dev/null +++ b/python/van_inventory/templates/base.html @@ -0,0 +1,42 @@ + + + + + + {% block title %}Van Inventory{% endblock %} + + + + + + {% block content %}{% endblock %} + + diff --git a/python/van_inventory/templates/items.html b/python/van_inventory/templates/items.html new file mode 100644 index 0000000..f82be9d --- /dev/null +++ b/python/van_inventory/templates/items.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Inventory - Van{% endblock %} +{% block content %} +

Van Inventory

+ +
+ + + + + +
+ +
+ {% include "partials/item_rows.html" %} +
+{% endblock %} diff --git a/python/van_inventory/templates/meal_detail.html b/python/van_inventory/templates/meal_detail.html new file mode 100644 index 0000000..807eadc --- /dev/null +++ b/python/van_inventory/templates/meal_detail.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}{{ meal.name }} - Van{% endblock %} +{% block content %} +

{{ meal.name }}

+{% if meal.instructions %}

{{ meal.instructions }}

{% endif %} + +

Ingredients

+
+ + + +
+ +
+ {% include "partials/ingredient_rows.html" %} +
+{% endblock %} diff --git a/python/van_inventory/templates/meals.html b/python/van_inventory/templates/meals.html new file mode 100644 index 0000000..10acc50 --- /dev/null +++ b/python/van_inventory/templates/meals.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Meals - Van{% endblock %} +{% block content %} +

Meals

+ +
+ + + +
+ +
+ {% include "partials/meal_rows.html" %} +
+{% endblock %} diff --git a/python/van_inventory/templates/partials/ingredient_rows.html b/python/van_inventory/templates/partials/ingredient_rows.html new file mode 100644 index 0000000..9ac9748 --- /dev/null +++ b/python/van_inventory/templates/partials/ingredient_rows.html @@ -0,0 +1,16 @@ + + + + + + {% for mi in meal.ingredients %} + + + + + + + + {% endfor %} + +
ItemNeededHaveUnit
{{ mi.item.name }}{{ mi.quantity_needed }}{{ mi.item.quantity }}{{ mi.item.unit }}
diff --git a/python/van_inventory/templates/partials/item_rows.html b/python/van_inventory/templates/partials/item_rows.html new file mode 100644 index 0000000..445e7e1 --- /dev/null +++ b/python/van_inventory/templates/partials/item_rows.html @@ -0,0 +1,21 @@ + + + + + + {% for item in items %} + + + + + + + + {% endfor %} + +
NameQtyUnitCategory
{{ item.name }} +
+ + +
+
{{ item.unit }}{{ item.category or "" }}
diff --git a/python/van_inventory/templates/partials/meal_rows.html b/python/van_inventory/templates/partials/meal_rows.html new file mode 100644 index 0000000..7429b31 --- /dev/null +++ b/python/van_inventory/templates/partials/meal_rows.html @@ -0,0 +1,15 @@ + + + + + + {% for meal in meals %} + + + + + + + {% endfor %} + +
NameIngredientsInstructions
{{ meal.name }}{{ meal.ingredients | length }}{{ (meal.instructions or "")[:50] }}