Add carrier gateway field, calendar-only push, and menu preview to UI
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:
Barely Removable 2026-09-03 07:18:12 -07:00
parent a9ea0a2d87
commit 9c1112f28e
2 changed files with 197 additions and 36 deletions

View file

@ -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
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.
- **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

222
app.py
View file

@ -35,7 +35,13 @@ _DEFAULTS = {
"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()
@ -83,19 +89,49 @@ def _send_email_sms(message: str, phone: str, gateway: str) -> None:
log.info(f"Sent ✓ → {to_addr}")
def _push_to_home_assistant(items: list[str], date_str: str) -> bool:
"""Returns True if a push was attempted and succeeded, False if skipped (not configured)."""
webhook_url = os.environ.get("HA_WEBHOOK_URL")
if not webhook_url:
return False
def _push_to_home_assistant(items: list[str], date_str: str) -> None:
webhook_url = os.environ["HA_WEBHOOK_URL"]
log.info(f"Pushing menu → Home Assistant webhook")
resp = requests.post(webhook_url, json={"date": date_str, "items": items}, timeout=10)
resp.raise_for_status()
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:
"""Full daily job: scrape, push to HA calendar, and text everyone."""
global _status
settings = load_settings()
@ -108,30 +144,10 @@ def run_job() -> None:
phones = [p.strip() for p in settings.get("phone_numbers", []) if p.strip()]
gateway = settings.get("carrier_gateway", "vtext.com")
try:
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] + "..."
items, date_str, message = _scrape_menu()
log.info(f"Message:\n{message}")
calendar_result = "n/a"
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}"
calendar_result = _push_calendar(items, date_str)
errors = []
for phone in phones:
@ -142,10 +158,25 @@ def run_job() -> None:
errors.append(str(e))
_status = {
**_status,
"last_run": datetime.now().isoformat(),
"last_result": "error: " + "; ".join(errors) if errors else "ok",
"last_message": message,
"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})
@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")
def api_status():
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>
<!-- Send Now -->
<!-- Manual Actions -->
<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()">
Send Lunch Menu Now
</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>
<!-- Schedule -->
@ -392,6 +463,22 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
<label class="field-label">Days</label>
<div class="days-row" id="days-row"></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&amp;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&hellip;</option>
</select>
<input type="text" id="f-gateway-custom" placeholder="your-carrier-gateway.com"
style="margin-top:8px;display:none;">
</div>
<hr class="divider">
<button class="btn btn-primary" onclick="saveSchedule()">Save Schedule</button>
</div>
@ -441,13 +528,27 @@ const DAY_LABELS = {mon:'Mon',tue:'Tue',wed:'Wed',thu:'Thu',fri:'Fri',sat:'Sat',
let cfg = {};
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/status').then(r=>r.json()),
fetch('/api/integrations').then(r=>r.json()),
]);
cfg = s;
applySettings(s);
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) {
@ -459,6 +560,19 @@ function fmt(iso) {
function applySettings(s) {
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
const row = document.getElementById('days-row');
row.innerHTML = DAYS.map(d =>
@ -504,10 +618,11 @@ function applyStatus(st) {
: '<span class="badge badge-green">Active</span>';
const calStat = document.getElementById('s-cal-stat');
const cal = st.calendar_result;
if (cal && cal !== 'n/a') {
if (cal) {
calStat.style.display = '';
const calCls = cal === 'ok' ? 'badge-green' : 'badge-red';
document.getElementById('s-cal').innerHTML = `<span class="badge ${calCls}">${cal}</span>`;
const calCls = cal === 'ok' ? 'badge-green' : cal.startsWith('error') ? 'badge-red' : 'badge-yellow';
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 {
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() {
const run_time = document.getElementById('f-time').value;
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');
load();
}
@ -575,6 +700,31 @@ async function sendNow() {
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) {
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
}