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) 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 zoneinfo import ZoneInfo
from urllib.parse import urlencode from urllib.parse import urlencode
from fastapi import HTTPException, Query from fastapi import HTTPException, Query
@@ -266,6 +266,9 @@ data:
COOKED_PATH = DATA_DIR / "tandoor-cooked.json" COOKED_PATH = DATA_DIR / "tandoor-cooked.json"
PICKS_PATH = DATA_DIR / "tandoor-picks.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: def _today_str() -> str:
"""YYYY-MM-DD in Europe/Budapest if tzdata exists, else UTC.""" """YYYY-MM-DD in Europe/Budapest if tzdata exists, else UTC."""
try: try:
@@ -304,7 +307,6 @@ data:
def _fetch_all_recipes() -> list[dict]: def _fetch_all_recipes() -> list[dict]:
if not TANDOOR_INTERNAL_URL: if not TANDOOR_INTERNAL_URL:
raise HTTPException(status_code=500, detail="TANDOOR_INTERNAL_URL is not set") 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" url = f"{TANDOOR_INTERNAL_URL}/api/recipe/?page_size=200"
out = [] out = []
for _ in range(100): # safety for _ in range(100): # safety
@@ -317,31 +319,115 @@ data:
url = j.get("next") url = j.get("next")
if not url: if not url:
break break
# `next` may be absolute internal URL already; keep as-is.
return out 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() 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, {}) 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()
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() recipes = _fetch_all_recipes()
# Make a stable deterministic base random seed once, but still "true daily random": available = [r for r in recipes
# created once per day and stored. if r.get("id") is not None
available = [r for r in recipes if r.get("id") not in cooked_today] and r.get("id") not in recently_cooked]
if not available: 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 [] 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) _save_json(PICKS_PATH, picks_doc)
return 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() recipes = _fetch_all_recipes()
by_id = {r.get("id"): r for r in recipes if r.get("id") is not None} by_id = {r.get("id"): r for r in recipes if r.get("id") is not None}
items = [] items = []
@@ -355,45 +441,88 @@ data:
if GLANCE_HELPER_KEY: if GLANCE_HELPER_KEY:
cook_params["key"] = 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 "" 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) return items, len(recipes)
@APP.get("/tandoor/daily") @APP.get("/tandoor/daily")
def tandoor_daily(count: int = Query(3, ge=1, le=10)): def tandoor_daily(count: int = Query(3, ge=1, le=10), cooldown: int = Query(None, ge=0, le=365)):
picks_doc = _compute_daily_picks(count) """
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", []) ids = picks_doc.get("ids", [])
items, total = _build_items_from_ids(ids) history = _load_cooked_history()
return {"date": picks_doc.get("date"), "total_recipes": total, "items": items} 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") @APP.get("/tandoor/cook")
def tandoor_cook(id: int, key: str | None = None, redirect: str | None = None): 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: if GLANCE_HELPER_KEY and key != GLANCE_HELPER_KEY:
raise HTTPException(status_code=403, detail="Invalid key") raise HTTPException(status_code=403, detail="Invalid key")
today = _today_str() 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, {}) picks = _load_json(PICKS_PATH, {})
if picks.get("date") == today and isinstance(picks.get("ids"), list): if picks.get("date") == today and isinstance(picks.get("ids"), list):
ids = [x for x in picks["ids"] if x != id] ids = [x for x in picks["ids"] if x != id]
target = int(picks.get("count") or len(ids)) target = int(picks.get("count") or len(ids))
if len(ids) < target: if len(ids) < target:
recipes = _fetch_all_recipes() recipes = _fetch_all_recipes()
avoid = set(ids) | cooked_today 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] candidates = [r.get("id") for r in recipes
if r.get("id") is not None
and r.get("id") not in avoid]
if candidates: if candidates:
ids.append(random.choice(candidates)) ids.append(random.choice(candidates))
picks["ids"] = ids[:target] picks["ids"] = ids[:target]
_save_json(PICKS_PATH, picks) _save_json(PICKS_PATH, picks)
if redirect: if redirect:
return RedirectResponse(url=redirect, status_code=302) 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 # 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 url: http://glance-helper.glance-system.svc.cluster.local:8000/tandoor/daily
parameters: parameters:
count: 3 count: 3
cooldown: 14 # Optional: override default cooldown days
options: options:
tandoor_url: https://tandoor.dooplex.hu 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-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 { 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-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-s { min-width:100%; flex-shrink:0; }
.mw-img { height:150px; background:rgba(0,0,0,0.15); overflow:hidden; } .mw-img { height:150px; background:rgba(0,0,0,0.15); overflow:hidden; }
.mw-img img { width:100%; height:100%; object-fit:cover; } .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-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 { 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; } .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, .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-p { left:6px; }
.mw-n { right:6px; } .mw-n { right:6px; }
@@ -833,12 +835,13 @@ data:
<div class="mw-box"> <div class="mw-box">
{{ range $i, $_ := $items }}<input type="radio" name="mr" id="mr{{ $i }}"{{ if eq $i 0 }} checked{{ end }}>{{ end }} {{ range $i, $_ := $items }}<input type="radio" name="mr" id="mr{{ $i }}"{{ if eq $i 0 }} checked{{ end }}>{{ end }}
<div class="mw-track"> <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"> <div class="mw-s">
<a class="mw-link" href="{{ $url }}" target="_blank"> <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-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> <div class="mw-name">{{ $r.String "name" }}</div>
</a> </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 class="mw-acts"><a href="{{ $url }}" target="_blank">Open</a> <a href="{{ $cook }}" target="_blank">Cooked today ✔</a></div>
</div> </div>
{{ end }} {{ end }}