Add carrier gateway field, calendar-only push, and menu preview to UI
All checks were successful
Deploy / deploy (push) Successful in 39s
All checks were successful
Deploy / deploy (push) Successful in 39s
Surfaces the settings/actions that only existed at the API or env-var level: carrier_gateway was already an accepted setting but had no form field; HA webhook and Gmail config were invisible from the UI entirely. Adds a calendar-only push and a side-effect-free preview so the HA sync and scraper can be tested without texting everyone. Secrets stay env-only since the UI has no auth — only configured/not-configured status is exposed. Also fixes calendar_result reporting "ok" when HA_WEBHOOK_URL wasn't set at all, and stops the calendar-only push from clobbering the SMS job's last_run/last_result status fields. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KsJqAZ82Veyf5oihBr71S5
This commit is contained in:
parent
a9ea0a2d87
commit
9c1112f28e
2 changed files with 197 additions and 36 deletions
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -26,6 +26,17 @@ Runs as one Docker container on the user's Unraid home server.
|
||||||
automation (webhook trigger → `calendar.create_event`) turns this into an
|
automation (webhook trigger → `calendar.create_event`) turns this into an
|
||||||
all-day calendar event with the menu. This makes the scrape here the
|
all-day calendar event with the menu. This makes the scrape here the
|
||||||
single source of truth — HA doesn't poll or re-scrape anything.
|
single source of truth — HA doesn't poll or re-scrape anything.
|
||||||
|
- **Manual actions (2026-09-03):** besides "Send Lunch Menu Now" (full job —
|
||||||
|
scrape, HA push, text everyone), the UI has "Push to Calendar Only"
|
||||||
|
(`POST /api/push-calendar` → `push_calendar_only()`, scrape + HA push, no
|
||||||
|
texts — for testing the calendar sync without spamming phones) and
|
||||||
|
"Preview Today's Menu" (`GET /api/preview` → `_scrape_menu()`, pure dry
|
||||||
|
run, no side effects at all). `carrier_gateway` is editable in the
|
||||||
|
Schedule card (dropdown of common US carriers + custom domain). Gmail
|
||||||
|
credentials and `HA_WEBHOOK_URL` stay env-only, not settings.json — the
|
||||||
|
UI has no auth, so secrets aren't made editable there; `GET
|
||||||
|
/api/integrations` just exposes whether each is configured (booleans,
|
||||||
|
not values) so the UI can show a badge.
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
|
|
|
||||||
222
app.py
222
app.py
|
|
@ -35,7 +35,13 @@ _DEFAULTS = {
|
||||||
"vacations": [],
|
"vacations": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
_status = {"last_run": None, "last_result": None, "last_message": None, "calendar_result": None}
|
_status = {
|
||||||
|
"last_run": None,
|
||||||
|
"last_result": None,
|
||||||
|
"last_message": None,
|
||||||
|
"calendar_result": None,
|
||||||
|
"last_calendar_run": None,
|
||||||
|
}
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -83,19 +89,49 @@ def _send_email_sms(message: str, phone: str, gateway: str) -> None:
|
||||||
log.info(f"Sent ✓ → {to_addr}")
|
log.info(f"Sent ✓ → {to_addr}")
|
||||||
|
|
||||||
|
|
||||||
def _push_to_home_assistant(items: list[str], date_str: str) -> bool:
|
def _push_to_home_assistant(items: list[str], date_str: str) -> None:
|
||||||
"""Returns True if a push was attempted and succeeded, False if skipped (not configured)."""
|
webhook_url = os.environ["HA_WEBHOOK_URL"]
|
||||||
webhook_url = os.environ.get("HA_WEBHOOK_URL")
|
|
||||||
if not webhook_url:
|
|
||||||
return False
|
|
||||||
log.info(f"Pushing menu → Home Assistant webhook")
|
log.info(f"Pushing menu → Home Assistant webhook")
|
||||||
resp = requests.post(webhook_url, json={"date": date_str, "items": items}, timeout=10)
|
resp = requests.post(webhook_url, json={"date": date_str, "items": items}, timeout=10)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
log.info("Home Assistant push ✓")
|
log.info("Home Assistant push ✓")
|
||||||
return True
|
|
||||||
|
|
||||||
|
def _scrape_menu() -> tuple[list[str], str | None, str]:
|
||||||
|
"""Fetch the menu and build the display/SMS message. No side effects."""
|
||||||
|
try:
|
||||||
|
items, date_str = get_lunch_menu()
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Menu fetch failed")
|
||||||
|
return [], None, f"Error fetching Mariposa lunch menu: {e}"
|
||||||
|
|
||||||
|
if items:
|
||||||
|
body = "\n".join(f"• {i}" for i in items)
|
||||||
|
message = f"Mariposa Lunch {date_str}:\n{body}"
|
||||||
|
else:
|
||||||
|
message = f"No lunch menu posted for {date_str} (may be a break or holiday)."
|
||||||
|
|
||||||
|
if len(message) > 1400:
|
||||||
|
message = message[:1397] + "..."
|
||||||
|
return items, date_str, message
|
||||||
|
|
||||||
|
|
||||||
|
def _push_calendar(items: list[str], date_str: str | None) -> str:
|
||||||
|
"""Push to Home Assistant if configured. Returns a calendar_result 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: {e}")
|
||||||
|
return f"error: {e}"
|
||||||
|
|
||||||
|
|
||||||
def run_job() -> None:
|
def run_job() -> None:
|
||||||
|
"""Full daily job: scrape, push to HA calendar, and text everyone."""
|
||||||
global _status
|
global _status
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
|
|
||||||
|
|
@ -108,30 +144,10 @@ def run_job() -> None:
|
||||||
phones = [p.strip() for p in settings.get("phone_numbers", []) if p.strip()]
|
phones = [p.strip() for p in settings.get("phone_numbers", []) if p.strip()]
|
||||||
gateway = settings.get("carrier_gateway", "vtext.com")
|
gateway = settings.get("carrier_gateway", "vtext.com")
|
||||||
|
|
||||||
try:
|
items, date_str, message = _scrape_menu()
|
||||||
items, date_str = get_lunch_menu()
|
|
||||||
if items:
|
|
||||||
body = "\n".join(f"• {i}" for i in items)
|
|
||||||
message = f"Mariposa Lunch {date_str}:\n{body}"
|
|
||||||
else:
|
|
||||||
message = f"No lunch menu posted for {date_str} (may be a break or holiday)."
|
|
||||||
except Exception as e:
|
|
||||||
log.exception("Menu fetch failed")
|
|
||||||
items, date_str = [], None
|
|
||||||
message = f"Error fetching Mariposa lunch menu: {e}"
|
|
||||||
|
|
||||||
if len(message) > 1400:
|
|
||||||
message = message[:1397] + "..."
|
|
||||||
|
|
||||||
log.info(f"Message:\n{message}")
|
log.info(f"Message:\n{message}")
|
||||||
|
|
||||||
calendar_result = "n/a"
|
calendar_result = _push_calendar(items, date_str)
|
||||||
if items:
|
|
||||||
try:
|
|
||||||
calendar_result = "ok" if _push_to_home_assistant(items, date_str) else "n/a"
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"Home Assistant push failed: {e}")
|
|
||||||
calendar_result = f"error: {e}"
|
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
for phone in phones:
|
for phone in phones:
|
||||||
|
|
@ -142,10 +158,25 @@ def run_job() -> None:
|
||||||
errors.append(str(e))
|
errors.append(str(e))
|
||||||
|
|
||||||
_status = {
|
_status = {
|
||||||
|
**_status,
|
||||||
"last_run": datetime.now().isoformat(),
|
"last_run": datetime.now().isoformat(),
|
||||||
"last_result": "error: " + "; ".join(errors) if errors else "ok",
|
"last_result": "error: " + "; ".join(errors) if errors else "ok",
|
||||||
"last_message": message,
|
"last_message": message,
|
||||||
"calendar_result": calendar_result,
|
"calendar_result": calendar_result,
|
||||||
|
"last_calendar_run": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def push_calendar_only() -> None:
|
||||||
|
"""Scrape and push to Home Assistant without texting anyone."""
|
||||||
|
global _status
|
||||||
|
items, date_str, message = _scrape_menu()
|
||||||
|
log.info(f"Calendar-only push — message:\n{message}")
|
||||||
|
calendar_result = _push_calendar(items, date_str)
|
||||||
|
_status = {
|
||||||
|
**_status,
|
||||||
|
"calendar_result": calendar_result,
|
||||||
|
"last_calendar_run": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -240,6 +271,27 @@ def api_send():
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/push-calendar", methods=["POST"])
|
||||||
|
def api_push_calendar():
|
||||||
|
threading.Thread(target=push_calendar_only, daemon=True).start()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/preview")
|
||||||
|
def api_preview():
|
||||||
|
"""Dry-run scrape: no SMS, no Home Assistant push."""
|
||||||
|
items, date_str, message = _scrape_menu()
|
||||||
|
return jsonify({"date": date_str, "items": items, "message": message})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/integrations")
|
||||||
|
def api_integrations():
|
||||||
|
return jsonify({
|
||||||
|
"sms_configured": bool(os.environ.get("GMAIL_USER") and os.environ.get("GMAIL_APP_PASSWORD")),
|
||||||
|
"ha_webhook_configured": bool(os.environ.get("HA_WEBHOOK_URL")),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/status")
|
@app.route("/api/status")
|
||||||
def api_status():
|
def api_status():
|
||||||
s = load_settings()
|
s = load_settings()
|
||||||
|
|
@ -373,12 +425,31 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
|
||||||
<div id="s-last-msg" style="display:none;margin-top:14px;padding:10px 12px;background:#1c2128;border-radius:8px;font-size:.8rem;color:#c9d1d9;white-space:pre-wrap;"></div>
|
<div id="s-last-msg" style="display:none;margin-top:14px;padding:10px 12px;background:#1c2128;border-radius:8px;font-size:.8rem;color:#c9d1d9;white-space:pre-wrap;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Send Now -->
|
<!-- Manual Actions -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Manual Send</div>
|
<div class="card-label">Manual Actions</div>
|
||||||
<button id="send-btn" class="btn btn-primary btn-send" onclick="sendNow()">
|
<button id="send-btn" class="btn btn-primary btn-send" onclick="sendNow()">
|
||||||
Send Lunch Menu Now
|
Send Lunch Menu Now
|
||||||
</button>
|
</button>
|
||||||
|
<div class="row-2" style="margin-top:10px;">
|
||||||
|
<button id="push-cal-btn" class="btn btn-ghost" onclick="pushCalendar()">
|
||||||
|
Push to Calendar Only
|
||||||
|
</button>
|
||||||
|
<button id="preview-btn" class="btn btn-ghost" onclick="previewMenu()">
|
||||||
|
Preview Today's Menu
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="preview-box" style="display:none;margin-top:14px;padding:10px 12px;background:#1c2128;border-radius:8px;font-size:.8rem;color:#c9d1d9;white-space:pre-wrap;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Integrations -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">Integrations</div>
|
||||||
|
<dl class="stat-grid">
|
||||||
|
<div class="stat"><dt>SMS (Gmail)</dt><dd id="i-sms">—</dd></div>
|
||||||
|
<div class="stat"><dt>Home Assistant</dt><dd id="i-ha">—</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div class="item-sub" style="margin-top:10px;">Configured via <code>.env</code> on the server, not here.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Schedule -->
|
<!-- Schedule -->
|
||||||
|
|
@ -392,6 +463,22 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
|
||||||
<label class="field-label">Days</label>
|
<label class="field-label">Days</label>
|
||||||
<div class="days-row" id="days-row"></div>
|
<div class="days-row" id="days-row"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">Carrier Gateway</label>
|
||||||
|
<select id="f-gateway" onchange="onGatewayChange()" style="width:100%;padding:9px 12px;border:1px solid var(--border);border-radius:8px;font-size:.875rem;color:var(--text);background:var(--card);">
|
||||||
|
<option value="vtext.com">Verizon (vtext.com)</option>
|
||||||
|
<option value="txt.att.net">AT&T (txt.att.net)</option>
|
||||||
|
<option value="tmomail.net">T-Mobile (tmomail.net)</option>
|
||||||
|
<option value="messaging.sprintpcs.com">Sprint (messaging.sprintpcs.com)</option>
|
||||||
|
<option value="sms.myboostmobile.com">Boost Mobile (sms.myboostmobile.com)</option>
|
||||||
|
<option value="sms.cricketwireless.net">Cricket (sms.cricketwireless.net)</option>
|
||||||
|
<option value="email.uscc.net">US Cellular (email.uscc.net)</option>
|
||||||
|
<option value="msg.fi.google.com">Google Fi (msg.fi.google.com)</option>
|
||||||
|
<option value="__custom__">Custom…</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="f-gateway-custom" placeholder="your-carrier-gateway.com"
|
||||||
|
style="margin-top:8px;display:none;">
|
||||||
|
</div>
|
||||||
<hr class="divider">
|
<hr class="divider">
|
||||||
<button class="btn btn-primary" onclick="saveSchedule()">Save Schedule</button>
|
<button class="btn btn-primary" onclick="saveSchedule()">Save Schedule</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -441,13 +528,27 @@ const DAY_LABELS = {mon:'Mon',tue:'Tue',wed:'Wed',thu:'Thu',fri:'Fri',sat:'Sat',
|
||||||
let cfg = {};
|
let cfg = {};
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const [s, st] = await Promise.all([
|
const [s, st, ig] = await Promise.all([
|
||||||
fetch('/api/settings').then(r=>r.json()),
|
fetch('/api/settings').then(r=>r.json()),
|
||||||
fetch('/api/status').then(r=>r.json()),
|
fetch('/api/status').then(r=>r.json()),
|
||||||
|
fetch('/api/integrations').then(r=>r.json()),
|
||||||
]);
|
]);
|
||||||
cfg = s;
|
cfg = s;
|
||||||
applySettings(s);
|
applySettings(s);
|
||||||
applyStatus(st);
|
applyStatus(st);
|
||||||
|
applyIntegrations(ig);
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN_GATEWAYS = ['vtext.com','txt.att.net','tmomail.net','messaging.sprintpcs.com',
|
||||||
|
'sms.myboostmobile.com','sms.cricketwireless.net','email.uscc.net','msg.fi.google.com'];
|
||||||
|
|
||||||
|
function applyIntegrations(ig) {
|
||||||
|
document.getElementById('i-sms').innerHTML = ig.sms_configured
|
||||||
|
? '<span class="badge badge-green">Configured</span>'
|
||||||
|
: '<span class="badge badge-yellow">Not configured</span>';
|
||||||
|
document.getElementById('i-ha').innerHTML = ig.ha_webhook_configured
|
||||||
|
? '<span class="badge badge-green">Configured</span>'
|
||||||
|
: '<span class="badge badge-yellow">Not configured</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmt(iso) {
|
function fmt(iso) {
|
||||||
|
|
@ -459,6 +560,19 @@ function fmt(iso) {
|
||||||
|
|
||||||
function applySettings(s) {
|
function applySettings(s) {
|
||||||
document.getElementById('f-time').value = s.run_time || '07:00';
|
document.getElementById('f-time').value = s.run_time || '07:00';
|
||||||
|
// Carrier gateway
|
||||||
|
const gw = s.carrier_gateway || 'vtext.com';
|
||||||
|
const gwSel = document.getElementById('f-gateway');
|
||||||
|
const gwCustom = document.getElementById('f-gateway-custom');
|
||||||
|
if (KNOWN_GATEWAYS.includes(gw)) {
|
||||||
|
gwSel.value = gw;
|
||||||
|
gwCustom.style.display = 'none';
|
||||||
|
gwCustom.value = '';
|
||||||
|
} else {
|
||||||
|
gwSel.value = '__custom__';
|
||||||
|
gwCustom.style.display = '';
|
||||||
|
gwCustom.value = gw;
|
||||||
|
}
|
||||||
// Days
|
// Days
|
||||||
const row = document.getElementById('days-row');
|
const row = document.getElementById('days-row');
|
||||||
row.innerHTML = DAYS.map(d =>
|
row.innerHTML = DAYS.map(d =>
|
||||||
|
|
@ -504,10 +618,11 @@ function applyStatus(st) {
|
||||||
: '<span class="badge badge-green">Active</span>';
|
: '<span class="badge badge-green">Active</span>';
|
||||||
const calStat = document.getElementById('s-cal-stat');
|
const calStat = document.getElementById('s-cal-stat');
|
||||||
const cal = st.calendar_result;
|
const cal = st.calendar_result;
|
||||||
if (cal && cal !== 'n/a') {
|
if (cal) {
|
||||||
calStat.style.display = '';
|
calStat.style.display = '';
|
||||||
const calCls = cal === 'ok' ? 'badge-green' : 'badge-red';
|
const calCls = cal === 'ok' ? 'badge-green' : cal.startsWith('error') ? 'badge-red' : 'badge-yellow';
|
||||||
document.getElementById('s-cal').innerHTML = `<span class="badge ${calCls}">${cal}</span>`;
|
const when = st.last_calendar_run ? `<div class="item-sub">${fmt(st.last_calendar_run)}</div>` : '';
|
||||||
|
document.getElementById('s-cal').innerHTML = `<span class="badge ${calCls}">${cal}</span>${when}`;
|
||||||
} else {
|
} else {
|
||||||
calStat.style.display = 'none';
|
calStat.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
@ -520,10 +635,20 @@ function applyStatus(st) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onGatewayChange() {
|
||||||
|
const sel = document.getElementById('f-gateway');
|
||||||
|
document.getElementById('f-gateway-custom').style.display = sel.value === '__custom__' ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
async function saveSchedule() {
|
async function saveSchedule() {
|
||||||
const run_time = document.getElementById('f-time').value;
|
const run_time = document.getElementById('f-time').value;
|
||||||
const run_days = [...document.querySelectorAll('.day-chip.on')].map(c=>c.dataset.day);
|
const run_days = [...document.querySelectorAll('.day-chip.on')].map(c=>c.dataset.day);
|
||||||
await post('/api/settings', {run_time, run_days});
|
const gwSel = document.getElementById('f-gateway').value;
|
||||||
|
const carrier_gateway = gwSel === '__custom__'
|
||||||
|
? document.getElementById('f-gateway-custom').value.trim()
|
||||||
|
: gwSel;
|
||||||
|
if (!carrier_gateway) { toast('Enter a carrier gateway domain'); return; }
|
||||||
|
await post('/api/settings', {run_time, run_days, carrier_gateway});
|
||||||
toast('Schedule saved');
|
toast('Schedule saved');
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
@ -575,6 +700,31 @@ async function sendNow() {
|
||||||
setTimeout(() => { btn.disabled=false; btn.textContent='Send Lunch Menu Now'; load(); }, 6000);
|
setTimeout(() => { btn.disabled=false; btn.textContent='Send Lunch Menu Now'; load(); }, 6000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pushCalendar() {
|
||||||
|
const btn = document.getElementById('push-cal-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Pushing…';
|
||||||
|
await post('/api/push-calendar', {});
|
||||||
|
toast('Pushed! Check status in a moment.');
|
||||||
|
setTimeout(() => { btn.disabled=false; btn.textContent='Push to Calendar Only'; 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();
|
||||||
|
box.textContent = data.message;
|
||||||
|
box.style.display = '';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "Preview Today's Menu";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function post(url, body) {
|
async function post(url, body) {
|
||||||
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue