updated tandoor widget and helper code
This commit is contained in:
@@ -246,9 +246,9 @@ data:
|
||||
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
||||
|
||||
# ================================
|
||||
# Tandoor "Meal of the Day"
|
||||
# Tandoor "Meal of the Day" - Enhanced Version
|
||||
# ================================
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from urllib.parse import urlencode
|
||||
from fastapi import HTTPException, Query
|
||||
@@ -266,6 +266,9 @@ data:
|
||||
COOKED_PATH = DATA_DIR / "tandoor-cooked.json"
|
||||
PICKS_PATH = DATA_DIR / "tandoor-picks.json"
|
||||
|
||||
# Cooldown: don't suggest recipes cooked within this many days
|
||||
TANDOOR_COOLDOWN_DAYS = int(os.getenv("TANDOOR_COOLDOWN_DAYS", "14"))
|
||||
|
||||
def _today_str() -> str:
|
||||
"""YYYY-MM-DD in Europe/Budapest if tzdata exists, else UTC."""
|
||||
try:
|
||||
@@ -304,7 +307,6 @@ data:
|
||||
def _fetch_all_recipes() -> list[dict]:
|
||||
if not TANDOOR_INTERNAL_URL:
|
||||
raise HTTPException(status_code=500, detail="TANDOOR_INTERNAL_URL is not set")
|
||||
# Prefer paginated /api/recipe/
|
||||
url = f"{TANDOOR_INTERNAL_URL}/api/recipe/?page_size=200"
|
||||
out = []
|
||||
for _ in range(100): # safety
|
||||
@@ -317,31 +319,115 @@ data:
|
||||
url = j.get("next")
|
||||
if not url:
|
||||
break
|
||||
# `next` may be absolute internal URL already; keep as-is.
|
||||
return out
|
||||
|
||||
def _compute_daily_picks(count: int) -> dict:
|
||||
# ================================
|
||||
# Cooked History Management
|
||||
# ================================
|
||||
# Structure: {"<recipe_id>": ["2026-01-22", "2026-01-15", ...], ...}
|
||||
|
||||
def _load_cooked_history() -> dict[str, list[str]]:
|
||||
"""Load cooked history. Returns {recipe_id_str: [date_str, ...]}"""
|
||||
return _load_json(COOKED_PATH, {})
|
||||
|
||||
def _save_cooked_history(history: dict[str, list[str]]) -> None:
|
||||
_save_json(COOKED_PATH, history)
|
||||
|
||||
def _get_cooked_count(history: dict, recipe_id: int) -> int:
|
||||
"""Get total times a recipe has been cooked."""
|
||||
return len(history.get(str(recipe_id), []))
|
||||
|
||||
def _get_recently_cooked_ids(history: dict, days: int) -> set[int]:
|
||||
"""Get recipe IDs cooked within the last N days."""
|
||||
if days <= 0:
|
||||
return set()
|
||||
try:
|
||||
tz = ZoneInfo("Europe/Budapest")
|
||||
except Exception:
|
||||
tz = timezone.utc
|
||||
|
||||
cutoff = (datetime.now(tz) - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
recent = set()
|
||||
for rid_str, dates in history.items():
|
||||
for d in dates:
|
||||
if d >= cutoff:
|
||||
recent.add(int(rid_str))
|
||||
break
|
||||
return recent
|
||||
|
||||
def _record_cooked(recipe_id: int) -> dict[str, list[str]]:
|
||||
"""Record that a recipe was cooked today. Returns updated history."""
|
||||
history = _load_cooked_history()
|
||||
rid_str = str(recipe_id)
|
||||
today = _today_str()
|
||||
cooked = _load_json(COOKED_PATH, {})
|
||||
cooked_today = set(cooked.get(today, []))
|
||||
|
||||
if rid_str not in history:
|
||||
history[rid_str] = []
|
||||
|
||||
# Avoid duplicate entries for same day
|
||||
if today not in history[rid_str]:
|
||||
history[rid_str].append(today)
|
||||
# Keep sorted (newest first) for easier reading
|
||||
history[rid_str] = sorted(history[rid_str], reverse=True)
|
||||
|
||||
_save_cooked_history(history)
|
||||
return history
|
||||
|
||||
# ================================
|
||||
# Daily Picks Logic
|
||||
# ================================
|
||||
|
||||
def _compute_daily_picks(count: int, cooldown_days: int) -> dict:
|
||||
today = _today_str()
|
||||
history = _load_cooked_history()
|
||||
recently_cooked = _get_recently_cooked_ids(history, cooldown_days)
|
||||
|
||||
picks_doc = _load_json(PICKS_PATH, {})
|
||||
if picks_doc.get("date") == today and isinstance(picks_doc.get("ids"), list) and picks_doc.get("count") == count:
|
||||
return picks_doc # stable for the day
|
||||
|
||||
# Check if we need to regenerate picks
|
||||
needs_regen = False
|
||||
if picks_doc.get("date") != today:
|
||||
needs_regen = True
|
||||
elif picks_doc.get("count") != count:
|
||||
needs_regen = True
|
||||
elif picks_doc.get("cooldown") != cooldown_days:
|
||||
needs_regen = True
|
||||
else:
|
||||
# Check if we can add more picks (new recipes added mid-day)
|
||||
current_ids = set(picks_doc.get("ids", []))
|
||||
if len(current_ids) < count:
|
||||
recipes = _fetch_all_recipes()
|
||||
# Make a stable deterministic base random seed once, but still "true daily random":
|
||||
# created once per day and stored.
|
||||
available = [r for r in recipes if r.get("id") not in cooked_today]
|
||||
available = [r.get("id") for r in recipes
|
||||
if r.get("id") is not None
|
||||
and r.get("id") not in current_ids
|
||||
and r.get("id") not in recently_cooked]
|
||||
if available:
|
||||
needs_regen = True
|
||||
|
||||
if not needs_regen:
|
||||
return picks_doc
|
||||
|
||||
# Generate new picks
|
||||
recipes = _fetch_all_recipes()
|
||||
available = [r for r in recipes
|
||||
if r.get("id") is not None
|
||||
and r.get("id") not in recently_cooked]
|
||||
|
||||
if not available:
|
||||
available = recipes[:] # fallback: if everything cooked, ignore filter
|
||||
# Fallback: if everything is in cooldown, ignore cooldown
|
||||
available = recipes[:]
|
||||
|
||||
chosen = random.sample(available, k=min(count, len(available))) if available else []
|
||||
picks_doc = {"date": today, "count": count, "ids": [r.get("id") for r in chosen if r.get("id") is not None]}
|
||||
picks_doc = {
|
||||
"date": today,
|
||||
"count": count,
|
||||
"cooldown": cooldown_days,
|
||||
"ids": [r.get("id") for r in chosen if r.get("id") is not None]
|
||||
}
|
||||
_save_json(PICKS_PATH, picks_doc)
|
||||
return picks_doc
|
||||
|
||||
def _build_items_from_ids(ids: list[int]) -> tuple[list[dict], int]:
|
||||
def _build_items_from_ids(ids: list[int], history: dict) -> tuple[list[dict], int]:
|
||||
recipes = _fetch_all_recipes()
|
||||
by_id = {r.get("id"): r for r in recipes if r.get("id") is not None}
|
||||
items = []
|
||||
@@ -355,45 +441,88 @@ data:
|
||||
if GLANCE_HELPER_KEY:
|
||||
cook_params["key"] = GLANCE_HELPER_KEY
|
||||
cook_url = f"{GLANCE_HELPER_PUBLIC_URL}/tandoor/cook?{urlencode(cook_params)}" if GLANCE_HELPER_PUBLIC_URL else ""
|
||||
items.append({"id": rid, "name": r.get("name") or "", "image": img, "url": url, "cook_url": cook_url})
|
||||
|
||||
items.append({
|
||||
"id": rid,
|
||||
"name": r.get("name") or "",
|
||||
"image": img,
|
||||
"url": url,
|
||||
"cook_url": cook_url,
|
||||
"cooked_count": _get_cooked_count(history, rid)
|
||||
})
|
||||
return items, len(recipes)
|
||||
|
||||
@APP.get("/tandoor/daily")
|
||||
def tandoor_daily(count: int = Query(3, ge=1, le=10)):
|
||||
picks_doc = _compute_daily_picks(count)
|
||||
def tandoor_daily(count: int = Query(3, ge=1, le=10), cooldown: int = Query(None, ge=0, le=365)):
|
||||
"""
|
||||
Get daily meal suggestions.
|
||||
|
||||
- count: Number of suggestions (1-10, default 3)
|
||||
- cooldown: Days to exclude recently cooked meals (0-365, default from env TANDOOR_COOLDOWN_DAYS or 14)
|
||||
"""
|
||||
cooldown_days = cooldown if cooldown is not None else TANDOOR_COOLDOWN_DAYS
|
||||
picks_doc = _compute_daily_picks(count, cooldown_days)
|
||||
ids = picks_doc.get("ids", [])
|
||||
items, total = _build_items_from_ids(ids)
|
||||
return {"date": picks_doc.get("date"), "total_recipes": total, "items": items}
|
||||
history = _load_cooked_history()
|
||||
items, total = _build_items_from_ids(ids, history)
|
||||
return {
|
||||
"date": picks_doc.get("date"),
|
||||
"total_recipes": total,
|
||||
"cooldown_days": cooldown_days,
|
||||
"items": items
|
||||
}
|
||||
|
||||
@APP.get("/tandoor/cook")
|
||||
def tandoor_cook(id: int, key: str | None = None, redirect: str | None = None):
|
||||
"""Mark a recipe as cooked today."""
|
||||
if GLANCE_HELPER_KEY and key != GLANCE_HELPER_KEY:
|
||||
raise HTTPException(status_code=403, detail="Invalid key")
|
||||
|
||||
today = _today_str()
|
||||
cooked = _load_json(COOKED_PATH, {})
|
||||
cooked_today = set(cooked.get(today, []))
|
||||
cooked_today.add(id)
|
||||
cooked[today] = sorted(list(cooked_today))
|
||||
_save_json(COOKED_PATH, cooked)
|
||||
|
||||
# Remove from today's picks and try to refill to keep count
|
||||
# Record in permanent history
|
||||
history = _record_cooked(id)
|
||||
recently_cooked = _get_recently_cooked_ids(history, TANDOOR_COOLDOWN_DAYS)
|
||||
|
||||
# Remove from today's picks and try to refill
|
||||
picks = _load_json(PICKS_PATH, {})
|
||||
if picks.get("date") == today and isinstance(picks.get("ids"), list):
|
||||
ids = [x for x in picks["ids"] if x != id]
|
||||
target = int(picks.get("count") or len(ids))
|
||||
|
||||
if len(ids) < target:
|
||||
recipes = _fetch_all_recipes()
|
||||
avoid = set(ids) | cooked_today
|
||||
candidates = [r.get("id") for r in recipes if r.get("id") is not None and r.get("id") not in avoid]
|
||||
avoid = set(ids) | recently_cooked
|
||||
candidates = [r.get("id") for r in recipes
|
||||
if r.get("id") is not None
|
||||
and r.get("id") not in avoid]
|
||||
if candidates:
|
||||
ids.append(random.choice(candidates))
|
||||
|
||||
picks["ids"] = ids[:target]
|
||||
_save_json(PICKS_PATH, picks)
|
||||
|
||||
if redirect:
|
||||
return RedirectResponse(url=redirect, status_code=302)
|
||||
return {"ok": True, "date": today, "cooked": id}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"date": today,
|
||||
"cooked_id": id,
|
||||
"cooked_total": _get_cooked_count(history, id)
|
||||
}
|
||||
|
||||
@APP.get("/tandoor/history")
|
||||
def tandoor_history():
|
||||
"""Get full cooked history (for debugging/stats)."""
|
||||
history = _load_cooked_history()
|
||||
return {
|
||||
"recipes": {
|
||||
int(k): {"dates": v, "count": len(v)}
|
||||
for k, v in history.items()
|
||||
},
|
||||
"total_cooks": sum(len(v) for v in history.values())
|
||||
}
|
||||
|
||||
# ================================
|
||||
# Version Checker - Container Version Monitoring
|
||||
|
||||
@@ -783,6 +783,7 @@ data:
|
||||
url: http://glance-helper.glance-system.svc.cluster.local:8000/tandoor/daily
|
||||
parameters:
|
||||
count: 3
|
||||
cooldown: 14 # Optional: override default cooldown days
|
||||
|
||||
options:
|
||||
tandoor_url: https://tandoor.dooplex.hu
|
||||
@@ -798,15 +799,16 @@ data:
|
||||
.mw-meta { opacity:.65; font-size:12px; display:flex; justify-content:space-between; align-items:center; }
|
||||
.mw-box { position:relative; border-radius:14px; background:rgba(255,255,255,0.04); box-shadow:0 0 0 1px rgba(255,255,255,0.06) inset; overflow:hidden; }
|
||||
.mw-box input { display:none; }
|
||||
.mw-track { display:flex; transition:transform 0.3s ease; }
|
||||
.mw-track { display:flex; }
|
||||
.mw-s { min-width:100%; flex-shrink:0; }
|
||||
.mw-img { height:150px; background:rgba(0,0,0,0.15); overflow:hidden; }
|
||||
.mw-img img { width:100%; height:100%; object-fit:cover; }
|
||||
.mw-noimg { height:150px; display:flex; align-items:center; justify-content:center; opacity:.5; font-size:12px; }
|
||||
.mw-name { padding:10px 12px 6px; font-weight:700; opacity:.95; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.mw-name { padding:10px 12px 4px; font-weight:700; opacity:.95; line-height:1.3; }
|
||||
.mw-stats { padding:0 12px 6px; font-size:11px; opacity:.5; }
|
||||
.mw-acts { padding:0 12px 10px; display:flex; gap:10px; opacity:.8; font-size:12px; }
|
||||
.mw-acts a, .mw-link { text-decoration:none; color:inherit; display:block; }
|
||||
/* Arrows - hidden by default, shown on hover */
|
||||
/* Arrows */
|
||||
.mw-p, .mw-n { position:absolute; top:75px; transform:translateY(-50%); width:26px; height:26px; border-radius:50%; background:rgba(0,0,0,0.6); color:#fff; align-items:center; justify-content:center; font-size:11px; cursor:pointer; z-index:5; display:none; }
|
||||
.mw-p { left:6px; }
|
||||
.mw-n { right:6px; }
|
||||
@@ -833,12 +835,13 @@ data:
|
||||
<div class="mw-box">
|
||||
{{ range $i, $_ := $items }}<input type="radio" name="mr" id="mr{{ $i }}"{{ if eq $i 0 }} checked{{ end }}>{{ end }}
|
||||
<div class="mw-track">
|
||||
{{ range $r := $items }}{{ $img := $r.String "image" }}{{ $url := $r.String "url" }}{{ $cook := $r.String "cook_url" }}
|
||||
{{ range $r := $items }}{{ $img := $r.String "image" }}{{ $url := $r.String "url" }}{{ $cook := $r.String "cook_url" }}{{ $cooked := $r.Int "cooked_count" }}
|
||||
<div class="mw-s">
|
||||
<a class="mw-link" href="{{ $url }}" target="_blank">
|
||||
<div class="mw-img">{{ if $img }}<img src="{{ $img }}" alt="">{{ else }}<div class="mw-noimg">No image</div>{{ end }}</div>
|
||||
<div class="mw-name">{{ $r.String "name" }}</div>
|
||||
</a>
|
||||
{{ if gt $cooked 0 }}<div class="mw-stats">Cooked {{ $cooked }}× before</div>{{ end }}
|
||||
<div class="mw-acts"><a href="{{ $url }}" target="_blank">Open</a> <a href="{{ $cook }}" target="_blank">Cooked today ✔</a></div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
Reference in New Issue
Block a user