All checks were successful
Deploy / deploy (push) Successful in 31s
The wall dashboard's Ombi tab now shows a lunch panel with pull time and next scheduled run. It's served from Home Assistant (:8123), a different origin, so allow that one origin to read the one read-only endpoint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
505 lines
19 KiB
Python
505 lines
19 KiB
Python
"""
|
|
Lunch Notifier — pulls the Mariposa lunch menu and pushes it to a Home
|
|
Assistant calendar. Visit http://localhost:2323 to manage settings.
|
|
"""
|
|
import json
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
from datetime import date, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
import schedule
|
|
from flask import Flask, jsonify, request
|
|
|
|
from scraper import get_month_menu
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
log = logging.getLogger(__name__)
|
|
|
|
app = Flask(__name__)
|
|
|
|
SETTINGS_FILE = Path(os.environ.get("SETTINGS_FILE", "/data/settings.json"))
|
|
PORT = int(os.environ.get("PORT", "2323"))
|
|
|
|
# Env-var defaults (used only when no settings file exists yet). These are
|
|
# also the complete settings schema: load_settings() drops any other key.
|
|
_DEFAULTS = {
|
|
"calendar_run_time": os.environ.get("CALENDAR_RUN_TIME", "18:00"),
|
|
}
|
|
|
|
_status = {"last_calendar_run": None, "calendar_result": None}
|
|
_lock = threading.Lock()
|
|
|
|
|
|
# ── Settings ──────────────────────────────────────────────────────────────────
|
|
|
|
def load_settings() -> dict:
|
|
if SETTINGS_FILE.exists():
|
|
try:
|
|
with open(SETTINGS_FILE) as f:
|
|
# Merge over _DEFAULTS so a settings.json from before a schema
|
|
# change still gets a valid schedule rather than silently
|
|
# scheduling nothing, and keep only current keys so stale
|
|
# ones (SMS-era phone numbers, the old day-of-month pull)
|
|
# don't linger. Startup rewrites the file with this result.
|
|
stored = json.load(f)
|
|
return {**_DEFAULTS, **{k: v for k, v in stored.items() if k in _DEFAULTS}}
|
|
except Exception:
|
|
pass
|
|
return dict(_DEFAULTS)
|
|
|
|
|
|
def save_settings(s: dict) -> None:
|
|
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(SETTINGS_FILE, "w") as f:
|
|
json.dump(s, f, indent=2)
|
|
|
|
|
|
# ── Calendar push ─────────────────────────────────────────────────────────────
|
|
|
|
def _push_to_home_assistant(items: list[str], date_str: str) -> None:
|
|
webhook_url = os.environ["HA_WEBHOOK_URL"]
|
|
log.info(f"Pushing {date_str} menu → Home Assistant webhook")
|
|
resp = requests.post(webhook_url, json={"date": date_str, "items": items}, timeout=10)
|
|
resp.raise_for_status()
|
|
log.info(f"Home Assistant push ✓ ({date_str})")
|
|
|
|
|
|
def _push_calendar(items: list[str], date_str: str | None) -> str:
|
|
"""Push one day to Home Assistant if configured. Returns a status string."""
|
|
if not items:
|
|
return "no items"
|
|
if not os.environ.get("HA_WEBHOOK_URL"):
|
|
return "not configured"
|
|
try:
|
|
_push_to_home_assistant(items, date_str)
|
|
return "ok"
|
|
except Exception as e:
|
|
log.error(f"Home Assistant push failed ({date_str}): {e}")
|
|
return f"error: {e}"
|
|
|
|
|
|
def push_month_to_calendar() -> None:
|
|
"""Fetch whichever month is currently published and push every day from
|
|
today onward that has a posted menu. Days with nothing posted (breaks,
|
|
holidays) are skipped since there's nothing to create an event from;
|
|
past days are skipped since they're already on the calendar or no
|
|
longer matter; weekend days are skipped even if the PDF read put a menu
|
|
on one (there's never school lunch on a weekend, so that's a misread
|
|
date — seen once as Fri 9/25's menu landing on Sat 9/26). Runs daily
|
|
(calendar_run_time); Home Assistant skips dates that already have a
|
|
lunch event, so repeated pulls don't create duplicates."""
|
|
global _status
|
|
|
|
try:
|
|
month = get_month_menu()
|
|
except Exception as e:
|
|
log.exception("Month fetch failed")
|
|
_status = {**_status, "last_calendar_run": datetime.now().isoformat(), "calendar_result": f"error: {e}"}
|
|
return
|
|
|
|
today = date.today()
|
|
pushed = errors = no_items = past = weekend = 0
|
|
for day in month:
|
|
d = date.fromisoformat(day["date"])
|
|
if d < today:
|
|
past += 1
|
|
continue
|
|
if d.weekday() >= 5:
|
|
if day["items"]:
|
|
log.warning(f"Skipping {day['date']} ({d:%a}): menu on a weekend is a misread date")
|
|
weekend += 1
|
|
else:
|
|
no_items += 1
|
|
continue
|
|
result = _push_calendar(day["items"], day["date"])
|
|
if result == "ok":
|
|
pushed += 1
|
|
elif result == "no items":
|
|
no_items += 1
|
|
else:
|
|
errors += 1
|
|
|
|
log.info(f"Month push: {pushed} pushed, {errors} errors, {no_items} no menu, "
|
|
f"{past} past, {weekend} weekend misreads skipped")
|
|
|
|
if errors:
|
|
summary = f"{pushed} ok, {errors} error" + ("s" if errors != 1 else "")
|
|
elif pushed:
|
|
summary = f"ok ({pushed} day{'s' if pushed != 1 else ''})"
|
|
else:
|
|
summary = "no items"
|
|
if weekend:
|
|
summary += f", {weekend} weekend misread{'s' if weekend != 1 else ''} skipped"
|
|
|
|
_status = {
|
|
**_status,
|
|
"last_calendar_run": datetime.now().isoformat(),
|
|
"calendar_result": summary,
|
|
}
|
|
|
|
|
|
# ── Scheduler ─────────────────────────────────────────────────────────────────
|
|
|
|
def apply_calendar_schedule(settings: dict) -> None:
|
|
with _lock:
|
|
schedule.clear("calendar")
|
|
run_time = settings.get("calendar_run_time", "18:00")
|
|
schedule.every().day.at(run_time).do(push_month_to_calendar).tag("calendar")
|
|
log.info(f"Scheduled calendar pull: daily at {run_time}")
|
|
|
|
|
|
def next_calendar_run(settings: dict) -> datetime:
|
|
run_time = settings.get("calendar_run_time", "18:00")
|
|
hour, minute = (int(p) for p in run_time.split(":"))
|
|
now = datetime.now()
|
|
candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
return candidate if candidate > now else candidate + timedelta(days=1)
|
|
|
|
|
|
def _schedule_loop() -> None:
|
|
while True:
|
|
with _lock:
|
|
schedule.run_pending()
|
|
time.sleep(30)
|
|
|
|
|
|
# ── Flask API ─────────────────────────────────────────────────────────────────
|
|
|
|
MASCOT_URL = "https://resources.finalsite.net/images/f_png,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png"
|
|
|
|
@app.route("/")
|
|
def index():
|
|
return HTML, 200, {"Content-Type": "text/html"}
|
|
|
|
|
|
@app.route("/icon.png")
|
|
def icon():
|
|
r = requests.get(MASCOT_URL, timeout=10)
|
|
return r.content, 200, {"Content-Type": "image/png", "Cache-Control": "public, max-age=86400"}
|
|
|
|
|
|
@app.route("/api/settings", methods=["GET"])
|
|
def api_get_settings():
|
|
return jsonify(load_settings())
|
|
|
|
|
|
@app.route("/api/settings", methods=["POST"])
|
|
def api_post_settings():
|
|
data = request.get_json(force=True)
|
|
s = load_settings()
|
|
for key in _DEFAULTS:
|
|
if key in data:
|
|
s[key] = data[key]
|
|
save_settings(s)
|
|
apply_calendar_schedule(s)
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/push-calendar", methods=["POST"])
|
|
def api_push_calendar():
|
|
threading.Thread(target=push_month_to_calendar, daemon=True).start()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/preview")
|
|
def api_preview():
|
|
"""Dry-run month scrape: no Home Assistant push, no side effects."""
|
|
try:
|
|
month = get_month_menu()
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 502
|
|
return jsonify({"days": month})
|
|
|
|
|
|
@app.route("/api/integrations")
|
|
def api_integrations():
|
|
return jsonify({
|
|
"ha_webhook_configured": bool(os.environ.get("HA_WEBHOOK_URL")),
|
|
"anthropic_api_key_configured": bool(os.environ.get("ANTHROPIC_API_KEY")),
|
|
})
|
|
|
|
|
|
# The Home Assistant iPad wall dashboard (served from HA at :8123, a different
|
|
# origin) reads /api/status for its lunch summary panel. Allow that one
|
|
# origin to read that one read-only endpoint; everything else stays same-origin.
|
|
DASHBOARD_ORIGIN = "http://192.168.86.207:8123"
|
|
|
|
@app.after_request
|
|
def _cors_for_dashboard(resp):
|
|
if request.path == "/api/status":
|
|
resp.headers["Access-Control-Allow-Origin"] = DASHBOARD_ORIGIN
|
|
return resp
|
|
|
|
|
|
@app.route("/api/status")
|
|
def api_status():
|
|
return jsonify({
|
|
**_status,
|
|
"next_calendar_run": next_calendar_run(load_settings()).isoformat(),
|
|
})
|
|
|
|
|
|
# ── Embedded UI ───────────────────────────────────────────────────────────────
|
|
|
|
HTML = """<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>🍱 Lunch Notifier</title>
|
|
<link rel="icon" href="https://resources.finalsite.net/images/f_auto,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png">
|
|
<style>
|
|
:root {
|
|
--bg: #0d1117;
|
|
--card: #161b22;
|
|
--primary: #14b8a6;
|
|
--primary-hover: #0d9488;
|
|
--danger: #f87171;
|
|
--success: #4ade80;
|
|
--text: #e6edf3;
|
|
--muted: #8b949e;
|
|
--border: #30363d;
|
|
--radius: 12px;
|
|
--shadow: 0 1px 3px rgba(0,0,0,.4), 0 1px 2px rgba(0,0,0,.3);
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: var(--bg); color: var(--text); min-height: 100vh; }
|
|
.container { max-width: 620px; margin: 0 auto; padding: 24px 16px 48px; }
|
|
header { margin-bottom: 28px; }
|
|
header h1 { font-size: 1.5rem; font-weight: 700; }
|
|
header p { color: var(--muted); font-size: .875rem; margin-top: 2px; }
|
|
.card { background: var(--card); border-radius: var(--radius); padding: 20px;
|
|
margin-bottom: 16px; box-shadow: var(--shadow); border: 1px solid var(--border); }
|
|
.card-label { font-size: .7rem; font-weight: 700; text-transform: uppercase;
|
|
letter-spacing: .06em; color: var(--muted); margin-bottom: 14px; }
|
|
/* Status grid */
|
|
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
|
.stat dt { font-size: .75rem; color: var(--muted); margin-bottom: 2px; }
|
|
.stat dd { font-size: .875rem; font-weight: 500; }
|
|
/* Badges */
|
|
.badge { display: inline-block; padding: 2px 10px; border-radius: 99px; font-size: .75rem; font-weight: 600; }
|
|
.badge-green { background: #14532d; color: #4ade80; }
|
|
.badge-yellow { background: #713f12; color: #fbbf24; }
|
|
.badge-red { background: #7f1d1d; color: #fca5a5; }
|
|
.badge-blue { background: #1e3a5f; color: #7dd3fc; }
|
|
/* Buttons */
|
|
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
|
padding: 9px 18px; border-radius: 8px; font-size: .875rem; font-weight: 600;
|
|
cursor: pointer; border: none; transition: background .15s, opacity .15s; }
|
|
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
|
.btn-primary { background: var(--primary); color: #fff; }
|
|
.btn-primary:hover:not(:disabled) { background: var(--primary-hover); }
|
|
.btn-ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
|
|
.btn-ghost:hover { background: var(--bg); }
|
|
.btn-danger { background: var(--card); color: var(--danger); border: 1px solid #7f1d1d; }
|
|
.btn-danger:hover { background: #1a0f0f; }
|
|
.btn-send { width: 100%; padding: 14px; font-size: 1rem; border-radius: 10px; }
|
|
.btn-sm { padding: 6px 12px; font-size: .75rem; }
|
|
/* Form elements */
|
|
.field { margin-bottom: 14px; }
|
|
.field:last-child { margin-bottom: 0; }
|
|
label.field-label { display: block; font-size: .8rem; font-weight: 600; color: var(--text);
|
|
margin-bottom: 6px; }
|
|
input[type=text], input[type=time], input[type=number] {
|
|
width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px;
|
|
font-size: .875rem; color: var(--text); background: var(--card);
|
|
}
|
|
input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-color: transparent; }
|
|
.item-sub { font-size: .75rem; color: var(--muted); margin-top: 1px; }
|
|
/* Divider */
|
|
.divider { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
|
|
/* Toast */
|
|
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(8px);
|
|
background: #1f2937; color: #fff; padding: 10px 20px; border-radius: 99px;
|
|
font-size: .875rem; font-weight: 500; opacity: 0; transition: opacity .25s, transform .25s;
|
|
pointer-events: none; white-space: nowrap; z-index: 100; }
|
|
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
.day-row { padding: 8px 0; border-bottom: 1px solid var(--border); }
|
|
.day-row:last-child { border-bottom: none; }
|
|
.day-row-date { font-size: .8rem; font-weight: 600; margin-bottom: 3px; }
|
|
.day-row-items { font-size: .8rem; color: #c9d1d9; white-space: pre-wrap; }
|
|
.day-row-empty { font-size: .8rem; color: var(--muted); font-style: italic; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<div style="display:flex;align-items:center;gap:16px;">
|
|
<img src="https://resources.finalsite.net/images/f_auto,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png" alt="Chip the Challenger" style="height:80px;width:auto;">
|
|
<div>
|
|
<h1>Lunch Notifier</h1>
|
|
<p>Kyrene de la Mariposa · Menu → Home Assistant calendar</p>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Status -->
|
|
<div class="card">
|
|
<div class="card-label">Status</div>
|
|
<dl class="stat-grid">
|
|
<div class="stat"><dt>Last Pull</dt><dd id="s-last-run">—</dd></div>
|
|
<div class="stat"><dt>Result</dt><dd id="s-result">—</dd></div>
|
|
<div class="stat"><dt>Next Scheduled</dt><dd id="s-next">—</dd></div>
|
|
</dl>
|
|
</div>
|
|
|
|
<!-- Manual Actions -->
|
|
<div class="card">
|
|
<div class="card-label">Manual Actions</div>
|
|
<button id="push-cal-btn" class="btn btn-primary btn-send" onclick="pushCalendar()">
|
|
Push This Month to Calendar
|
|
</button>
|
|
<button id="preview-btn" class="btn btn-ghost" style="width:100%;margin-top:10px;" onclick="previewMenu()">
|
|
Preview This Month's Menu
|
|
</button>
|
|
<div id="preview-box" style="display:none;margin-top:14px;"></div>
|
|
</div>
|
|
|
|
<!-- Integrations -->
|
|
<div class="card">
|
|
<div class="card-label">Integrations</div>
|
|
<dl class="stat-grid">
|
|
<div class="stat"><dt>Home Assistant</dt><dd id="i-ha">—</dd></div>
|
|
<div class="stat"><dt>Anthropic API</dt><dd id="i-anthropic">—</dd></div>
|
|
</dl>
|
|
<div class="item-sub" style="margin-top:10px;">Configured via <code>.env</code> (<code>HA_WEBHOOK_URL</code>, <code>ANTHROPIC_API_KEY</code>) on the server, not here.</div>
|
|
</div>
|
|
|
|
<!-- Schedule -->
|
|
<div class="card">
|
|
<div class="card-label">Calendar Pull Schedule</div>
|
|
<div class="field">
|
|
<label class="field-label">Pull Time</label>
|
|
<input type="time" id="f-time" style="max-width:160px">
|
|
</div>
|
|
<div class="item-sub" style="margin-bottom:14px;">Pulls every day and pushes today onward from whichever month the district has published, so next month's menu lands on the calendar as soon as it's posted. Home Assistant skips dates that already have a lunch event, so daily pulls don't create duplicates.</div>
|
|
<hr class="divider">
|
|
<button class="btn btn-primary" onclick="saveSchedule()">Save Schedule</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<script>
|
|
let cfg = {};
|
|
|
|
async function load() {
|
|
const [s, st, ig] = await Promise.all([
|
|
fetch('/api/settings').then(r=>r.json()),
|
|
fetch('/api/status').then(r=>r.json()),
|
|
fetch('/api/integrations').then(r=>r.json()),
|
|
]);
|
|
cfg = s;
|
|
applySettings(s);
|
|
applyStatus(st);
|
|
applyIntegrations(ig);
|
|
}
|
|
|
|
function fmt(iso) {
|
|
if (!iso) return '—';
|
|
const d = new Date(iso);
|
|
return d.toLocaleDateString([], {month:'short',day:'numeric'}) + ' ' +
|
|
d.toLocaleTimeString([], {hour:'numeric',minute:'2-digit'});
|
|
}
|
|
|
|
function applyIntegrations(ig) {
|
|
const badge = ok => ok
|
|
? '<span class="badge badge-green">Configured</span>'
|
|
: '<span class="badge badge-yellow">Not configured</span>';
|
|
document.getElementById('i-ha').innerHTML = badge(ig.ha_webhook_configured);
|
|
document.getElementById('i-anthropic').innerHTML = badge(ig.anthropic_api_key_configured);
|
|
}
|
|
|
|
function applySettings(s) {
|
|
document.getElementById('f-time').value = s.calendar_run_time || '18:00';
|
|
}
|
|
|
|
function applyStatus(st) {
|
|
document.getElementById('s-last-run').textContent = fmt(st.last_calendar_run);
|
|
const res = st.calendar_result;
|
|
let cls = res==='ok' || res?.startsWith('ok') ? 'badge-green'
|
|
: res?.startsWith('skipped') || res === 'no items' ? 'badge-yellow'
|
|
: res ? 'badge-red' : '';
|
|
document.getElementById('s-result').innerHTML = res ? `<span class="badge ${cls}">${res}</span>` : '—';
|
|
document.getElementById('s-next').textContent = fmt(st.next_calendar_run);
|
|
}
|
|
|
|
async function saveSchedule() {
|
|
const calendar_run_time = document.getElementById('f-time').value;
|
|
await post('/api/settings', {calendar_run_time});
|
|
toast('Schedule saved');
|
|
load();
|
|
}
|
|
|
|
async function pushCalendar() {
|
|
const btn = document.getElementById('push-cal-btn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Pushing…';
|
|
await post('/api/push-calendar', {});
|
|
toast('Triggered! Check status in a moment.');
|
|
setTimeout(() => { btn.disabled=false; btn.textContent='Push This Month to Calendar'; load(); }, 6000);
|
|
}
|
|
|
|
async function previewMenu() {
|
|
const btn = document.getElementById('preview-btn');
|
|
const box = document.getElementById('preview-box');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Loading…';
|
|
try {
|
|
const r = await fetch('/api/preview');
|
|
const data = await r.json();
|
|
if (data.error) {
|
|
box.innerHTML = `<div class="day-row-empty">Error: ${data.error}</div>`;
|
|
} else {
|
|
box.innerHTML = (data.days||[]).map(d => `
|
|
<div class="day-row">
|
|
<div class="day-row-date">${d.date}</div>
|
|
${d.items.length
|
|
? `<div class="day-row-items">${d.items.map(i=>'• '+i).join('\\n')}</div>`
|
|
: '<div class="day-row-empty">No menu posted</div>'}
|
|
</div>`).join('');
|
|
}
|
|
box.style.display = '';
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = "Preview This Month's Menu";
|
|
}
|
|
}
|
|
|
|
async function post(url, body) {
|
|
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
|
}
|
|
|
|
let toastTimer;
|
|
function toast(msg) {
|
|
const el = document.getElementById('toast');
|
|
el.textContent = msg;
|
|
el.classList.add('show');
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => el.classList.remove('show'), 3000);
|
|
}
|
|
|
|
load();
|
|
setInterval(load, 30000);
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
# ── Entrypoint ────────────────────────────────────────────────────────────────
|
|
|
|
if __name__ == "__main__":
|
|
# Always rewrite: creates the file on first run, and prunes keys the
|
|
# current schema no longer uses (see load_settings).
|
|
settings = load_settings()
|
|
save_settings(settings)
|
|
|
|
apply_calendar_schedule(settings)
|
|
threading.Thread(target=_schedule_loop, daemon=True).start()
|
|
log.info(f"Web UI → http://localhost:{PORT}")
|
|
app.run(host="0.0.0.0", port=PORT, debug=False, use_reloader=False)
|