diff --git a/CLAUDE.md b/CLAUDE.md index aad396c..64f5afe 100644 --- a/CLAUDE.md +++ b/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 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 diff --git a/app.py b/app.py index e2d750d..72bf798 100644 --- a/app.py +++ b/app.py @@ -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
- +.env on the server, not here.