lunch-notifier/app.py
Barely Removable a809aa5a5a
All checks were successful
Deploy / deploy (push) Successful in 35s
Switch menu source from Nutrislice to Claude-read PDF, weekly to monthly pull
The district discontinued Nutrislice in favor of a monthly PDF calendar
per grade band on kyrene.org. That PDF is a graphic calendar grid, not
reliably text-extractable, so scraper.py now sends it to Claude (vision +
structured JSON output) instead of hitting a JSON API. Fetches through a
stable Finalsite resource-manager permalink that always redirects to
whichever month is currently published, so no code change is needed each
month.

Also moves the pull schedule from weekly (day-of-week list) to monthly
(day-of-month), since the source itself is now monthly — defaults to the
25th, since the district tends to post next month's PDF a few days early.

Requires a new ANTHROPIC_API_KEY in the server's .env.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RbBoYm23muVyMZ18Ldqa6
2026-09-08 07:12:34 -07:00

502 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 calendar
import json
import logging
import os
import threading
import time
from datetime import datetime
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)
_DEFAULTS = {
"calendar_run_time": os.environ.get("CALENDAR_RUN_TIME", "18:00"),
"calendar_run_day_of_month": int(os.environ.get("CALENDAR_RUN_DAY_OF_MONTH", "25")),
}
_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 (e.g. the pre-calendar SMS-era file, or the
# weekly-schedule era before calendar_run_day_of_month
# existed) still gets a valid schedule rather than silently
# scheduling nothing.
return {**_DEFAULTS, **json.load(f)}
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 that
has a posted menu — days with nothing posted (weekends, breaks,
holidays) are skipped automatically since there's nothing to create an
event from. Runs on its own schedule (calendar_run_time/
calendar_run_day_of_month), independent of anything else."""
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
pushed = errors = no_items = 0
for day in month:
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")
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"
_status = {
**_status,
"last_calendar_run": datetime.now().isoformat(),
"calendar_result": summary,
}
# ── Scheduler ─────────────────────────────────────────────────────────────────
def _maybe_push_month(day_of_month: int) -> None:
"""schedule fires this daily at run_time; only actually pushes on the
configured day of the month (the `schedule` library has no native
day-of-month trigger)."""
if datetime.now().day == day_of_month:
push_month_to_calendar()
def apply_calendar_schedule(settings: dict) -> None:
with _lock:
schedule.clear("calendar")
run_time = settings.get("calendar_run_time", "18:00")
day_of_month = settings.get("calendar_run_day_of_month", 25)
schedule.every().day.at(run_time).do(_maybe_push_month, day_of_month).tag("calendar")
log.info(f"Scheduled calendar pull: day {day_of_month} of each month at {run_time}")
def next_calendar_run(settings: dict) -> datetime:
"""The `schedule` job above fires daily (it has no native day-of-month
trigger — see _maybe_push_month), so its own next_run is just
"tomorrow", not the actual next push. Compute the real one directly
from settings for display in the UI."""
run_time = settings.get("calendar_run_time", "18:00")
hour, minute = (int(p) for p in run_time.split(":"))
day_of_month = settings.get("calendar_run_day_of_month", 25)
now = datetime.now()
candidate = now.replace(
day=min(day_of_month, calendar.monthrange(now.year, now.month)[1]),
hour=hour, minute=minute, second=0, microsecond=0,
)
if candidate <= now:
y, m = (now.year, now.month + 1) if now.month < 12 else (now.year + 1, 1)
candidate = candidate.replace(
year=y, month=m, day=min(day_of_month, calendar.monthrange(y, m)[1]),
)
return candidate
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 ("calendar_run_time", "calendar_run_day_of_month"):
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")),
})
@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 &middot; Menu &rarr; 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="field">
<label class="field-label">Pull Day of Month</label>
<input type="number" id="f-day" min="1" max="28" style="max-width:100px">
<div class="item-sub" style="margin-top:8px;">Fires once a month and pushes whichever month is currently published &mdash; e.g. day 25 catches next month's menu once the district posts it near month-end. There's no dedup, so re-pushing the same month creates duplicate calendar events.</div>
</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';
document.getElementById('f-day').value = s.calendar_run_day_of_month || 25;
}
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;
const calendar_run_day_of_month = parseInt(document.getElementById('f-day').value, 10);
await post('/api/settings', {calendar_run_time, calendar_run_day_of_month});
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 Week 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 Week'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__":
if not SETTINGS_FILE.exists():
log.info(f"Creating default settings at {SETTINGS_FILE}")
save_settings(_DEFAULTS)
apply_calendar_schedule(load_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)