deleting van_inventory
This commit is contained in:
+2
-11
@@ -4,12 +4,10 @@ Usage:
|
||||
database <db_name> <command> [args...]
|
||||
|
||||
Examples:
|
||||
database van_inventory upgrade head
|
||||
database van_inventory downgrade head-1
|
||||
database van_inventory revision --autogenerate -m "add meals table"
|
||||
database van_inventory check
|
||||
database richie check
|
||||
database richie upgrade head
|
||||
database richie downgrade head-1
|
||||
database richie revision --autogenerate -m "add meals table"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -73,13 +71,6 @@ DATABASES: dict[str, DatabaseConfig] = {
|
||||
base_class_name="RichieBase",
|
||||
models_module="python.orm.richie",
|
||||
),
|
||||
"van_inventory": DatabaseConfig(
|
||||
env_prefix="VAN_INVENTORY",
|
||||
version_location="python/alembic/van_inventory/versions",
|
||||
base_module="python.orm.van_inventory.base",
|
||||
base_class_name="VanInventoryBase",
|
||||
models_module="python.orm.van_inventory.models",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""ORM package exports."""
|
||||
|
||||
from python.orm.richie.base import RichieBase
|
||||
from python.orm.van_inventory.base import VanInventoryBase
|
||||
|
||||
__all__ = [
|
||||
"RichieBase",
|
||||
"VanInventoryBase",
|
||||
]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Van inventory database ORM exports."""
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Van inventory database ORM base."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, MetaData, func
|
||||
from sqlalchemy.ext.declarative import AbstractConcreteBase
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
from python.orm.common import NAMING_CONVENTION
|
||||
|
||||
|
||||
class VanInventoryBase(DeclarativeBase):
|
||||
"""Base class for van_inventory database ORM models."""
|
||||
|
||||
schema_name = "main"
|
||||
|
||||
metadata = MetaData(
|
||||
schema=schema_name,
|
||||
naming_convention=NAMING_CONVENTION,
|
||||
)
|
||||
|
||||
|
||||
class VanTableBase(AbstractConcreteBase, VanInventoryBase):
|
||||
"""Abstract concrete base for van_inventory tables with IDs and timestamps."""
|
||||
|
||||
__abstract__ = True
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
created: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
@@ -1,46 +0,0 @@
|
||||
"""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 +0,0 @@
|
||||
"""Van inventory FastAPI application."""
|
||||
@@ -1,16 +0,0 @@
|
||||
"""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)]
|
||||
@@ -1,56 +0,0 @@
|
||||
"""FastAPI app for van inventory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Annotated
|
||||
|
||||
import typer
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from python.common import configure_logger
|
||||
from python.orm.common import get_postgres_engine
|
||||
from python.van_inventory.routers import api_router, frontend_router
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
|
||||
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.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
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)
|
||||
@@ -1,6 +0,0 @@
|
||||
"""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"]
|
||||
@@ -1,314 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -1,198 +0,0 @@
|
||||
"""HTMX frontend routes for van inventory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, 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
|
||||
|
||||
# FastAPI needs DbSession at runtime to resolve the Depends() annotation
|
||||
from python.van_inventory.dependencies import DbSession # noqa: TC001
|
||||
from python.van_inventory.routers.api import _check_meal
|
||||
|
||||
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."""
|
||||
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.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."""
|
||||
if quantity < 0:
|
||||
raise HTTPException(status_code=422, detail="Quantity must not be negative")
|
||||
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)
|
||||
if not meal:
|
||||
raise HTTPException(status_code=404, detail="Meal not found")
|
||||
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."""
|
||||
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.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})
|
||||
@@ -1,212 +0,0 @@
|
||||
:root {
|
||||
--neon-pink: #ff2a6d;
|
||||
--neon-cyan: #05d9e8;
|
||||
--neon-yellow: #f9f002;
|
||||
--neon-purple: #d300c5;
|
||||
--bg-dark: #0a0a0f;
|
||||
--bg-panel: #0d0d1a;
|
||||
--bg-input: #111128;
|
||||
--border: #1a1a3e;
|
||||
--text: #c0c0d0;
|
||||
--text-dim: #8e8ea0;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Scanline overlay */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.08) 2px,
|
||||
rgba(0, 0, 0, 0.08) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--neon-cyan);
|
||||
text-shadow: 0 0 10px rgba(5, 217, 232, 0.5), 0 0 40px rgba(5, 217, 232, 0.2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
a { color: var(--neon-pink); text-decoration: none; transition: all 0.2s; }
|
||||
a:hover {
|
||||
text-shadow: 0 0 8px rgba(255, 42, 109, 0.8), 0 0 20px rgba(255, 42, 109, 0.4);
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
nav::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--neon-pink), var(--neon-cyan), var(--neon-purple));
|
||||
opacity: 0.6;
|
||||
}
|
||||
nav a {
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
padding: 0.3rem 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
nav a:hover {
|
||||
border-bottom-color: var(--neon-pink);
|
||||
text-shadow: 0 0 8px rgba(255, 42, 109, 0.8);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
th {
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
color: var(--neon-cyan);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--neon-cyan);
|
||||
text-shadow: 0 0 6px rgba(5, 217, 232, 0.3);
|
||||
}
|
||||
tr:hover td {
|
||||
background: rgba(5, 217, 232, 0.03);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: end;
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
input, select {
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
background: var(--bg-input);
|
||||
color: var(--neon-cyan);
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--neon-cyan);
|
||||
box-shadow: 0 0 8px rgba(5, 217, 232, 0.3), inset 0 0 8px rgba(5, 217, 232, 0.05);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.5rem 1.2rem;
|
||||
border: 1px solid var(--neon-pink);
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
color: var(--neon-pink);
|
||||
cursor: pointer;
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
button:hover {
|
||||
background: var(--neon-pink);
|
||||
color: var(--bg-dark);
|
||||
box-shadow: 0 0 15px rgba(255, 42, 109, 0.5), 0 0 30px rgba(255, 42, 109, 0.2);
|
||||
}
|
||||
button.danger {
|
||||
border-color: var(--text-dim);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
button.danger:hover {
|
||||
border-color: var(--neon-pink);
|
||||
background: var(--neon-pink);
|
||||
color: var(--bg-dark);
|
||||
box-shadow: 0 0 15px rgba(255, 42, 109, 0.5);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 2px;
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.badge.yes {
|
||||
background: rgba(5, 217, 232, 0.1);
|
||||
color: var(--neon-cyan);
|
||||
border: 1px solid var(--neon-cyan);
|
||||
text-shadow: 0 0 6px rgba(5, 217, 232, 0.5);
|
||||
}
|
||||
.badge.no {
|
||||
background: rgba(255, 42, 109, 0.1);
|
||||
color: var(--neon-pink);
|
||||
border: 1px solid var(--neon-pink);
|
||||
text-shadow: 0 0 6px rgba(255, 42, 109, 0.5);
|
||||
}
|
||||
|
||||
.missing-list { font-size: 0.85rem; color: var(--text-dim); }
|
||||
|
||||
label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.flash {
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0.5rem 0;
|
||||
border-radius: 2px;
|
||||
background: rgba(5, 217, 232, 0.1);
|
||||
color: var(--neon-cyan);
|
||||
border: 1px solid var(--neon-cyan);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,20 +0,0 @@
|
||||
<!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>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</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>
|
||||
@@ -1,17 +0,0 @@
|
||||
{% 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="if(event.detail.successful) this.reset()">
|
||||
<label>Name <input type="text" name="name" 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>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 %}
|
||||
@@ -1,24 +0,0 @@
|
||||
{% 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="if(event.detail.successful) 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" min="0.01" required></label>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
|
||||
<div id="ingredient-list">
|
||||
{% include "partials/ingredient_rows.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,15 +0,0 @@
|
||||
{% 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="if(event.detail.successful) 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 %}
|
||||
@@ -1,16 +0,0 @@
|
||||
<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>
|
||||
@@ -1,21 +0,0 @@
|
||||
<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" min="0" 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>
|
||||
@@ -1,15 +0,0 @@
|
||||
<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>
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
pkgs,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
networking.firewall.allowedTCPPorts = [ 8001 ];
|
||||
|
||||
users = {
|
||||
users.vaninventory = {
|
||||
isSystemUser = true;
|
||||
group = "vaninventory";
|
||||
};
|
||||
groups.vaninventory = { };
|
||||
};
|
||||
|
||||
systemd.services.van_inventory = {
|
||||
description = "Van Inventory API";
|
||||
after = [
|
||||
"network.target"
|
||||
"postgresql.service"
|
||||
];
|
||||
requires = [ "postgresql.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
environment = {
|
||||
PYTHONPATH = "${inputs.self}/";
|
||||
VAN_INVENTORY_DB = "vaninventory";
|
||||
VAN_INVENTORY_USER = "vaninventory";
|
||||
VAN_INVENTORY_HOST = "/run/postgresql";
|
||||
VAN_INVENTORY_PORT = "5432";
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = "vaninventory";
|
||||
Group = "vaninventory";
|
||||
ExecStart = "${pkgs.my_python}/bin/python -m python.van_inventory.main --host 0.0.0.0 --port 8001";
|
||||
Restart = "on-failure";
|
||||
RestartSec = "5s";
|
||||
StandardOutput = "journal";
|
||||
StandardError = "journal";
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = "read-only";
|
||||
PrivateTmp = true;
|
||||
ReadOnlyPaths = [ "${inputs.self}" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user