Add Doris Kitchen household recipe app
This commit is contained in:
33
home/doris-kitchen/app/routes/api_dashboard.py
Normal file
33
home/doris-kitchen/app/routes/api_dashboard.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.services.repository import repo
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "backend": repo.backend}
|
||||
|
||||
|
||||
@router.get("/dashboard/summary")
|
||||
async def summary():
|
||||
profile = repo.get_profile()
|
||||
suggestions = repo.list_suggestions()
|
||||
plans = repo.list_meal_plans()
|
||||
counts = {
|
||||
"suggested": sum(1 for item in suggestions if item.get("status") == "suggested"),
|
||||
"approved": sum(1 for item in suggestions if item.get("status") == "approved"),
|
||||
"saved": sum(1 for item in suggestions if item.get("status") == "saved"),
|
||||
"rejected": sum(1 for item in suggestions if item.get("status") == "rejected"),
|
||||
"plans": len(plans),
|
||||
}
|
||||
return {
|
||||
"backend": repo.backend,
|
||||
"profile": profile,
|
||||
"counts": counts,
|
||||
"recent_suggestions": repo.recent_suggestions(),
|
||||
"recent_plans": list(reversed(plans[-4:])),
|
||||
}
|
||||
|
||||
26
home/doris-kitchen/app/routes/api_planner.py
Normal file
26
home/doris-kitchen/app/routes/api_planner.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.services.planner import planner
|
||||
from app.services.repository import repo
|
||||
from app.services.store import JsonStore
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/planner", tags=["planner"])
|
||||
store = JsonStore()
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
async def generate_plan(request: Request):
|
||||
payload = await request.json()
|
||||
week_start = str(payload.get("week_start") or "").strip()
|
||||
if not week_start:
|
||||
raise HTTPException(status_code=400, detail="week_start is required.")
|
||||
anchors = payload.get("anchors") or []
|
||||
if isinstance(anchors, str):
|
||||
anchors = [line.strip() for line in anchors.splitlines() if line.strip()]
|
||||
target_meals = max(3, min(7, int(payload.get("target_meals") or 5)))
|
||||
plan = planner.generate(week_start=week_start, anchors=list(anchors), target_meals=target_meals)
|
||||
record = {"id": store.new_id("mealplan"), **plan, "created_at": store.now()}
|
||||
repo.create_meal_plan(record)
|
||||
return {"status": "ok", "plan": record}
|
||||
|
||||
45
home/doris-kitchen/app/routes/api_profile.py
Normal file
45
home/doris-kitchen/app/routes/api_profile.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.services.repository import repo
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/profile", tags=["profile"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_profile():
|
||||
return {"profile": repo.get_profile(), "backend": repo.backend}
|
||||
|
||||
|
||||
@router.post("/blacklist")
|
||||
async def update_blacklist(request: Request):
|
||||
payload = await request.json()
|
||||
name = str(payload.get("name") or "").strip().lower()
|
||||
enabled = bool(payload.get("enabled", True))
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Ingredient or term is required.")
|
||||
profile = repo.get_profile()
|
||||
items = list(profile.get("soft_blacklist") or [])
|
||||
existing = next((item for item in items if str(item.get("name") or "").strip().lower() == name), None)
|
||||
if existing:
|
||||
existing["enabled"] = enabled
|
||||
else:
|
||||
items.append({"name": name, "enabled": enabled})
|
||||
profile["soft_blacklist"] = sorted(items, key=lambda item: str(item.get("name") or ""))
|
||||
repo.save_profile(profile)
|
||||
return {"status": "ok", "profile": profile}
|
||||
|
||||
|
||||
@router.post("/settings")
|
||||
async def update_settings(request: Request):
|
||||
payload = await request.json()
|
||||
profile = repo.get_profile()
|
||||
if "notes" in payload:
|
||||
profile["notes"] = str(payload.get("notes") or "")
|
||||
if "auto_import_threshold" in payload:
|
||||
profile["auto_import_threshold"] = max(50, min(100, int(payload["auto_import_threshold"])))
|
||||
if "auto_import_enabled" in payload:
|
||||
profile["auto_import_enabled"] = bool(payload["auto_import_enabled"])
|
||||
repo.save_profile(profile)
|
||||
return {"status": "ok", "profile": profile}
|
||||
|
||||
24
home/doris-kitchen/app/routes/api_search.py
Normal file
24
home/doris-kitchen/app/routes/api_search.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.services.recipe_search import recipe_search
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/search", tags=["search"])
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def run_search(request: Request):
|
||||
payload = await request.json()
|
||||
query = str(payload.get("query") or "").strip()
|
||||
if not query:
|
||||
raise HTTPException(status_code=400, detail="Search query is required.")
|
||||
limit = int(payload.get("limit") or 6)
|
||||
results = recipe_search.search(query, limit=limit)
|
||||
return {"status": "ok", "query": query, "results": results}
|
||||
|
||||
|
||||
@router.post("/seed")
|
||||
async def seed_queue():
|
||||
results = recipe_search.seed_queries()
|
||||
return {"status": "ok", "seeded": results, "count": len(results)}
|
||||
|
||||
72
home/doris-kitchen/app/routes/api_suggestions.py
Normal file
72
home/doris-kitchen/app/routes/api_suggestions.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.services.kitchenowl import kitchenowl
|
||||
from app.services.repository import repo
|
||||
from app.services.store import JsonStore
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/suggestions", tags=["suggestions"])
|
||||
store = JsonStore()
|
||||
|
||||
|
||||
def _bump(counter: dict, key: str, amount: int = 1) -> None:
|
||||
counter[key] = int(counter.get(key, 0)) + amount
|
||||
|
||||
|
||||
def _apply_learning(profile: dict, suggestion: dict, approved: bool) -> dict:
|
||||
ingredients = suggestion.get("ingredients") or []
|
||||
tags = suggestion.get("tags") or []
|
||||
title = str(suggestion.get("title") or "").strip().lower()
|
||||
ingredient_counter_key = "approved_ingredients" if approved else "rejected_ingredients"
|
||||
tag_counter_key = "approved_tags" if approved else "rejected_tags"
|
||||
title_counter_key = "approved_titles" if approved else "rejected_titles"
|
||||
for ingredient in ingredients:
|
||||
_bump(profile.setdefault(ingredient_counter_key, {}), ingredient)
|
||||
for tag in tags:
|
||||
_bump(profile.setdefault(tag_counter_key, {}), tag)
|
||||
if title:
|
||||
_bump(profile.setdefault(title_counter_key, {}), title)
|
||||
return profile
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_suggestions():
|
||||
items = sorted(repo.list_suggestions(), key=lambda item: item.get("updated_at") or item.get("created_at") or "", reverse=True)
|
||||
return {"items": items, "backend": repo.backend}
|
||||
|
||||
|
||||
@router.post("/{suggestion_id}/decision")
|
||||
async def decide(suggestion_id: str, request: Request):
|
||||
payload = await request.json()
|
||||
decision = str(payload.get("decision") or "").strip().lower()
|
||||
current = next((item for item in repo.list_suggestions() if str(item.get("id")) == str(suggestion_id)), None)
|
||||
if not current:
|
||||
raise HTTPException(status_code=404, detail="Suggestion not found.")
|
||||
if decision not in {"approve", "reject", "later"}:
|
||||
raise HTTPException(status_code=400, detail="Decision must be approve, reject, or later.")
|
||||
profile = repo.get_profile()
|
||||
kitchenowl_result = None
|
||||
if decision == "approve":
|
||||
profile = _apply_learning(profile, current, approved=True)
|
||||
import_now = bool(payload.get("import_to_kitchenowl", True))
|
||||
if import_now and kitchenowl.configured():
|
||||
kitchenowl_result = kitchenowl.create_recipe(current.get("payload") or {})
|
||||
current_status = "approved"
|
||||
elif decision == "reject":
|
||||
profile = _apply_learning(profile, current, approved=False)
|
||||
current_status = "rejected"
|
||||
else:
|
||||
current_status = "saved"
|
||||
repo.save_profile(profile)
|
||||
updated = repo.update_suggestion(
|
||||
suggestion_id,
|
||||
{
|
||||
"status": current_status,
|
||||
"updated_at": store.now(),
|
||||
"decision_at": store.now(),
|
||||
"kitchenowl_status": (kitchenowl_result or {}).get("status") if kitchenowl_result else current.get("kitchenowl_status", "not-imported"),
|
||||
"kitchenowl_result": kitchenowl_result,
|
||||
},
|
||||
)
|
||||
return {"status": "ok", "suggestion": updated, "profile": profile, "kitchenowl": kitchenowl_result}
|
||||
|
||||
25
home/doris-kitchen/app/routes/ui.py
Normal file
25
home/doris-kitchen/app/routes/ui.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
return templates.TemplateResponse("dashboard.html", {"request": request, "title": "Doris Kitchen"})
|
||||
|
||||
|
||||
@router.get("/search", response_class=HTMLResponse)
|
||||
async def search(request: Request):
|
||||
return templates.TemplateResponse("search.html", {"request": request, "title": "Recipe Search"})
|
||||
|
||||
|
||||
@router.get("/planner", response_class=HTMLResponse)
|
||||
async def planner_page(request: Request):
|
||||
return templates.TemplateResponse("planner.html", {"request": request, "title": "Meal Planner"})
|
||||
|
||||
Reference in New Issue
Block a user