updated tandoor widget and helper code

This commit is contained in:
2026-01-22 17:35:32 +01:00
parent a1f861b0b2
commit a3b06f5e5e
2 changed files with 165 additions and 33 deletions
+158 -29
View File
@@ -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
+7 -4
View File
@@ -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 }}