""" 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")), }) @app.route("/api/status") def api_status(): return jsonify({ **_status, "next_calendar_run": next_calendar_run(load_settings()).isoformat(), }) # ── Embedded UI ─────────────────────────────────────────────────────────────── HTML = """
Kyrene de la Mariposa · Menu → Home Assistant calendar
.env (HA_WEBHOOK_URL, ANTHROPIC_API_KEY) on the server, not here.