Public Access
Compare commits
2
Commits
18d7300b57
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7838c49721 | ||
|
|
7f5757094e |
@@ -33,3 +33,5 @@ SECRET_KEY=change-me-to-a-random-secret-key
|
|||||||
# Auth
|
# Auth
|
||||||
ADMIN_TOKEN=change-me-to-a-random-admin-token
|
ADMIN_TOKEN=change-me-to-a-random-admin-token
|
||||||
SESSION_PASSWORD=change-me-to-the-family-shared-password
|
SESSION_PASSWORD=change-me-to-the-family-shared-password
|
||||||
|
SESSION_COOKIE_SECURE=false
|
||||||
|
TRUSTED_NETWORK_AUTO_AUTH=true
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)):
|
|||||||
value=cookie_value,
|
value=cookie_value,
|
||||||
max_age=SESSION_MAX_AGE,
|
max_age=SESSION_MAX_AGE,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=True,
|
secure=settings.SESSION_COOKIE_SECURE,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
path="/",
|
path="/",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from app.config import settings
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import FamilyProfile, MealPlan, MealPlanItem, MealType, Recipe
|
from app.models import FamilyProfile, MealPlan, MealPlanItem, MealType, Recipe
|
||||||
from app.security import require_session
|
from app.security import require_session
|
||||||
|
from app.services.meal_pairings import components_with_suggested_sides
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -257,6 +258,10 @@ def synthesize_plan(
|
|||||||
raw_picks = _parse_picks(raw or "")
|
raw_picks = _parse_picks(raw or "")
|
||||||
valid_recipe_ids = {r["id"] for r in library}
|
valid_recipe_ids = {r["id"] for r in library}
|
||||||
picks = _validate_picks(raw_picks, valid_recipe_ids)
|
picks = _validate_picks(raw_picks, valid_recipe_ids)
|
||||||
|
recipe_by_id = {
|
||||||
|
str(r.id): r
|
||||||
|
for r in db.query(Recipe).filter(Recipe.id.in_([uuid.UUID(rid) for rid in valid_recipe_ids])).all()
|
||||||
|
}
|
||||||
logger.info(
|
logger.info(
|
||||||
"LLM plan: prompt=%d chars, raw_picks=%d, valid_picks=%d",
|
"LLM plan: prompt=%d chars, raw_picks=%d, valid_picks=%d",
|
||||||
len(payload.prompt), len(raw_picks), len(picks),
|
len(payload.prompt), len(raw_picks), len(picks),
|
||||||
@@ -279,6 +284,10 @@ def synthesize_plan(
|
|||||||
recipe_id=uuid.UUID(pick.recipe_id),
|
recipe_id=uuid.UUID(pick.recipe_id),
|
||||||
day_of_week=pick.day_of_week,
|
day_of_week=pick.day_of_week,
|
||||||
meal_type=MealType(pick.meal_type),
|
meal_type=MealType(pick.meal_type),
|
||||||
|
components=components_with_suggested_sides(
|
||||||
|
recipe_by_id.get(pick.recipe_id),
|
||||||
|
pick.meal_type,
|
||||||
|
),
|
||||||
))
|
))
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
@@ -310,6 +319,10 @@ def synthesize_plan(
|
|||||||
recipe_id=uuid.UUID(chosen["id"]),
|
recipe_id=uuid.UUID(chosen["id"]),
|
||||||
day_of_week=day,
|
day_of_week=day,
|
||||||
meal_type=MealType(mt),
|
meal_type=MealType(mt),
|
||||||
|
components=components_with_suggested_sides(
|
||||||
|
recipe_by_id.get(chosen["id"]),
|
||||||
|
mt,
|
||||||
|
),
|
||||||
))
|
))
|
||||||
used_recipe_ids.add(uuid.UUID(chosen["id"]))
|
used_recipe_ids.add(uuid.UUID(chosen["id"]))
|
||||||
filled_count += 1
|
filled_count += 1
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ admin_router = APIRouter(
|
|||||||
public_router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"])
|
public_router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"])
|
||||||
|
|
||||||
|
|
||||||
|
def _components_payload(components: dict | None) -> dict:
|
||||||
|
return dict(components or {})
|
||||||
|
|
||||||
|
|
||||||
def _to_response(week_start, plan_id, items: List[MealPlanItem], result: GenerationResult) -> GenerationResponse:
|
def _to_response(week_start, plan_id, items: List[MealPlanItem], result: GenerationResult) -> GenerationResponse:
|
||||||
item_payloads: List[GenerationItem] = []
|
item_payloads: List[GenerationItem] = []
|
||||||
score_by_recipe = {s.recipe_id: s for s in result.selected}
|
score_by_recipe = {s.recipe_id: s for s in result.selected}
|
||||||
@@ -41,7 +45,7 @@ def _to_response(week_start, plan_id, items: List[MealPlanItem], result: Generat
|
|||||||
day_of_week=it.day_of_week,
|
day_of_week=it.day_of_week,
|
||||||
estimated_cost=it.estimated_cost or 0,
|
estimated_cost=it.estimated_cost or 0,
|
||||||
score=scored.score if scored else 0.0,
|
score=scored.score if scored else 0.0,
|
||||||
components={k: float(v) for k, v in (scored.components.items() if scored else [])},
|
components=_components_payload(it.components or (scored.components if scored else None)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return GenerationResponse(
|
return GenerationResponse(
|
||||||
@@ -135,7 +139,7 @@ def get_plan(plan_id: UUID, db: Session = Depends(get_db)) -> GenerationResponse
|
|||||||
day_of_week=it.day_of_week,
|
day_of_week=it.day_of_week,
|
||||||
estimated_cost=it.estimated_cost or 0,
|
estimated_cost=it.estimated_cost or 0,
|
||||||
score=it.score or 0.0,
|
score=it.score or 0.0,
|
||||||
components={k: float(v) for k, v in (it.components.items() if it.components else [])},
|
components=_components_payload(it.components),
|
||||||
)
|
)
|
||||||
for it in items
|
for it in items
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.schemas import (
|
|||||||
from app.security import require_session
|
from app.security import require_session
|
||||||
from app.services import approval as approval_service
|
from app.services import approval as approval_service
|
||||||
from app.services.feedback_analyzer import FeedbackAnalyzer
|
from app.services.feedback_analyzer import FeedbackAnalyzer
|
||||||
|
from app.services.meal_pairings import components_with_suggested_sides
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -683,6 +684,7 @@ def generate_single_item(
|
|||||||
day_of_week=day_of_week,
|
day_of_week=day_of_week,
|
||||||
meal_type=MealType[meal_type.upper()],
|
meal_type=MealType[meal_type.upper()],
|
||||||
approval_status=MealPlanItemStatus.pending,
|
approval_status=MealPlanItemStatus.pending,
|
||||||
|
components=components_with_suggested_sides(recipe, meal_type),
|
||||||
)
|
)
|
||||||
db.add(new_item)
|
db.add(new_item)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -769,6 +771,7 @@ def fill_empty_slots(
|
|||||||
day_of_week=day,
|
day_of_week=day,
|
||||||
meal_type=MealType[mt.upper()],
|
meal_type=MealType[mt.upper()],
|
||||||
approval_status=MealPlanItemStatus.pending,
|
approval_status=MealPlanItemStatus.pending,
|
||||||
|
components=components_with_suggested_sides(recipe, mt),
|
||||||
)
|
)
|
||||||
db.add(new_item)
|
db.add(new_item)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class Settings(BaseSettings):
|
|||||||
# Auth (R1-B+D)
|
# Auth (R1-B+D)
|
||||||
ADMIN_TOKEN: str = ""
|
ADMIN_TOKEN: str = ""
|
||||||
SESSION_PASSWORD: str = ""
|
SESSION_PASSWORD: str = ""
|
||||||
|
SESSION_COOKIE_SECURE: bool = True
|
||||||
|
TRUSTED_NETWORK_AUTO_AUTH: bool = False
|
||||||
ADMIN_EMAIL: str = ""
|
ADMIN_EMAIL: str = ""
|
||||||
APP_BASE_URL: str = "http://localhost"
|
APP_BASE_URL: str = "http://localhost"
|
||||||
|
|
||||||
|
|||||||
+28
-1
@@ -1,9 +1,11 @@
|
|||||||
from fastapi import FastAPI, Depends
|
from fastapi import FastAPI, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.security import require_session
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logging.basicConfig(level=settings.LOG_LEVEL)
|
logging.basicConfig(level=settings.LOG_LEVEL)
|
||||||
@@ -19,6 +21,31 @@ app = FastAPI(
|
|||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
def _requires_session(path: str, method: str) -> bool:
|
||||||
|
if method == "OPTIONS" or not path.startswith("/api/"):
|
||||||
|
return False
|
||||||
|
if path.startswith("/api/auth/") or path.startswith("/api/admin/"):
|
||||||
|
return False
|
||||||
|
# Email approval links carry their own signed, single-use token.
|
||||||
|
if path.startswith("/api/meals/vote/"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def require_family_session(request: Request, call_next):
|
||||||
|
if _requires_session(request.url.path, request.method):
|
||||||
|
try:
|
||||||
|
require_session(request)
|
||||||
|
except HTTPException as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={"detail": exc.detail},
|
||||||
|
headers=getattr(exc, "headers", None),
|
||||||
|
)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health_check(db: Session = Depends(get_db)):
|
def health_check(db: Session = Depends(get_db)):
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ class MealPlanItemResponse(MealPlanItemBase):
|
|||||||
denial_expires_at: Optional[datetime] = None
|
denial_expires_at: Optional[datetime] = None
|
||||||
used_pantry_items: Optional[List[UUID]] = []
|
used_pantry_items: Optional[List[UUID]] = []
|
||||||
score: Optional[float] = None
|
score: Optional[float] = None
|
||||||
components: Optional[Dict[str, float]] = None
|
components: Optional[Dict[str, Any]] = None
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
recipe: Optional[RecipeResponse] = None
|
recipe: Optional[RecipeResponse] = None
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -27,7 +27,7 @@ class GenerationItem(BaseModel):
|
|||||||
day_of_week: int
|
day_of_week: int
|
||||||
estimated_cost: Decimal
|
estimated_cost: Decimal
|
||||||
score: float
|
score: float
|
||||||
components: Dict[str, float]
|
components: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
class GenerationDebug(BaseModel):
|
class GenerationDebug(BaseModel):
|
||||||
|
|||||||
+19
-25
@@ -1,17 +1,6 @@
|
|||||||
"""
|
"""Auth dependencies for the MealPlanner backend."""
|
||||||
Auth dependencies for the MealPlanner backend.
|
|
||||||
|
|
||||||
Two flavors:
|
import secrets
|
||||||
- ``require_admin`` — bearer token for ``/api/admin/*`` routes; token compared
|
|
||||||
to ``settings.ADMIN_TOKEN`` (must be set in env).
|
|
||||||
- ``require_session`` — auto-returns the first family_profile_id (no login
|
|
||||||
required). This app runs on a private home network so auth is disabled
|
|
||||||
for family-facing routes. Kept as a dependency so admin/token endpoints
|
|
||||||
can be re-enabled later by restoring cookie logic.
|
|
||||||
|
|
||||||
The per-voter approval-token flow on meal items is intentionally NOT covered
|
|
||||||
here — it has its own short-lived single-use tokens elsewhere.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import HTTPException, Request, status
|
from fastapi import HTTPException, Request, status
|
||||||
from itsdangerous import TimestampSigner
|
from itsdangerous import TimestampSigner
|
||||||
@@ -35,7 +24,7 @@ def require_admin(request: Request) -> None:
|
|||||||
detail="Admin auth not configured",
|
detail="Admin auth not configured",
|
||||||
)
|
)
|
||||||
auth = request.headers.get(bearer_header, "")
|
auth = request.headers.get(bearer_header, "")
|
||||||
if not auth.startswith("Bearer ") or auth[7:] != expected:
|
if not auth.startswith("Bearer ") or not secrets.compare_digest(auth[7:], expected):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid admin token",
|
detail="Invalid admin token",
|
||||||
@@ -52,13 +41,7 @@ def issue_session(family_profile_id: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def require_session(request: Request) -> str:
|
def require_session(request: Request) -> str:
|
||||||
"""Auto-authenticate: return the first family_profile_id from the DB.
|
"""Require a signed session cookie, with explicit LAN auto-auth opt-in."""
|
||||||
|
|
||||||
No cookie or password needed — this app runs on a private home network.
|
|
||||||
If no FamilyProfile exists yet, return \"bootstrap\" so the app can
|
|
||||||
initialise itself on first run.
|
|
||||||
"""
|
|
||||||
# 1. Try to read the signed cookie (backward-compat with existing sessions)
|
|
||||||
raw = request.cookies.get(SESSION_COOKIE)
|
raw = request.cookies.get(SESSION_COOKIE)
|
||||||
if raw:
|
if raw:
|
||||||
try:
|
try:
|
||||||
@@ -66,13 +49,24 @@ def require_session(request: Request) -> str:
|
|||||||
_signer().unsign(raw.encode(), max_age=SESSION_MAX_AGE).decode()
|
_signer().unsign(raw.encode(), max_age=SESSION_MAX_AGE).decode()
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # fall through to auto-auth
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired session",
|
||||||
|
)
|
||||||
|
|
||||||
# 2. Auto-auth: grab the first family profile from the DB
|
if not settings.TRUSTED_NETWORK_AUTO_AUTH:
|
||||||
db = next(get_db())
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Session required",
|
||||||
|
)
|
||||||
|
|
||||||
|
db_gen = get_db()
|
||||||
|
db = next(db_gen)
|
||||||
|
try:
|
||||||
profile = db.query(FamilyProfile).first()
|
profile = db.query(FamilyProfile).first()
|
||||||
if profile:
|
if profile:
|
||||||
return str(profile.id)
|
return str(profile.id)
|
||||||
|
finally:
|
||||||
|
db_gen.close()
|
||||||
|
|
||||||
# 3. Bootstrap hatch — no profile yet, return a sentinel value
|
|
||||||
return "bootstrap"
|
return "bootstrap"
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from app.models import MealType
|
||||||
|
|
||||||
|
|
||||||
|
_COMPLETE_MEAL_TERMS = {
|
||||||
|
"bowl", "burger", "burrito", "casserole", "chili", "curry", "fried rice",
|
||||||
|
"lasagna", "noodle", "paella", "pasta", "pizza", "quesadilla", "ramen",
|
||||||
|
"rice bowl", "risotto", "salad", "sandwich", "soup", "spaghetti", "stew",
|
||||||
|
"stir fry", "stir-fry", "taco", "tacos", "wrap",
|
||||||
|
}
|
||||||
|
|
||||||
|
_CARB_TERMS = {
|
||||||
|
"bread", "bun", "couscous", "farro", "grain", "noodle", "orzo", "pasta",
|
||||||
|
"pita", "potato", "quinoa", "rice", "tortilla",
|
||||||
|
}
|
||||||
|
|
||||||
|
_VEG_TERMS = {
|
||||||
|
"asparagus", "beans", "broccoli", "brussels", "cabbage", "carrot",
|
||||||
|
"cauliflower", "corn", "greens", "kale", "pepper", "salad", "spinach",
|
||||||
|
"vegetable", "zucchini",
|
||||||
|
}
|
||||||
|
|
||||||
|
_PROTEIN_TERMS = {
|
||||||
|
"beef", "breast", "chicken", "chop", "cod", "cutlet", "fish", "pork",
|
||||||
|
"salmon", "shrimp", "steak", "tilapia", "tofu", "turkey",
|
||||||
|
}
|
||||||
|
|
||||||
|
_PAIRINGS_BY_CUISINE = {
|
||||||
|
"asian": ("sesame broccoli", "steamed jasmine rice"),
|
||||||
|
"chinese": ("garlic green beans", "steamed jasmine rice"),
|
||||||
|
"indian": ("roasted cauliflower", "basmati rice"),
|
||||||
|
"italian": ("garlicky green beans", "orzo or crusty bread"),
|
||||||
|
"mediterranean": ("cucumber tomato salad", "warm pita or couscous"),
|
||||||
|
"mexican": ("sauteed peppers and onions", "cilantro lime rice"),
|
||||||
|
"thai": ("cucumber salad", "steamed jasmine rice"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_DEFAULT_PAIRING = ("roasted broccoli", "rice pilaf or roasted potatoes")
|
||||||
|
|
||||||
|
|
||||||
|
def components_with_suggested_sides(
|
||||||
|
recipe: Any,
|
||||||
|
meal_type: MealType | str,
|
||||||
|
base_components: Mapping[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
components = dict(base_components or {})
|
||||||
|
suggestion = suggest_sides_for_recipe(recipe, meal_type)
|
||||||
|
if suggestion:
|
||||||
|
components["suggested_sides"] = suggestion
|
||||||
|
return components
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_sides_for_recipe(recipe: Any, meal_type: MealType | str) -> dict[str, Any] | None:
|
||||||
|
meal_value = meal_type.value if isinstance(meal_type, MealType) else str(meal_type).lower()
|
||||||
|
if meal_value == MealType.BREAKFAST.value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
explicit_sides = [
|
||||||
|
side.get("name")
|
||||||
|
for side in (getattr(recipe, "side_dishes", None) or [])
|
||||||
|
if isinstance(side, dict) and side.get("name")
|
||||||
|
]
|
||||||
|
if explicit_sides:
|
||||||
|
return {
|
||||||
|
"needed": True,
|
||||||
|
"items": explicit_sides[:2],
|
||||||
|
"note": "Use the recipe's recommended side dish pairing.",
|
||||||
|
}
|
||||||
|
|
||||||
|
text = _recipe_text(recipe)
|
||||||
|
if not _looks_like_simple_protein(recipe, text):
|
||||||
|
return None
|
||||||
|
|
||||||
|
vegetable, carb = _pairing_for_cuisine(getattr(recipe, "cuisine_tags", None) or [])
|
||||||
|
return {
|
||||||
|
"needed": True,
|
||||||
|
"vegetable": vegetable,
|
||||||
|
"carb": carb,
|
||||||
|
"note": "Simple protein entree; add a vegetable and carb to make it a complete meal.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _recipe_text(recipe: Any) -> str:
|
||||||
|
parts = [
|
||||||
|
getattr(recipe, "name", "") or "",
|
||||||
|
getattr(recipe, "protein_type", "") or "",
|
||||||
|
" ".join(getattr(recipe, "cuisine_tags", None) or []),
|
||||||
|
]
|
||||||
|
for ingredient in getattr(recipe, "ingredients", None) or []:
|
||||||
|
if isinstance(ingredient, dict):
|
||||||
|
parts.append(str(ingredient.get("name") or ingredient.get("ingredient") or ""))
|
||||||
|
return " ".join(parts).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_simple_protein(recipe: Any, text: str) -> bool:
|
||||||
|
if any(term in text for term in _COMPLETE_MEAL_TERMS):
|
||||||
|
return False
|
||||||
|
has_protein = bool(getattr(recipe, "protein_type", None)) or any(
|
||||||
|
term in text for term in _PROTEIN_TERMS
|
||||||
|
)
|
||||||
|
if not has_protein:
|
||||||
|
return False
|
||||||
|
has_carb = any(term in text for term in _CARB_TERMS)
|
||||||
|
has_veg = any(term in text for term in _VEG_TERMS)
|
||||||
|
return not (has_carb and has_veg)
|
||||||
|
|
||||||
|
|
||||||
|
def _pairing_for_cuisine(tags: list[str]) -> tuple[str, str]:
|
||||||
|
lowered = {tag.lower() for tag in tags}
|
||||||
|
for key, pairing in _PAIRINGS_BY_CUISINE.items():
|
||||||
|
if key in lowered:
|
||||||
|
return pairing
|
||||||
|
return _DEFAULT_PAIRING
|
||||||
@@ -36,6 +36,26 @@ if TYPE_CHECKING:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _suggested_sides_html(components: dict | None) -> str:
|
||||||
|
sides = (components or {}).get("suggested_sides")
|
||||||
|
if not isinstance(sides, dict):
|
||||||
|
return ""
|
||||||
|
items = sides.get("items")
|
||||||
|
if isinstance(items, list) and items:
|
||||||
|
text = "Pair with " + " + ".join(str(item) for item in items[:2])
|
||||||
|
else:
|
||||||
|
pair = [sides.get("vegetable"), sides.get("carb")]
|
||||||
|
pair = [str(part) for part in pair if part]
|
||||||
|
if not pair:
|
||||||
|
return ""
|
||||||
|
text = "Add " + " + ".join(pair)
|
||||||
|
return (
|
||||||
|
"<p style='font-size:13px;background:#ecfdf5;color:#065f46;"
|
||||||
|
"padding:8px;border-radius:6px;margin:8px 0'>"
|
||||||
|
f"{html.escape(text)}</p>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def step_scrape(run: "WeeklyRun", db: "Session") -> None:
|
def step_scrape(run: "WeeklyRun", db: "Session") -> None:
|
||||||
if run.scraped_at is not None:
|
if run.scraped_at is not None:
|
||||||
logger.info("step_scrape: already done for %s, skipping", run.week_start_date)
|
logger.info("step_scrape: already done for %s, skipping", run.week_start_date)
|
||||||
@@ -273,6 +293,7 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
|
|||||||
f"<p style='font-size:13px;color:#888'>Est. ~${est_cost_per_serving:.2f}/serving</p>"
|
f"<p style='font-size:13px;color:#888'>Est. ~${est_cost_per_serving:.2f}/serving</p>"
|
||||||
if est_cost_total > 0 else ""
|
if est_cost_total > 0 else ""
|
||||||
)
|
)
|
||||||
|
sides_block = _suggested_sides_html(item.components)
|
||||||
|
|
||||||
item_html_parts.append(
|
item_html_parts.append(
|
||||||
f'<div style="border:1px solid #e5e7eb;border-radius:8px;padding:16px;margin-bottom:12px">'
|
f'<div style="border:1px solid #e5e7eb;border-radius:8px;padding:16px;margin-bottom:12px">'
|
||||||
@@ -280,6 +301,7 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
|
|||||||
f'<h3 style="margin:8px 0 4px">{recipe_name}</h3>'
|
f'<h3 style="margin:8px 0 4px">{recipe_name}</h3>'
|
||||||
f'{ing_block}'
|
f'{ing_block}'
|
||||||
f'{instructions_block}'
|
f'{instructions_block}'
|
||||||
|
f'{sides_block}'
|
||||||
f'{cost_block}'
|
f'{cost_block}'
|
||||||
f'<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px">'
|
f'<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px">'
|
||||||
f'<a href="{vote_url}&scope=approve" style="display:inline-block;padding:8px 14px;'
|
f'<a href="{vote_url}&scope=approve" style="display:inline-block;padding:8px 14px;'
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from app.services.planner.filter import filter_recipes
|
|||||||
from app.services.planner.score import score_recipes
|
from app.services.planner.score import score_recipes
|
||||||
from app.services.planner.select import select_set
|
from app.services.planner.select import select_set
|
||||||
from app.services.planner.types import GenerationResult
|
from app.services.planner.types import GenerationResult
|
||||||
|
from app.services.meal_pairings import components_with_suggested_sides
|
||||||
|
|
||||||
|
|
||||||
def _load_match_index(db: Session) -> Dict[UUID, List[dict]]:
|
def _load_match_index(db: Session) -> Dict[UUID, List[dict]]:
|
||||||
@@ -234,9 +235,11 @@ def generate_meal_plan(
|
|||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
_dinner_days = [1, 3, 5] # Mon, Wed, Fri — spread across the week
|
_dinner_days = [1, 3, 5] # Mon, Wed, Fri — spread across the week
|
||||||
|
recipe_by_id = {recipe.id: recipe for recipe in recipes}
|
||||||
for index, scored_recipe in enumerate(chosen):
|
for index, scored_recipe in enumerate(chosen):
|
||||||
day = _dinner_days[index] if index < len(_dinner_days) else index + 1
|
day = _dinner_days[index] if index < len(_dinner_days) else index + 1
|
||||||
meal_type = MealType.DINNER
|
meal_type = MealType.DINNER
|
||||||
|
recipe = recipe_by_id.get(scored_recipe.recipe_id)
|
||||||
item = MealPlanItem(
|
item = MealPlanItem(
|
||||||
meal_plan_id=plan.id,
|
meal_plan_id=plan.id,
|
||||||
recipe_id=scored_recipe.recipe_id,
|
recipe_id=scored_recipe.recipe_id,
|
||||||
@@ -245,7 +248,11 @@ def generate_meal_plan(
|
|||||||
approval_status=MealPlanItemStatus.pending,
|
approval_status=MealPlanItemStatus.pending,
|
||||||
estimated_cost=scored_recipe.cost.total_cost,
|
estimated_cost=scored_recipe.cost.total_cost,
|
||||||
score=scored_recipe.score,
|
score=scored_recipe.score,
|
||||||
components=scored_recipe.components,
|
components=components_with_suggested_sides(
|
||||||
|
recipe,
|
||||||
|
meal_type,
|
||||||
|
scored_recipe.components,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
db.add(item)
|
db.add(item)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import pytest
|
|||||||
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
|
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
|
||||||
os.environ.setdefault("SESSION_PASSWORD", "test-family-password")
|
os.environ.setdefault("SESSION_PASSWORD", "test-family-password")
|
||||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-do-not-use-in-prod")
|
os.environ.setdefault("SECRET_KEY", "test-secret-key-do-not-use-in-prod")
|
||||||
|
os.environ.setdefault("SESSION_COOKIE_SECURE", "false")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -27,6 +28,7 @@ def _reload_settings(monkeypatch):
|
|||||||
monkeypatch.setenv("ADMIN_TOKEN", "test-admin-token")
|
monkeypatch.setenv("ADMIN_TOKEN", "test-admin-token")
|
||||||
monkeypatch.setenv("SESSION_PASSWORD", "test-family-password")
|
monkeypatch.setenv("SESSION_PASSWORD", "test-family-password")
|
||||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key-do-not-use-in-prod")
|
monkeypatch.setenv("SECRET_KEY", "test-secret-key-do-not-use-in-prod")
|
||||||
|
monkeypatch.setenv("SESSION_COOKIE_SECURE", "false")
|
||||||
# Re-instantiate the singleton so dependents pick up env.
|
# Re-instantiate the singleton so dependents pick up env.
|
||||||
from app import config as app_config
|
from app import config as app_config
|
||||||
|
|
||||||
@@ -76,11 +78,10 @@ def test_session_required_for_mutation(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.requires_postgres
|
@pytest.mark.requires_postgres
|
||||||
def test_session_open_for_reads(client):
|
def test_session_required_for_reads(client):
|
||||||
"""GET /api/profile is NOT auth-gated (reads stay open)."""
|
"""GET /api/profile requires a session for non-LAN exposure."""
|
||||||
r = client.get("/api/profile")
|
r = client.get("/api/profile")
|
||||||
# Either 200 (profile exists) or 404 (no profile yet) — never 401.
|
assert r.status_code == 401, r.text
|
||||||
assert r.status_code in (200, 404), r.text
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.models import MealType
|
||||||
|
from app.services.meal_pairings import components_with_suggested_sides, suggest_sides_for_recipe
|
||||||
|
|
||||||
|
|
||||||
|
def _recipe(**kwargs):
|
||||||
|
defaults = {
|
||||||
|
"name": "Grilled Chicken Breast",
|
||||||
|
"protein_type": "chicken",
|
||||||
|
"cuisine_tags": [],
|
||||||
|
"ingredients": [],
|
||||||
|
"side_dishes": [],
|
||||||
|
}
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return SimpleNamespace(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def test_simple_protein_gets_vegetable_and_carb_pairing():
|
||||||
|
sides = suggest_sides_for_recipe(_recipe(), MealType.DINNER)
|
||||||
|
|
||||||
|
assert sides is not None
|
||||||
|
assert sides["vegetable"] == "roasted broccoli"
|
||||||
|
assert sides["carb"] == "rice pilaf or roasted potatoes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_meal_does_not_get_extra_pairing():
|
||||||
|
recipe = _recipe(name="Chicken Pasta Bake", protein_type="chicken")
|
||||||
|
|
||||||
|
assert suggest_sides_for_recipe(recipe, MealType.DINNER) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_breakfast_does_not_get_side_pairing():
|
||||||
|
recipe = _recipe(name="Turkey Sausage", protein_type="turkey")
|
||||||
|
|
||||||
|
assert suggest_sides_for_recipe(recipe, MealType.BREAKFAST) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_recipe_side_dishes_are_used_when_present():
|
||||||
|
recipe = _recipe(side_dishes=[{"name": "green salad"}, {"name": "garlic bread"}])
|
||||||
|
|
||||||
|
sides = suggest_sides_for_recipe(recipe, "dinner")
|
||||||
|
|
||||||
|
assert sides is not None
|
||||||
|
assert sides["items"] == ["green salad", "garlic bread"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_components_preserve_existing_scores():
|
||||||
|
components = components_with_suggested_sides(
|
||||||
|
_recipe(),
|
||||||
|
MealType.DINNER,
|
||||||
|
{"savings": 0.25},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert components["savings"] == 0.25
|
||||||
|
assert components["suggested_sides"]["needed"] is True
|
||||||
@@ -25,6 +25,8 @@ services:
|
|||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
- ADMIN_TOKEN=${ADMIN_TOKEN}
|
- ADMIN_TOKEN=${ADMIN_TOKEN}
|
||||||
- SESSION_PASSWORD=${SESSION_PASSWORD}
|
- SESSION_PASSWORD=${SESSION_PASSWORD}
|
||||||
|
- SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-false}
|
||||||
|
- TRUSTED_NETWORK_AUTO_AUTH=${TRUSTED_NETWORK_AUTO_AUTH:-true}
|
||||||
- EMAIL_BACKEND=${EMAIL_BACKEND:-console}
|
- EMAIL_BACKEND=${EMAIL_BACKEND:-console}
|
||||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-}
|
- ADMIN_EMAIL=${ADMIN_EMAIL:-}
|
||||||
- APP_BASE_URL=${APP_BASE_URL:-http://localhost}
|
- APP_BASE_URL=${APP_BASE_URL:-http://localhost}
|
||||||
@@ -63,6 +65,8 @@ services:
|
|||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
- ADMIN_TOKEN=${ADMIN_TOKEN}
|
- ADMIN_TOKEN=${ADMIN_TOKEN}
|
||||||
- SESSION_PASSWORD=${SESSION_PASSWORD}
|
- SESSION_PASSWORD=${SESSION_PASSWORD}
|
||||||
|
- SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-false}
|
||||||
|
- TRUSTED_NETWORK_AUTO_AUTH=${TRUSTED_NETWORK_AUTO_AUTH:-true}
|
||||||
- EMAIL_BACKEND=${EMAIL_BACKEND:-console}
|
- EMAIL_BACKEND=${EMAIL_BACKEND:-console}
|
||||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-}
|
- ADMIN_EMAIL=${ADMIN_EMAIL:-}
|
||||||
- APP_BASE_URL=${APP_BASE_URL:-http://localhost}
|
- APP_BASE_URL=${APP_BASE_URL:-http://localhost}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Home Assistant Ingress
|
||||||
|
|
||||||
|
MealPlanner can be exposed through Home Assistant by installing the `mealplanner-ingress` add-on. The add-on is an authenticated Ingress proxy to the existing MealPlanner Docker deployment; it does not run the database, backend, or frontend itself.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
- In Home Assistant, go to Settings -> Add-ons -> Add-on Store -> Repositories.
|
||||||
|
- Add this repository URL: `https://git.research.bike/admin/Meal-Planner.git`.
|
||||||
|
- Install `MealPlanner Ingress` from the add-on store.
|
||||||
|
- Set `upstream_url` to the LAN URL for the existing MealPlanner nginx service, for example `http://192.168.1.54:8082`.
|
||||||
|
- Start the add-on and open the `MealPlanner` sidebar item.
|
||||||
|
|
||||||
|
## Required MealPlanner Env
|
||||||
|
|
||||||
|
Set these in MealPlanner's `.env` before exposing it through Home Assistant:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ADMIN_TOKEN=<random-admin-token>
|
||||||
|
SESSION_PASSWORD=<family-shared-password>
|
||||||
|
SECRET_KEY=<random-secret>
|
||||||
|
SESSION_COOKIE_SECURE=false
|
||||||
|
TRUSTED_NETWORK_AUTO_AUTH=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Home Assistant is the authentication boundary in this setup. `TRUSTED_NETWORK_AUTO_AUTH=true` removes the extra MealPlanner password prompt, so do not port-forward MealPlanner directly.
|
||||||
|
|
||||||
|
## Network Model
|
||||||
|
|
||||||
|
- Public Internet -> Home Assistant auth/MFA -> Ingress -> MealPlanner LAN URL.
|
||||||
|
- Do not port-forward MealPlanner directly.
|
||||||
|
- Keep the existing MealPlanner compose stack bound to the LAN only.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- `could not read Username`: the Git repository is not anonymously cloneable from Home Assistant. Make the repository public, or use a separate public add-on repository.
|
||||||
|
- `not a valid app repository`: Home Assistant cloned the repository, but did not find valid add-on metadata. Confirm `repository.yaml` exists at the repository root and `mealplanner-ingress/config.yaml` exists on the default branch.
|
||||||
|
- Short/clipped display: do not use an embedded WebURL card for this app. Use the `MealPlanner Ingress` add-on sidebar item so Home Assistant proxies the full UI.
|
||||||
@@ -6,6 +6,7 @@ import { OnboardingTour, useOnboarding } from './components/OnboardingTour'
|
|||||||
import { showApiError } from './lib/toast'
|
import { showApiError } from './lib/toast'
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
import { requestFocusSearch } from './hooks/useFocusSearch'
|
import { requestFocusSearch } from './hooks/useFocusSearch'
|
||||||
|
import { APP_BASE_PATH } from './api'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import MealDetail from './pages/MealDetail'
|
import MealDetail from './pages/MealDetail'
|
||||||
import Pantry from './pages/Pantry'
|
import Pantry from './pages/Pantry'
|
||||||
@@ -81,7 +82,7 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter basename={APP_BASE_PATH || undefined}>
|
||||||
<GlobalShortcuts />
|
<GlobalShortcuts />
|
||||||
<div className="min-h-screen bg-surface-50">
|
<div className="min-h-screen bg-surface-50">
|
||||||
<Navigation />
|
<Navigation />
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_URL || '/api'
|
export function getIngressBasePath() {
|
||||||
|
const parts = window.location.pathname.split('/').filter(Boolean)
|
||||||
|
if (parts[0] === 'api' && parts[1] === 'hassio_ingress' && parts[2]) {
|
||||||
|
return `/${parts.slice(0, 3).join('/')}`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const APP_BASE_PATH = getIngressBasePath()
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || `${APP_BASE_PATH}/api`
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: API_BASE,
|
baseURL: API_BASE,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
type DroppableStateSnapshot,
|
type DroppableStateSnapshot,
|
||||||
} from '@hello-pangea/dnd'
|
} from '@hello-pangea/dnd'
|
||||||
import { mealPlannerApi } from '../api'
|
import { mealPlannerApi } from '../api'
|
||||||
import type { MealPlan, MealPlanItem } from '../types'
|
import type { MealPlan, MealPlanItem, SuggestedSides } from '../types'
|
||||||
import { Badge } from '../components/ui/Badge'
|
import { Badge } from '../components/ui/Badge'
|
||||||
import { Card, CardBody, CardHeader } from '../components/ui/Card'
|
import { Card, CardBody, CardHeader } from '../components/ui/Card'
|
||||||
import { SkeletonCard, Skeleton } from '../components/ui/Skeleton'
|
import { SkeletonCard, Skeleton } from '../components/ui/Skeleton'
|
||||||
@@ -33,6 +33,18 @@ const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
|||||||
const FULL_DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
const FULL_DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||||
const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const
|
const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const
|
||||||
|
|
||||||
|
function getSuggestedSides(item: MealPlanItem): SuggestedSides | null {
|
||||||
|
const value = item.components?.suggested_sides
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
return value as SuggestedSides
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSuggestedSides(sides: SuggestedSides): string | null {
|
||||||
|
if (sides.items?.length) return `Pair with ${sides.items.join(' + ')}`
|
||||||
|
const pair = [sides.vegetable, sides.carb].filter(Boolean).join(' + ')
|
||||||
|
return pair ? `Add ${pair}` : null
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* MealCard (draggable) */
|
/* MealCard (draggable) */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -55,6 +67,8 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, on
|
|||||||
item.approval_status === 'denied' ? 'danger' :
|
item.approval_status === 'denied' ? 'danger' :
|
||||||
item.approval_status === 'swapped' ? 'warning' :
|
item.approval_status === 'swapped' ? 'warning' :
|
||||||
'neutral'
|
'neutral'
|
||||||
|
const suggestedSides = getSuggestedSides(item)
|
||||||
|
const sideText = suggestedSides ? formatSuggestedSides(suggestedSides) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative group block bg-surface-0 rounded-xl border overflow-hidden hover:shadow-md hover:border-primary-200 transition-all duration-200 ${isDragging ? 'shadow-lg border-primary-400 ring-2 ring-primary-200' : 'border-surface-200'}`}>
|
<div className={`relative group block bg-surface-0 rounded-xl border overflow-hidden hover:shadow-md hover:border-primary-200 transition-all duration-200 ${isDragging ? 'shadow-lg border-primary-400 ring-2 ring-primary-200' : 'border-surface-200'}`}>
|
||||||
@@ -100,6 +114,11 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, on
|
|||||||
{totalTime > 0 && `${totalTime} min · `}
|
{totalTime > 0 && `${totalTime} min · `}
|
||||||
{item.recipe?.servings} servings
|
{item.recipe?.servings} servings
|
||||||
</p>
|
</p>
|
||||||
|
{sideText && (
|
||||||
|
<p className="mt-1 rounded-lg bg-primary-50 px-2 py-1 text-[11px] leading-snug text-primary-800">
|
||||||
|
{sideText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-1.5 mt-1">
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
<Badge
|
<Badge
|
||||||
variant={statusVariant}
|
variant={statusVariant}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Lock, ArrowRight } from 'lucide-react'
|
import { Lock, ArrowRight } from 'lucide-react'
|
||||||
import { mealPlannerApi } from '../api'
|
import { APP_BASE_PATH, mealPlannerApi } from '../api'
|
||||||
import { Button } from '../components/ui/Button'
|
import { Button } from '../components/ui/Button'
|
||||||
import { Card, CardBody } from '../components/ui/Card'
|
import { Card, CardBody } from '../components/ui/Card'
|
||||||
import { Input } from '../components/ui/Input'
|
import { Input } from '../components/ui/Input'
|
||||||
@@ -16,7 +16,7 @@ export default function Login() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await mealPlannerApi.auth.login(password)
|
await mealPlannerApi.auth.login(password)
|
||||||
window.location.href = '/'
|
window.location.href = `${APP_BASE_PATH}/`
|
||||||
} catch {
|
} catch {
|
||||||
setError('Incorrect password. Try again.')
|
setError('Incorrect password. Try again.')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { Clock, Users, ChefHat, ArrowLeft, Printer, Star, AlertTriangle, MessageSquare } from 'lucide-react'
|
import { Clock, Users, ChefHat, ArrowLeft, Printer, Star, AlertTriangle, MessageSquare } from 'lucide-react'
|
||||||
import { mealPlannerApi } from '../api'
|
import { mealPlannerApi } from '../api'
|
||||||
import type { MealPlanItem, Feedback } from '../types'
|
import type { MealPlanItem, Feedback, SuggestedSides } from '../types'
|
||||||
import { Button } from '../components/ui/Button'
|
import { Button } from '../components/ui/Button'
|
||||||
import { Badge } from '../components/ui/Badge'
|
import { Badge } from '../components/ui/Badge'
|
||||||
import { Card, CardBody, CardHeader } from '../components/ui/Card'
|
import { Card, CardBody, CardHeader } from '../components/ui/Card'
|
||||||
@@ -22,6 +22,18 @@ const DENIAL_REASONS = [
|
|||||||
{ value: 'other', label: 'Other' },
|
{ value: 'other', label: 'Other' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function getSuggestedSides(item: MealPlanItem): SuggestedSides | null {
|
||||||
|
const value = item.components?.suggested_sides
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
return value as SuggestedSides
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSuggestedSides(sides: SuggestedSides): string | null {
|
||||||
|
if (sides.items?.length) return `Pair with ${sides.items.join(' + ')}.`
|
||||||
|
const pair = [sides.vegetable, sides.carb].filter(Boolean).join(' and ')
|
||||||
|
return pair ? `Add ${pair}.` : null
|
||||||
|
}
|
||||||
|
|
||||||
function StarRating({ value, onChange }: { value: number; onChange: (n: number) => void }) {
|
function StarRating({ value, onChange }: { value: number; onChange: (n: number) => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
@@ -158,6 +170,8 @@ export default function MealDetail() {
|
|||||||
|
|
||||||
const totalTime = recipe.total_time_minutes ??
|
const totalTime = recipe.total_time_minutes ??
|
||||||
(recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0)
|
(recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0)
|
||||||
|
const suggestedSides = getSuggestedSides(item)
|
||||||
|
const sideText = suggestedSides ? formatSuggestedSides(suggestedSides) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-5xl mx-auto">
|
<div className="space-y-6 max-w-5xl mx-auto">
|
||||||
@@ -240,6 +254,20 @@ export default function MealDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{sideText && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h2 className="text-lg font-semibold text-surface-900">Complete the meal</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<p className="text-sm text-surface-700">{sideText}</p>
|
||||||
|
{suggestedSides?.note && (
|
||||||
|
<p className="mt-2 text-xs text-surface-500">{suggestedSides.note}</p>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Ingredients */}
|
{/* Ingredients */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -62,6 +62,21 @@ export interface Recipe {
|
|||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
total_time_minutes?: number
|
total_time_minutes?: number
|
||||||
|
side_dishes?: SideDish[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuggestedSides {
|
||||||
|
needed?: boolean
|
||||||
|
vegetable?: string
|
||||||
|
carb?: string
|
||||||
|
items?: string[]
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SideDish {
|
||||||
|
name: string
|
||||||
|
ingredients?: Array<{ name: string; qty: number; unit?: string }>
|
||||||
|
prep_notes?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecipeIngredient {
|
export interface RecipeIngredient {
|
||||||
@@ -117,6 +132,8 @@ export interface MealPlanItem {
|
|||||||
approval_status: 'pending' | 'approved' | 'denied' | 'swapped'
|
approval_status: 'pending' | 'approved' | 'denied' | 'swapped'
|
||||||
denial_reason?: string
|
denial_reason?: string
|
||||||
denial_details?: string
|
denial_details?: string
|
||||||
|
score?: number | null
|
||||||
|
components?: Record<string, unknown> | null
|
||||||
estimated_cost?: number
|
estimated_cost?: number
|
||||||
used_pantry_items: string[]
|
used_pantry_items: string[]
|
||||||
recipe?: Recipe
|
recipe?: Recipe
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
base: './',
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Home Assistant Ingress Add-on
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Expose MealPlanner through Home Assistant Ingress while keeping MealPlanner off the public Internet.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
- [x] Add HA add-on metadata and nginx proxy wrapper -> Verify: `mealplanner-ingress/config.yaml`, `Dockerfile`, `run.sh` exist.
|
||||||
|
- [x] Make frontend path-prefix aware for Ingress -> Verify: Vite base, router basename, and API base derive from `/api/hassio_ingress/...`.
|
||||||
|
- [x] Re-enable session enforcement for family API routes -> Verify: missing session returns 401 unless `TRUSTED_NETWORK_AUTO_AUTH=true`.
|
||||||
|
- [x] Document install and env settings -> Verify: `docs/home-assistant-ingress.md` exists.
|
||||||
|
- [x] Run backend/frontend focused checks.
|
||||||
|
- [x] Push add-on repository metadata -> Verify: anonymous shallow clone contains `repository.yaml` and `mealplanner-ingress/config.yaml`.
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
- [x] Add-on config validates enough to build in Home Assistant.
|
||||||
|
- [x] Frontend builds.
|
||||||
|
- [x] Backend auth tests/import checks pass.
|
||||||
|
- [x] Home Assistant accepts the repository.
|
||||||
@@ -34,6 +34,20 @@ http {
|
|||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
|
||||||
|
add_header Pragma "no-cache" always;
|
||||||
|
add_header Expires "0" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
set $frontend_upstream http://frontend:80;
|
||||||
|
proxy_pass $frontend_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
|
||||||
|
add_header Pragma "no-cache" always;
|
||||||
|
add_header Expires "0" always;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user