Pivot from SMS to calendar-only: weekly pull, independent schedule
All checks were successful
Deploy / deploy (push) Successful in 36s
All checks were successful
Deploy / deploy (push) Successful in 36s
Deletes the entire SMS path at the user's request — Gmail SMTP, phone number management, carrier gateway settings, and the daily SMS+calendar job are all gone. Home Assistant calendar events are now the only output. - scraper.py: get_week_menu() returns the whole school week in one call (the Nutrislice API is week-granular regardless of the date requested); drops the now-unused single-day get_lunch_menu() and the send_sms()/ CLI entrypoint that only the SMS path used. - app.py: push_week_to_calendar() fetches the week and pushes every day with a posted menu; runs on its own schedule (calendar_run_time/ calendar_run_days, tagged "calendar" jobs) fully decoupled from anything else, since neither calendar target supports delete-based dedup and double-firing would create duplicate events. Manual actions become "Push This Week to Calendar" and "Preview This Week's Menu" (now a real dry-run over the whole week, not just today). - Deleted the vacation/no-push date range feature: a day with no posted menu already produces empty items, and pushing already skips empty items — the extra vacation-range layer for suppressing days the school *did* post a menu for was judged unnecessary and removed along with its UI, settings field, and API endpoints. - Deleted scheduler.py, a dead alternate entrypoint (unused by Dockerfile/ docker-compose) that only worked via the now-deleted scraper.main(). - load_settings() now merges the file over _DEFAULTS instead of returning it verbatim — the server's existing settings.json predates this schema and has neither calendar_run_time nor calendar_run_days, which would otherwise silently schedule zero jobs after this deploys. Verified with a migration test using the server's actual old-schema settings shape. All of this was exercised for real: Docker builds, live containers against a mock webhook (single-day and full-week pushes, per-entity dedup behavior), a migration test with the pre-existing settings.json shape, and a Chrome pass over the rebuilt UI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KsJqAZ82Veyf5oihBr71S5
This commit is contained in:
parent
d3498849d3
commit
1c31ec780b
6 changed files with 231 additions and 646 deletions
34
.env.example
34
.env.example
|
|
@ -1,30 +1,20 @@
|
||||||
# Copy this to .env and fill in your values
|
# Copy this to .env and fill in your values
|
||||||
|
|
||||||
# --- Required ---
|
# --- Required ---
|
||||||
# 10-digit phone number (no + or country code)
|
# Home Assistant webhook URL that receives the day's menu and creates a
|
||||||
PHONE_NUMBER=4805405363
|
# calendar event (see automation.lunch_menu_family_calendar in HA).
|
||||||
|
# e.g. http://192.168.86.X:8123/api/webhook/<random-token>
|
||||||
# Your carrier's SMS email gateway
|
|
||||||
# Verizon: vtext.com | T-Mobile: tmomail.net | AT&T: txt.att.net | Cricket: sms.cricketwireless.net
|
|
||||||
CARRIER_GATEWAY=vtext.com
|
|
||||||
|
|
||||||
# Gmail address to send from
|
|
||||||
GMAIL_USER=you@gmail.com
|
|
||||||
|
|
||||||
# Gmail App Password (myaccount.google.com → Security → App passwords)
|
|
||||||
# Remove spaces from the 16-char code
|
|
||||||
GMAIL_APP_PASSWORD=xxxxxxxxxxxxxxxxxxxx
|
|
||||||
|
|
||||||
# --- Optional ---
|
|
||||||
# Home Assistant webhook URL to push the day's menu to (for a calendar-event
|
|
||||||
# automation). Leave unset to skip. e.g. http://192.168.86.X:8123/api/webhook/<id>
|
|
||||||
HA_WEBHOOK_URL=
|
HA_WEBHOOK_URL=
|
||||||
|
|
||||||
# Time to send the text each day (24-hour HH:MM, container timezone)
|
# --- Optional ---
|
||||||
RUN_TIME=07:00
|
# Day(s) of week to pull the upcoming school week and push it to the
|
||||||
|
# calendar (comma-separated: mon,tue,wed,thu,fri,sat,sun). Picking more
|
||||||
|
# than one day creates duplicate events for days already pushed — most
|
||||||
|
# people want just one.
|
||||||
|
CALENDAR_RUN_DAYS=sun
|
||||||
|
|
||||||
# Days to run (comma-separated: mon,tue,wed,thu,fri,sat,sun)
|
# Time to run the pull (24-hour HH:MM, container timezone)
|
||||||
RUN_DAYS=mon,tue,wed,thu,fri
|
CALENDAR_RUN_TIME=18:00
|
||||||
|
|
||||||
# HTTP trigger server port
|
# HTTP server port
|
||||||
PORT=2323
|
PORT=2323
|
||||||
|
|
|
||||||
137
CLAUDE.md
137
CLAUDE.md
|
|
@ -1,57 +1,82 @@
|
||||||
# Lunch Notifier
|
# Lunch Notifier
|
||||||
|
|
||||||
Single-file Flask app that scrapes the daily Mariposa (Kyrene school
|
Single-file Flask app that scrapes the weekly Mariposa (Kyrene school
|
||||||
district) lunch menu and texts it to a list of phone numbers via
|
district) lunch menu and pushes each day to a Home Assistant calendar as an
|
||||||
email-to-SMS carrier gateways (e.g. `4805551234@vtext.com` for Verizon).
|
all-day event. Runs as one Docker container on the user's Unraid home
|
||||||
Runs as one Docker container on the user's Unraid home server.
|
server.
|
||||||
|
|
||||||
|
**2026-09-03: pivoted from SMS to calendar-only.** Originally texted phone
|
||||||
|
numbers via email-to-SMS carrier gateways; that whole path (Gmail SMTP,
|
||||||
|
phone number management, carrier gateway settings) was deleted at the
|
||||||
|
user's request in favor of Home Assistant calendar events exclusively. If
|
||||||
|
you find references to texting/SMS/phone numbers anywhere (docs, old
|
||||||
|
commits), they describe the pre-2026-09-03 app, not the current one.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **`app.py`** — everything: Flask web UI/API, APScheduler-style job
|
- **`app.py`** — everything: Flask web UI/API, `schedule`-library job
|
||||||
scheduling (`schedule` library), and the email-to-SMS send logic
|
scheduling, and the Home Assistant webhook push. The HTML/CSS/JS frontend
|
||||||
(`smtplib` via Gmail, app password auth). The HTML/CSS/JS frontend is
|
is embedded as a Python string constant (`HTML`) rather than a separate
|
||||||
embedded as a Python string constant (`HTML`) rather than a separate
|
|
||||||
template file — no build step, no template engine.
|
template file — no build step, no template engine.
|
||||||
- **`scraper.py`** — fetches and parses the school's published lunch menu.
|
- **`scraper.py`** — fetches and parses the school's published lunch menu
|
||||||
- **`scheduler.py`** — day-of-week/time scheduling helpers used by `app.py`.
|
from Nutrislice's API. `get_week_menu(date_str)` returns every day in the
|
||||||
- Settings (phone numbers, send time, days, vacation date ranges) persist
|
API's week-granular response as `[{"date": ..., "items": [...]}, ...]`
|
||||||
to `/data/settings.json`, not environment variables — env vars
|
(the API returns a full week regardless of which single date you ask
|
||||||
(`PHONE_NUMBER`, `RUN_TIME`, `RUN_DAYS`) are defaults used only the very
|
for). Days with no posted menu (weekends, breaks, holidays) come back
|
||||||
first time, before a settings file exists.
|
with an empty `items` list rather than being omitted.
|
||||||
- Vacation ranges suppress sends without disabling the schedule — checked
|
- Settings (calendar pull schedule) persist to `/data/settings.json`, not
|
||||||
fresh on every run via `is_on_vacation()`.
|
environment variables — env vars (`CALENDAR_RUN_TIME`, `CALENDAR_RUN_DAYS`)
|
||||||
- **Home Assistant integration (2026-09-02, HA side built 2026-09-03):** if
|
are defaults used only the very first time, before a settings file exists.
|
||||||
`HA_WEBHOOK_URL` is set, every successful menu fetch also POSTs
|
`load_settings()` merges the file's contents *over* `_DEFAULTS` rather
|
||||||
`{"date": ..., "items": [...]}` to that webhook, independent of whether
|
than returning the file verbatim — this matters because the server's
|
||||||
the SMS send itself succeeds. On the HA side (`automation.lunch_menu_family_calendar`,
|
settings.json predates this schema (it's from the SMS era) and lacks
|
||||||
`http://192.168.86.207:8123`), a `local_only` webhook trigger runs
|
`calendar_run_time`/`calendar_run_days` entirely; without the merge, the
|
||||||
`calendar.create_event` against `calendar.family_2` (the "Family" Google
|
scheduler would silently come up with zero jobs after every deploy until
|
||||||
Calendar entity — there are two entities both named "Family" in this HA
|
someone happened to re-save the Schedule card in the UI. Verified via a
|
||||||
instance, `calendar.family` and `calendar.family_2`; this integration
|
migration test (old-schema settings.json + fresh container → correct
|
||||||
deliberately targets `_2`), producing an all-day event with the menu as
|
defaults filled in, `next_calendar_run` populated).
|
||||||
the description (bulleted). This makes the scrape here the single
|
- **No vacation/no-push date ranges (removed 2026-09-03, briefly existed).**
|
||||||
source of truth — HA doesn't poll or re-scrape anything.
|
A day with no posted menu already produces an empty `items` list from
|
||||||
`calendar.family_2` only supports `create_event`, not `delete_event` —
|
`get_week_menu()`, and `_push_calendar()` already skips pushing when
|
||||||
a bad push can't be cleaned up via the HA API, only manually in Google
|
`items` is empty — that's sufficient for "don't create an event for a day
|
||||||
Calendar. No duplicate-prevention either way: re-pushing the same
|
with nothing happening." A separate vacation-range setting to *additionally*
|
||||||
date's menu creates a second event. The webhook id is a random token
|
suppress days the school did post a menu for was judged unnecessary
|
||||||
that lives only in the server's `.env` (`HA_WEBHOOK_URL`) and in the
|
complexity and deleted at the user's request; don't re-add it without
|
||||||
automation's trigger config — never committed here. If the calendar
|
being asked.
|
||||||
push stops working, check both ends: `GET /api/integrations` on this
|
- **Home Assistant integration (built 2026-09-02/03, calendar-only pivot
|
||||||
app reports whether `HA_WEBHOOK_URL` is set at all, and
|
2026-09-03):** `HA_WEBHOOK_URL` is now required, not optional — it's the
|
||||||
|
only way this app does anything externally-visible. On the HA side
|
||||||
|
(`automation.lunch_menu_family_calendar`, `http://192.168.86.207:8123`),
|
||||||
|
a `local_only` webhook trigger runs `calendar.create_event` against
|
||||||
|
`calendar.family_2` (the "Family" Google Calendar entity — there are two
|
||||||
|
entities both named "Family" in this HA instance, `calendar.family` and
|
||||||
|
`calendar.family_2`; this integration deliberately targets `_2` per user
|
||||||
|
choice). `calendar.family_2` only supports `create_event`, not
|
||||||
|
`delete_event` — a bad push can't be cleaned up via the HA API, only
|
||||||
|
manually in Google Calendar. No duplicate-prevention: re-pushing the
|
||||||
|
same date's menu creates a second event — this is why the default
|
||||||
|
schedule is a single day/week, not daily (see below). The webhook id is
|
||||||
|
a random token that lives only in the server's `.env` (`HA_WEBHOOK_URL`)
|
||||||
|
and in the automation's trigger config — never committed here. If the
|
||||||
|
calendar push stops working, check both ends: `GET /api/integrations`
|
||||||
|
on this app reports whether `HA_WEBHOOK_URL` is set at all, and
|
||||||
`ha_get_automation_traces` on `automation.lunch_menu_family_calendar`
|
`ha_get_automation_traces` on `automation.lunch_menu_family_calendar`
|
||||||
shows whether HA is even receiving the webhook.
|
shows whether HA is even receiving the webhook.
|
||||||
- **Manual actions (2026-09-03):** besides "Send Lunch Menu Now" (full job —
|
- **Independent weekly-pull scheduler (2026-09-03):** calendar pulls run on
|
||||||
scrape, HA push, text everyone), the UI has "Push to Calendar Only"
|
their own schedule (`calendar_run_time`/`calendar_run_days` in settings,
|
||||||
(`POST /api/push-calendar` → `push_calendar_only()`, scrape + HA push, no
|
`schedule` jobs tagged `"calendar"`), completely decoupled from any other
|
||||||
texts — for testing the calendar sync without spamming phones) and
|
job — there's nothing else in this app anymore, but the tag/isolation
|
||||||
"Preview Today's Menu" (`GET /api/preview` → `_scrape_menu()`, pure dry
|
pattern (`schedule.clear("calendar")` instead of a bare `schedule.clear()`)
|
||||||
run, no side effects at all). `carrier_gateway` is editable in the
|
is deliberate so a future second schedule wouldn't stomp this one.
|
||||||
Schedule card (dropdown of common US carriers + custom domain). Gmail
|
`push_week_to_calendar()` fetches the whole current school week and
|
||||||
credentials and `HA_WEBHOOK_URL` stay env-only, not settings.json — the
|
pushes every day with a posted menu. Default is Sunday 18:00 (one
|
||||||
UI has no auth, so secrets aren't made editable there; `GET
|
firing/week) — the UI warns that picking more than one day/week creates
|
||||||
/api/integrations` just exposes whether each is configured (booleans,
|
duplicate events for days already covered, since there's no dedup.
|
||||||
not values) so the UI can show a badge.
|
- **Manual actions:** "Push This Week to Calendar" (`POST /api/push-calendar`
|
||||||
|
→ `push_week_to_calendar()`, same function the schedule calls) and
|
||||||
|
"Preview This Week's Menu" (`GET /api/preview` → `get_week_menu()`
|
||||||
|
directly, pure dry run, no Home Assistant push, no side effects at all —
|
||||||
|
use this to check the scraper before trusting a scheduled push).
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
|
|
@ -60,10 +85,10 @@ pip install -r requirements.txt
|
||||||
python3 app.py
|
python3 app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Needs `GMAIL_USER` and `GMAIL_APP_PASSWORD` env vars to actually send
|
Needs `HA_WEBHOOK_URL` set to actually push anywhere; without it the app
|
||||||
(a Gmail **app password**, not the account password — required since Gmail
|
still runs and previews fine, `GET /api/integrations` just reports
|
||||||
disallows plain SMTP auth with the real password). Visit
|
`ha_webhook_configured: false` and pushes resolve to `"not configured"`.
|
||||||
`http://localhost:2323`.
|
Visit `http://localhost:2323`.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|
@ -79,13 +104,15 @@ config, HTTPS-not-SSH git auth, known tradeoffs). In short: push to `main`
|
||||||
on the `forgejo` remote (`https://git.hippofam.com/ninja_hippo/lunch-notifier.git`)
|
on the `forgejo` remote (`https://git.hippofam.com/ninja_hippo/lunch-notifier.git`)
|
||||||
triggers `.forgejo/workflows/deploy.yml`, which syncs into
|
triggers `.forgejo/workflows/deploy.yml`, which syncs into
|
||||||
`/mnt/user/appdata/lunch-notifier` (excluding `.git`, `.env`, `data/`) and
|
`/mnt/user/appdata/lunch-notifier` (excluding `.git`, `.env`, `data/`) and
|
||||||
runs `docker compose up -d --build`.
|
runs `docker compose up -d --build`. **`.env` changes need an explicit
|
||||||
|
`docker compose up -d` on the server afterward** — env vars only load at
|
||||||
|
container start, a deploy that doesn't change code won't pick up a
|
||||||
|
manually-edited `.env` on its own.
|
||||||
|
|
||||||
**Persistent state, never touched by deploys:** `data/settings.json` (the
|
**Persistent state, never touched by deploys:** `data/settings.json` (the
|
||||||
volume-mounted `/data` inside the container) holds phone numbers, schedule,
|
volume-mounted `/data` inside the container) holds the calendar pull
|
||||||
and vacation ranges — this is live user data, not deploy-managed. `.env`
|
schedule — this is live user data, not deploy-managed. `.env` holds
|
||||||
holds `GMAIL_USER`/`GMAIL_APP_PASSWORD`/`PHONE_NUMBER` etc. and is
|
`HA_WEBHOOK_URL` and is server-only, gitignored, never committed.
|
||||||
server-only, gitignored, never committed.
|
|
||||||
|
|
||||||
**Migration note (2026-08-25):** this container originally used a Docker
|
**Migration note (2026-08-25):** this container originally used a Docker
|
||||||
**named volume** (`lunch-notifier_lunch-settings`) instead of a bind mount.
|
**named volume** (`lunch-notifier_lunch-settings`) instead of a bind mount.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="🍱 Lunch Notifier"
|
LABEL org.opencontainers.image.title="🍱 Lunch Notifier"
|
||||||
LABEL org.opencontainers.image.description="Kyrene de la Mariposa lunch menu → SMS"
|
LABEL org.opencontainers.image.description="Kyrene de la Mariposa lunch menu → Home Assistant calendar"
|
||||||
LABEL org.opencontainers.image.url="http://localhost:2323"
|
LABEL org.opencontainers.image.url="http://localhost:2323"
|
||||||
LABEL org.opencontainers.image.icon="https://resources.finalsite.net/images/f_auto,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png"
|
LABEL org.opencontainers.image.icon="https://resources.finalsite.net/images/f_auto,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png"
|
||||||
LABEL net.unraid.docker.icon="http://192.168.86.33:2323/icon.png"
|
LABEL net.unraid.docker.icon="http://192.168.86.33:2323/icon.png"
|
||||||
|
|
|
||||||
493
app.py
493
app.py
|
|
@ -1,22 +1,20 @@
|
||||||
"""
|
"""
|
||||||
Lunch Notifier — web UI + scheduler combined.
|
Lunch Notifier — pulls the Mariposa lunch menu and pushes it to a Home
|
||||||
Visit http://localhost:2323 to manage settings.
|
Assistant calendar. Visit http://localhost:2323 to manage settings.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import smtplib
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import date, datetime
|
from datetime import datetime
|
||||||
from email.message import EmailMessage
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import schedule
|
import schedule
|
||||||
from flask import Flask, jsonify, request
|
from flask import Flask, jsonify, request
|
||||||
|
|
||||||
from scraper import get_lunch_menu
|
from scraper import get_week_menu
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
@ -28,20 +26,11 @@ PORT = int(os.environ.get("PORT", "2323"))
|
||||||
|
|
||||||
# Env-var defaults (used only when no settings file exists yet)
|
# Env-var defaults (used only when no settings file exists yet)
|
||||||
_DEFAULTS = {
|
_DEFAULTS = {
|
||||||
"phone_numbers": [p for p in [os.environ.get("PHONE_NUMBER", "")] if p],
|
"calendar_run_time": os.environ.get("CALENDAR_RUN_TIME", "18:00"),
|
||||||
"carrier_gateway": os.environ.get("CARRIER_GATEWAY", "vtext.com"),
|
"calendar_run_days": [d.strip() for d in os.environ.get("CALENDAR_RUN_DAYS", "sun").split(",") if d.strip()],
|
||||||
"run_time": os.environ.get("RUN_TIME", "07:00"),
|
|
||||||
"run_days": [d.strip() for d in os.environ.get("RUN_DAYS", "mon,tue,wed,thu,fri").split(",") if d.strip()],
|
|
||||||
"vacations": [],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_status = {
|
_status = {"last_calendar_run": None, "calendar_result": None}
|
||||||
"last_run": None,
|
|
||||||
"last_result": None,
|
|
||||||
"last_message": None,
|
|
||||||
"calendar_result": None,
|
|
||||||
"last_calendar_run": None,
|
|
||||||
}
|
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -51,7 +40,11 @@ def load_settings() -> dict:
|
||||||
if SETTINGS_FILE.exists():
|
if SETTINGS_FILE.exists():
|
||||||
try:
|
try:
|
||||||
with open(SETTINGS_FILE) as f:
|
with open(SETTINGS_FILE) as f:
|
||||||
return json.load(f)
|
# Merge over _DEFAULTS so a settings.json from before a schema
|
||||||
|
# change (e.g. the pre-calendar SMS-era file) still gets
|
||||||
|
# calendar_run_time/calendar_run_days rather than silently
|
||||||
|
# scheduling nothing.
|
||||||
|
return {**_DEFAULTS, **json.load(f)}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return dict(_DEFAULTS)
|
return dict(_DEFAULTS)
|
||||||
|
|
@ -63,61 +56,18 @@ def save_settings(s: dict) -> None:
|
||||||
json.dump(s, f, indent=2)
|
json.dump(s, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
def is_on_vacation(settings: dict) -> str | None:
|
# ── Calendar push ─────────────────────────────────────────────────────────────
|
||||||
today = date.today().isoformat()
|
|
||||||
for v in settings.get("vacations", []):
|
|
||||||
if v["start"] <= today <= v["end"]:
|
|
||||||
return v.get("label", "Vacation")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ── Core job ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _send_email_sms(message: str, phone: str, gateway: str) -> None:
|
|
||||||
gmail_user = os.environ["GMAIL_USER"]
|
|
||||||
gmail_pass = os.environ["GMAIL_APP_PASSWORD"]
|
|
||||||
to_addr = f"{phone}@{gateway}"
|
|
||||||
msg = EmailMessage()
|
|
||||||
msg["From"] = gmail_user
|
|
||||||
msg["To"] = to_addr
|
|
||||||
msg.set_content(message)
|
|
||||||
log.info(f"Sending → {to_addr}")
|
|
||||||
with smtplib.SMTP("smtp.gmail.com", 587, timeout=15) as smtp:
|
|
||||||
smtp.starttls()
|
|
||||||
smtp.login(gmail_user, gmail_pass)
|
|
||||||
smtp.send_message(msg)
|
|
||||||
log.info(f"Sent ✓ → {to_addr}")
|
|
||||||
|
|
||||||
|
|
||||||
def _push_to_home_assistant(items: list[str], date_str: str) -> None:
|
def _push_to_home_assistant(items: list[str], date_str: str) -> None:
|
||||||
webhook_url = os.environ["HA_WEBHOOK_URL"]
|
webhook_url = os.environ["HA_WEBHOOK_URL"]
|
||||||
log.info(f"Pushing menu → Home Assistant webhook")
|
log.info(f"Pushing {date_str} 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(f"Home Assistant push ✓ ({date_str})")
|
||||||
|
|
||||||
|
|
||||||
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:
|
def _push_calendar(items: list[str], date_str: str | None) -> str:
|
||||||
"""Push to Home Assistant if configured. Returns a calendar_result status string."""
|
"""Push one day to Home Assistant if configured. Returns a status string."""
|
||||||
if not items:
|
if not items:
|
||||||
return "no items"
|
return "no items"
|
||||||
if not os.environ.get("HA_WEBHOOK_URL"):
|
if not os.environ.get("HA_WEBHOOK_URL"):
|
||||||
|
|
@ -126,57 +76,48 @@ def _push_calendar(items: list[str], date_str: str | None) -> str:
|
||||||
_push_to_home_assistant(items, date_str)
|
_push_to_home_assistant(items, date_str)
|
||||||
return "ok"
|
return "ok"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Home Assistant push failed: {e}")
|
log.error(f"Home Assistant push failed ({date_str}): {e}")
|
||||||
return f"error: {e}"
|
return f"error: {e}"
|
||||||
|
|
||||||
|
|
||||||
def run_job() -> None:
|
def push_week_to_calendar() -> None:
|
||||||
"""Full daily job: scrape, push to HA calendar, and text everyone."""
|
"""Fetch the current school week 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_days),
|
||||||
|
independent of anything else."""
|
||||||
global _status
|
global _status
|
||||||
settings = load_settings()
|
|
||||||
|
|
||||||
vac = is_on_vacation(settings)
|
try:
|
||||||
if vac:
|
week = get_week_menu()
|
||||||
log.info(f"Skipping — vacation: {vac}")
|
except Exception as e:
|
||||||
_status = {**_status, "last_run": datetime.now().isoformat(), "last_result": f"skipped ({vac})"}
|
log.exception("Week fetch failed")
|
||||||
|
_status = {**_status, "last_calendar_run": datetime.now().isoformat(), "calendar_result": f"error: {e}"}
|
||||||
return
|
return
|
||||||
|
|
||||||
phones = [p.strip() for p in settings.get("phone_numbers", []) if p.strip()]
|
pushed = errors = no_items = 0
|
||||||
gateway = settings.get("carrier_gateway", "vtext.com")
|
for day in week:
|
||||||
|
result = _push_calendar(day["items"], day["date"])
|
||||||
|
if result == "ok":
|
||||||
|
pushed += 1
|
||||||
|
elif result == "no items":
|
||||||
|
no_items += 1
|
||||||
|
else:
|
||||||
|
errors += 1
|
||||||
|
|
||||||
items, date_str, message = _scrape_menu()
|
log.info(f"Week push: {pushed} pushed, {errors} errors, {no_items} no menu")
|
||||||
log.info(f"Message:\n{message}")
|
|
||||||
|
|
||||||
calendar_result = _push_calendar(items, date_str)
|
if errors:
|
||||||
|
summary = f"{pushed} ok, {errors} error" + ("s" if errors != 1 else "")
|
||||||
errors = []
|
elif pushed:
|
||||||
for phone in phones:
|
summary = f"ok ({pushed} day{'s' if pushed != 1 else ''})"
|
||||||
try:
|
else:
|
||||||
_send_email_sms(message, phone, gateway)
|
summary = "no items"
|
||||||
except Exception as e:
|
|
||||||
log.error(f"Failed to send to {phone}: {e}")
|
|
||||||
errors.append(str(e))
|
|
||||||
|
|
||||||
_status = {
|
_status = {
|
||||||
**_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(),
|
"last_calendar_run": datetime.now().isoformat(),
|
||||||
|
"calendar_result": summary,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -193,15 +134,15 @@ DAY_MAP_FNS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def apply_schedule(settings: dict) -> None:
|
def apply_calendar_schedule(settings: dict) -> None:
|
||||||
with _lock:
|
with _lock:
|
||||||
schedule.clear()
|
schedule.clear("calendar")
|
||||||
run_time = settings.get("run_time", "07:00")
|
run_time = settings.get("calendar_run_time", "18:00")
|
||||||
for day in settings.get("run_days", []):
|
for day in settings.get("calendar_run_days", []):
|
||||||
day = day.strip().lower()
|
day = day.strip().lower()
|
||||||
if day in DAY_MAP_FNS:
|
if day in DAY_MAP_FNS:
|
||||||
DAY_MAP_FNS[day]().at(run_time).do(run_job)
|
DAY_MAP_FNS[day]().at(run_time).do(push_week_to_calendar).tag("calendar")
|
||||||
log.info(f"Scheduled: {day} at {run_time}")
|
log.info(f"Scheduled calendar pull: {day} at {run_time}")
|
||||||
|
|
||||||
|
|
||||||
def _schedule_loop() -> None:
|
def _schedule_loop() -> None:
|
||||||
|
|
@ -235,72 +176,42 @@ def api_get_settings():
|
||||||
def api_post_settings():
|
def api_post_settings():
|
||||||
data = request.get_json(force=True)
|
data = request.get_json(force=True)
|
||||||
s = load_settings()
|
s = load_settings()
|
||||||
for key in ("phone_numbers", "carrier_gateway", "run_time", "run_days"):
|
for key in ("calendar_run_time", "calendar_run_days"):
|
||||||
if key in data:
|
if key in data:
|
||||||
s[key] = data[key]
|
s[key] = data[key]
|
||||||
save_settings(s)
|
save_settings(s)
|
||||||
apply_schedule(s)
|
apply_calendar_schedule(s)
|
||||||
return jsonify({"ok": True})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/vacation", methods=["POST"])
|
|
||||||
def api_add_vacation():
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
s = load_settings()
|
|
||||||
s.setdefault("vacations", []).append({
|
|
||||||
"id": str(int(time.time() * 1000)),
|
|
||||||
"start": data["start"],
|
|
||||||
"end": data["end"],
|
|
||||||
"label": data.get("label", "Vacation"),
|
|
||||||
})
|
|
||||||
save_settings(s)
|
|
||||||
return jsonify({"ok": True})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/vacation/<vid>", methods=["DELETE"])
|
|
||||||
def api_delete_vacation(vid: str):
|
|
||||||
s = load_settings()
|
|
||||||
s["vacations"] = [v for v in s.get("vacations", []) if str(v.get("id")) != vid]
|
|
||||||
save_settings(s)
|
|
||||||
return jsonify({"ok": True})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/send", methods=["POST"])
|
|
||||||
def api_send():
|
|
||||||
threading.Thread(target=run_job, daemon=True).start()
|
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/push-calendar", methods=["POST"])
|
@app.route("/api/push-calendar", methods=["POST"])
|
||||||
def api_push_calendar():
|
def api_push_calendar():
|
||||||
threading.Thread(target=push_calendar_only, daemon=True).start()
|
threading.Thread(target=push_week_to_calendar, daemon=True).start()
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/preview")
|
@app.route("/api/preview")
|
||||||
def api_preview():
|
def api_preview():
|
||||||
"""Dry-run scrape: no SMS, no Home Assistant push."""
|
"""Dry-run week scrape: no Home Assistant push, no side effects."""
|
||||||
items, date_str, message = _scrape_menu()
|
try:
|
||||||
return jsonify({"date": date_str, "items": items, "message": message})
|
week = get_week_menu()
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 502
|
||||||
|
return jsonify({"days": week})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/integrations")
|
@app.route("/api/integrations")
|
||||||
def api_integrations():
|
def api_integrations():
|
||||||
return jsonify({
|
return jsonify({"ha_webhook_configured": bool(os.environ.get("HA_WEBHOOK_URL"))})
|
||||||
"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()
|
|
||||||
with _lock:
|
with _lock:
|
||||||
next_runs = sorted([j.next_run for j in schedule.get_jobs() if j.next_run])
|
next_runs = sorted([j.next_run for j in schedule.get_jobs("calendar") if j.next_run])
|
||||||
return jsonify({
|
return jsonify({
|
||||||
**_status,
|
**_status,
|
||||||
"next_run": next_runs[0].isoformat() if next_runs else None,
|
"next_calendar_run": next_runs[0].isoformat() if next_runs else None,
|
||||||
"on_vacation": is_on_vacation(s),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -366,7 +277,7 @@ header p { color: var(--muted); font-size: .875rem; margin-top: 2px; }
|
||||||
.field:last-child { margin-bottom: 0; }
|
.field:last-child { margin-bottom: 0; }
|
||||||
label.field-label { display: block; font-size: .8rem; font-weight: 600; color: var(--text);
|
label.field-label { display: block; font-size: .8rem; font-weight: 600; color: var(--text);
|
||||||
margin-bottom: 6px; }
|
margin-bottom: 6px; }
|
||||||
input[type=text], input[type=time], input[type=date] {
|
input[type=text], input[type=time] {
|
||||||
width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px;
|
width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px;
|
||||||
font-size: .875rem; color: var(--text); background: var(--card);
|
font-size: .875rem; color: var(--text); background: var(--card);
|
||||||
}
|
}
|
||||||
|
|
@ -377,17 +288,7 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
|
||||||
font-size: .8rem; font-weight: 600; cursor: pointer; background: var(--card);
|
font-size: .8rem; font-weight: 600; cursor: pointer; background: var(--card);
|
||||||
transition: all .15s; user-select: none; }
|
transition: all .15s; user-select: none; }
|
||||||
.day-chip.on { background: var(--primary); color: #fff; border-color: var(--primary); }
|
.day-chip.on { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||||
/* Lists */
|
|
||||||
.item-list { list-style: none; }
|
|
||||||
.item-row { display: flex; align-items: center; gap: 10px; padding: 10px 0;
|
|
||||||
border-bottom: 1px solid var(--border); }
|
|
||||||
.item-row:last-child { border-bottom: none; }
|
|
||||||
.item-main { flex: 1; }
|
|
||||||
.item-title { font-size: .875rem; font-weight: 500; }
|
|
||||||
.item-sub { font-size: .75rem; color: var(--muted); margin-top: 1px; }
|
.item-sub { font-size: .75rem; color: var(--muted); margin-top: 1px; }
|
||||||
/* Add row */
|
|
||||||
.add-row { display: flex; gap: 8px; align-items: flex-end; margin-top: 14px; }
|
|
||||||
.add-row .field { flex: 1; margin-bottom: 0; }
|
|
||||||
/* Divider */
|
/* Divider */
|
||||||
.divider { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
|
.divider { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
|
||||||
/* Toast */
|
/* Toast */
|
||||||
|
|
@ -396,8 +297,11 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
|
||||||
font-size: .875rem; font-weight: 500; opacity: 0; transition: opacity .25s, transform .25s;
|
font-size: .875rem; font-weight: 500; opacity: 0; transition: opacity .25s, transform .25s;
|
||||||
pointer-events: none; white-space: nowrap; z-index: 100; }
|
pointer-events: none; white-space: nowrap; z-index: 100; }
|
||||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
.empty-state { color: var(--muted); font-size: .875rem; padding: 6px 0; }
|
.week-day { padding: 8px 0; border-bottom: 1px solid var(--border); }
|
||||||
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
.week-day:last-child { border-bottom: none; }
|
||||||
|
.week-day-date { font-size: .8rem; font-weight: 600; margin-bottom: 3px; }
|
||||||
|
.week-day-items { font-size: .8rem; color: #c9d1d9; white-space: pre-wrap; }
|
||||||
|
.week-day-empty { font-size: .8rem; color: var(--muted); font-style: italic; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -407,7 +311,7 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
|
||||||
<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;">
|
<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>
|
<div>
|
||||||
<h1>Lunch Notifier</h1>
|
<h1>Lunch Notifier</h1>
|
||||||
<p>Kyrene de la Mariposa · Automated lunch menu texts</p>
|
<p>Kyrene de la Mariposa · Menu → Home Assistant calendar</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -416,108 +320,48 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Status</div>
|
<div class="card-label">Status</div>
|
||||||
<dl class="stat-grid">
|
<dl class="stat-grid">
|
||||||
<div class="stat"><dt>Last Run</dt><dd id="s-last-run">—</dd></div>
|
<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>Result</dt><dd id="s-result">—</dd></div>
|
||||||
<div class="stat"><dt>Next Scheduled</dt><dd id="s-next">—</dd></div>
|
<div class="stat"><dt>Next Scheduled</dt><dd id="s-next">—</dd></div>
|
||||||
<div class="stat"><dt>Mode</dt><dd id="s-mode">—</dd></div>
|
|
||||||
<div class="stat" id="s-cal-stat" style="display:none;"><dt>Calendar Sync</dt><dd id="s-cal">—</dd></div>
|
|
||||||
</dl>
|
</dl>
|
||||||
<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>
|
||||||
|
|
||||||
<!-- Manual Actions -->
|
<!-- Manual Actions -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Manual Actions</div>
|
<div class="card-label">Manual Actions</div>
|
||||||
<button id="send-btn" class="btn btn-primary btn-send" onclick="sendNow()">
|
<button id="push-cal-btn" class="btn btn-primary btn-send" onclick="pushCalendar()">
|
||||||
Send Lunch Menu Now
|
Push This Week to Calendar
|
||||||
</button>
|
</button>
|
||||||
<div class="row-2" style="margin-top:10px;">
|
<button id="preview-btn" class="btn btn-ghost" style="width:100%;margin-top:10px;" onclick="previewMenu()">
|
||||||
<button id="push-cal-btn" class="btn btn-ghost" onclick="pushCalendar()">
|
Preview This Week's Menu
|
||||||
Push to Calendar Only
|
|
||||||
</button>
|
</button>
|
||||||
<button id="preview-btn" class="btn btn-ghost" onclick="previewMenu()">
|
<div id="preview-box" style="display:none;margin-top:14px;"></div>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<!-- Integrations -->
|
<!-- Integrations -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Integrations</div>
|
<div class="card-label">Integrations</div>
|
||||||
<dl class="stat-grid">
|
<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>
|
<div class="stat"><dt>Home Assistant</dt><dd id="i-ha">—</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="item-sub" style="margin-top:10px;">Configured via <code>.env</code> on the server, not here.</div>
|
<div class="item-sub" style="margin-top:10px;">Configured via <code>.env</code> (<code>HA_WEBHOOK_URL</code>) on the server, not here.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Schedule -->
|
<!-- Schedule -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Schedule</div>
|
<div class="card-label">Calendar Pull Schedule</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label">Send Time</label>
|
<label class="field-label">Pull Time</label>
|
||||||
<input type="time" id="f-time" style="max-width:160px">
|
<input type="time" id="f-time" style="max-width:160px">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label">Days</label>
|
<label class="field-label">Pull Days</label>
|
||||||
<div class="days-row" id="days-row"></div>
|
<div class="days-row" id="days-row"></div>
|
||||||
</div>
|
<div class="item-sub" style="margin-top:8px;">Each day fires a full week pull. Picking more than one day will create duplicate calendar events for days already pushed — most people want just one (e.g. Sunday evening).</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>
|
</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>
|
||||||
|
|
||||||
<!-- Phone Numbers -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-label">Phone Numbers</div>
|
|
||||||
<ul class="item-list" id="phone-list"></ul>
|
|
||||||
<div class="add-row">
|
|
||||||
<div class="field">
|
|
||||||
<label class="field-label">Add Number (10 digits)</label>
|
|
||||||
<input type="text" id="f-phone" placeholder="4805551234" maxlength="15"
|
|
||||||
onkeydown="if(event.key==='Enter') addPhone()">
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary btn-sm" onclick="addPhone()">Add</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Vacation -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-label">Vacation / No-Send Periods</div>
|
|
||||||
<ul class="item-list" id="vac-list"></ul>
|
|
||||||
<hr class="divider">
|
|
||||||
<div class="field">
|
|
||||||
<label class="field-label">Label</label>
|
|
||||||
<input type="text" id="f-vac-label" placeholder="Spring Break">
|
|
||||||
</div>
|
|
||||||
<div class="row-2">
|
|
||||||
<div class="field">
|
|
||||||
<label class="field-label">Start Date</label>
|
|
||||||
<input type="date" id="f-vac-start">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="field-label">End Date</label>
|
|
||||||
<input type="date" id="f-vac-end">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary" onclick="addVacation()">Add Period</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
@ -539,18 +383,6 @@ async function load() {
|
||||||
applyIntegrations(ig);
|
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) {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
|
|
@ -558,155 +390,46 @@ function fmt(iso) {
|
||||||
d.toLocaleTimeString([], {hour:'numeric',minute:'2-digit'});
|
d.toLocaleTimeString([], {hour:'numeric',minute:'2-digit'});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySettings(s) {
|
function applyIntegrations(ig) {
|
||||||
document.getElementById('f-time').value = s.run_time || '07:00';
|
document.getElementById('i-ha').innerHTML = ig.ha_webhook_configured
|
||||||
// Carrier gateway
|
? '<span class="badge badge-green">Configured</span>'
|
||||||
const gw = s.carrier_gateway || 'vtext.com';
|
: '<span class="badge badge-yellow">Not configured</span>';
|
||||||
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
|
|
||||||
|
function applySettings(s) {
|
||||||
|
document.getElementById('f-time').value = s.calendar_run_time || '18:00';
|
||||||
const row = document.getElementById('days-row');
|
const row = document.getElementById('days-row');
|
||||||
row.innerHTML = DAYS.map(d =>
|
row.innerHTML = DAYS.map(d =>
|
||||||
`<span class="day-chip ${(s.run_days||[]).includes(d)?'on':''}" data-day="${d}"
|
`<span class="day-chip ${(s.calendar_run_days||[]).includes(d)?'on':''}" data-day="${d}"
|
||||||
onclick="this.classList.toggle('on')">${DAY_LABELS[d]}</span>`
|
onclick="this.classList.toggle('on')">${DAY_LABELS[d]}</span>`
|
||||||
).join('');
|
).join('');
|
||||||
// Phones
|
|
||||||
const pl = document.getElementById('phone-list');
|
|
||||||
if (!(s.phone_numbers||[]).length) {
|
|
||||||
pl.innerHTML = '<li class="empty-state">No numbers added yet.</li>';
|
|
||||||
} else {
|
|
||||||
pl.innerHTML = (s.phone_numbers||[]).map((p,i) => `
|
|
||||||
<li class="item-row">
|
|
||||||
<div class="item-main"><div class="item-title">${p}</div></div>
|
|
||||||
<button class="btn btn-danger btn-sm" onclick="removePhone(${i})">Remove</button>
|
|
||||||
</li>`).join('');
|
|
||||||
}
|
|
||||||
// Vacations
|
|
||||||
const vl = document.getElementById('vac-list');
|
|
||||||
if (!(s.vacations||[]).length) {
|
|
||||||
vl.innerHTML = '<li class="empty-state">No vacation periods set.</li>';
|
|
||||||
} else {
|
|
||||||
vl.innerHTML = (s.vacations||[]).map(v => `
|
|
||||||
<li class="item-row">
|
|
||||||
<div class="item-main">
|
|
||||||
<div class="item-title">${v.label}</div>
|
|
||||||
<div class="item-sub">${v.start} → ${v.end}</div>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-danger btn-sm" onclick="removeVacation('${v.id}')">Remove</button>
|
|
||||||
</li>`).join('');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyStatus(st) {
|
function applyStatus(st) {
|
||||||
document.getElementById('s-last-run').textContent = fmt(st.last_run);
|
document.getElementById('s-last-run').textContent = fmt(st.last_calendar_run);
|
||||||
const res = st.last_result;
|
const res = st.calendar_result;
|
||||||
let cls = res==='ok' ? 'badge-green' : res?.startsWith('skipped') ? 'badge-yellow' : res ? 'badge-red' : '';
|
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-result').innerHTML = res ? `<span class="badge ${cls}">${res}</span>` : '—';
|
||||||
document.getElementById('s-next').textContent = fmt(st.next_run);
|
document.getElementById('s-next').textContent = fmt(st.next_calendar_run);
|
||||||
const vac = st.on_vacation;
|
|
||||||
document.getElementById('s-mode').innerHTML = vac
|
|
||||||
? `<span class="badge badge-yellow">Vacation: ${vac}</span>`
|
|
||||||
: '<span class="badge badge-green">Active</span>';
|
|
||||||
const calStat = document.getElementById('s-cal-stat');
|
|
||||||
const cal = st.calendar_result;
|
|
||||||
if (cal) {
|
|
||||||
calStat.style.display = '';
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
const msgEl = document.getElementById('s-last-msg');
|
|
||||||
if (st.last_message) {
|
|
||||||
msgEl.textContent = st.last_message;
|
|
||||||
msgEl.style.display = '';
|
|
||||||
} else {
|
|
||||||
msgEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 calendar_run_time = document.getElementById('f-time').value;
|
||||||
const run_days = [...document.querySelectorAll('.day-chip.on')].map(c=>c.dataset.day);
|
const calendar_run_days = [...document.querySelectorAll('.day-chip.on')].map(c=>c.dataset.day);
|
||||||
const gwSel = document.getElementById('f-gateway').value;
|
await post('/api/settings', {calendar_run_time, calendar_run_days});
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addPhone() {
|
|
||||||
const input = document.getElementById('f-phone');
|
|
||||||
const phone = input.value.trim().replace(/\D/g,'');
|
|
||||||
if (!phone) { toast('Enter a 10-digit number'); return; }
|
|
||||||
const phone_numbers = [...(cfg.phone_numbers||[]), phone];
|
|
||||||
await post('/api/settings', {phone_numbers});
|
|
||||||
input.value = '';
|
|
||||||
toast('Number added');
|
|
||||||
load();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removePhone(idx) {
|
|
||||||
const phone_numbers = (cfg.phone_numbers||[]).filter((_,i)=>i!==idx);
|
|
||||||
await post('/api/settings', {phone_numbers});
|
|
||||||
toast('Number removed');
|
|
||||||
load();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addVacation() {
|
|
||||||
const label = document.getElementById('f-vac-label').value.trim() || 'Vacation';
|
|
||||||
const start = document.getElementById('f-vac-start').value;
|
|
||||||
const end = document.getElementById('f-vac-end').value;
|
|
||||||
if (!start || !end) { toast('Set both start and end dates'); return; }
|
|
||||||
if (end < start) { toast('End must be after start'); return; }
|
|
||||||
await post('/api/vacation', {label, start, end});
|
|
||||||
document.getElementById('f-vac-label').value = '';
|
|
||||||
document.getElementById('f-vac-start').value = '';
|
|
||||||
document.getElementById('f-vac-end').value = '';
|
|
||||||
toast('Vacation period added');
|
|
||||||
load();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeVacation(id) {
|
|
||||||
await fetch(`/api/vacation/${id}`, {method:'DELETE'});
|
|
||||||
toast('Removed');
|
|
||||||
load();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendNow() {
|
|
||||||
const btn = document.getElementById('send-btn');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'Sending…';
|
|
||||||
await post('/api/send', {});
|
|
||||||
toast('Triggered! Check status in a moment.');
|
|
||||||
setTimeout(() => { btn.disabled=false; btn.textContent='Send Lunch Menu Now'; load(); }, 6000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pushCalendar() {
|
async function pushCalendar() {
|
||||||
const btn = document.getElementById('push-cal-btn');
|
const btn = document.getElementById('push-cal-btn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Pushing…';
|
btn.textContent = 'Pushing…';
|
||||||
await post('/api/push-calendar', {});
|
await post('/api/push-calendar', {});
|
||||||
toast('Pushed! Check status in a moment.');
|
toast('Triggered! Check status in a moment.');
|
||||||
setTimeout(() => { btn.disabled=false; btn.textContent='Push to Calendar Only'; load(); }, 6000);
|
setTimeout(() => { btn.disabled=false; btn.textContent='Push This Week to Calendar'; load(); }, 6000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function previewMenu() {
|
async function previewMenu() {
|
||||||
|
|
@ -717,11 +440,21 @@ async function previewMenu() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/preview');
|
const r = await fetch('/api/preview');
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
box.textContent = data.message;
|
if (data.error) {
|
||||||
|
box.innerHTML = `<div class="week-day-empty">Error: ${data.error}</div>`;
|
||||||
|
} else {
|
||||||
|
box.innerHTML = (data.days||[]).map(d => `
|
||||||
|
<div class="week-day">
|
||||||
|
<div class="week-day-date">${d.date}</div>
|
||||||
|
${d.items.length
|
||||||
|
? `<div class="week-day-items">${d.items.map(i=>'• '+i).join('\\n')}</div>`
|
||||||
|
: '<div class="week-day-empty">No menu posted</div>'}
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
box.style.display = '';
|
box.style.display = '';
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = "Preview Today's Menu";
|
btn.textContent = "Preview This Week's Menu";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -752,7 +485,7 @@ if __name__ == "__main__":
|
||||||
log.info(f"Creating default settings at {SETTINGS_FILE}")
|
log.info(f"Creating default settings at {SETTINGS_FILE}")
|
||||||
save_settings(_DEFAULTS)
|
save_settings(_DEFAULTS)
|
||||||
|
|
||||||
apply_schedule(load_settings())
|
apply_calendar_schedule(load_settings())
|
||||||
threading.Thread(target=_schedule_loop, daemon=True).start()
|
threading.Thread(target=_schedule_loop, daemon=True).start()
|
||||||
log.info(f"Web UI → http://localhost:{PORT}")
|
log.info(f"Web UI → http://localhost:{PORT}")
|
||||||
app.run(host="0.0.0.0", port=PORT, debug=False, use_reloader=False)
|
app.run(host="0.0.0.0", port=PORT, debug=False, use_reloader=False)
|
||||||
|
|
|
||||||
115
scheduler.py
115
scheduler.py
|
|
@ -1,115 +0,0 @@
|
||||||
"""
|
|
||||||
Runs scraper.py on a schedule inside the container.
|
|
||||||
|
|
||||||
HTTP trigger server on PORT (default 2323):
|
|
||||||
GET / → health check / status
|
|
||||||
POST /send → trigger scraper immediately
|
|
||||||
|
|
||||||
Env vars:
|
|
||||||
PHONE_NUMBER – destination phone (required)
|
|
||||||
TEXTBELT_KEY – textbelt key (default: "textbelt" free tier)
|
|
||||||
RUN_TIME – HH:MM 24h time to fire daily (default: 07:00)
|
|
||||||
RUN_DAYS – comma-sep days: mon,tue,wed,thu,fri (default)
|
|
||||||
PORT – HTTP port (default: 2323)
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
||||||
|
|
||||||
import schedule
|
|
||||||
|
|
||||||
from scraper import main as run_scraper # now a plain sync function
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
RUN_TIME = os.environ.get("RUN_TIME", "07:00")
|
|
||||||
RUN_DAYS = [d.strip().lower() for d in os.environ.get("RUN_DAYS", "mon,tue,wed,thu,fri").split(",")]
|
|
||||||
PORT = int(os.environ.get("PORT", "2323"))
|
|
||||||
|
|
||||||
DAY_MAP = {
|
|
||||||
"mon": schedule.every().monday,
|
|
||||||
"tue": schedule.every().tuesday,
|
|
||||||
"wed": schedule.every().wednesday,
|
|
||||||
"thu": schedule.every().thursday,
|
|
||||||
"fri": schedule.every().friday,
|
|
||||||
"sat": schedule.every().saturday,
|
|
||||||
"sun": schedule.every().sunday,
|
|
||||||
}
|
|
||||||
|
|
||||||
_last_run: str = "never"
|
|
||||||
_last_result: str = "n/a"
|
|
||||||
|
|
||||||
|
|
||||||
def job():
|
|
||||||
global _last_run, _last_result
|
|
||||||
log.info("Running scheduled lunch menu check...")
|
|
||||||
try:
|
|
||||||
run_scraper()
|
|
||||||
_last_result = "ok"
|
|
||||||
except SystemExit:
|
|
||||||
_last_result = "sms_failed"
|
|
||||||
except Exception as e:
|
|
||||||
log.exception("Scraper failed")
|
|
||||||
_last_result = f"error: {e}"
|
|
||||||
_last_run = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
def log_message(self, fmt, *args):
|
|
||||||
log.info("HTTP %s", fmt % args)
|
|
||||||
|
|
||||||
def _respond(self, code: int, body: str):
|
|
||||||
data = body.encode()
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Type", "text/plain")
|
|
||||||
self.send_header("Content-Length", str(len(data)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(data)
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
if self.path == "/":
|
|
||||||
status = (
|
|
||||||
f"lunch-notifier running\n"
|
|
||||||
f"schedule : {', '.join(RUN_DAYS)} at {RUN_TIME}\n"
|
|
||||||
f"phone : {os.environ.get('PHONE_NUMBER', 'NOT SET')}\n"
|
|
||||||
f"last run : {_last_run}\n"
|
|
||||||
f"last result: {_last_result}\n\n"
|
|
||||||
f"POST /send to trigger immediately"
|
|
||||||
)
|
|
||||||
self._respond(200, status)
|
|
||||||
else:
|
|
||||||
self._respond(404, "not found")
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
if self.path == "/send":
|
|
||||||
log.info("Manual trigger via HTTP POST /send")
|
|
||||||
threading.Thread(target=job, daemon=True).start()
|
|
||||||
self._respond(200, "Triggered — check container logs for result.")
|
|
||||||
else:
|
|
||||||
self._respond(404, "not found")
|
|
||||||
|
|
||||||
|
|
||||||
def start_http_server():
|
|
||||||
server = HTTPServer(("0.0.0.0", PORT), Handler)
|
|
||||||
log.info(f"HTTP trigger server listening on port {PORT}")
|
|
||||||
server.serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Schedule setup ────────────────────────────────────────────────────────────
|
|
||||||
for day in RUN_DAYS:
|
|
||||||
if day in DAY_MAP:
|
|
||||||
DAY_MAP[day].at(RUN_TIME).do(job)
|
|
||||||
log.info(f"Scheduled: {day} at {RUN_TIME}")
|
|
||||||
else:
|
|
||||||
log.warning(f"Unknown day '{day}', skipping.")
|
|
||||||
|
|
||||||
log.info(f"Phone: {os.environ.get('PHONE_NUMBER', 'NOT SET')}")
|
|
||||||
|
|
||||||
threading.Thread(target=start_http_server, daemon=True).start()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
schedule.run_pending()
|
|
||||||
time.sleep(30)
|
|
||||||
84
scraper.py
84
scraper.py
|
|
@ -1,9 +1,5 @@
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import logging
|
import logging
|
||||||
import smtplib
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from email.message import EmailMessage
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
@ -15,10 +11,7 @@ MENU_TYPE = "lunch"
|
||||||
API_BASE = "https://kyrene.api.nutrislice.com/menu/api/weeks/school"
|
API_BASE = "https://kyrene.api.nutrislice.com/menu/api/weeks/school"
|
||||||
|
|
||||||
|
|
||||||
def get_lunch_menu(date_str: str | None = None) -> tuple[list[str], str]:
|
def _fetch_week(date_str: str) -> dict:
|
||||||
if date_str is None:
|
|
||||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||||
url = f"{API_BASE}/{SCHOOL_SLUG}/menu-type/{MENU_TYPE}/{dt.year}/{dt.month:02d}/{dt.day:02d}/"
|
url = f"{API_BASE}/{SCHOOL_SLUG}/menu-type/{MENU_TYPE}/{dt.year}/{dt.month:02d}/{dt.day:02d}/"
|
||||||
log.info(f"Fetching {url}")
|
log.info(f"Fetching {url}")
|
||||||
|
|
@ -29,73 +22,30 @@ def get_lunch_menu(date_str: str | None = None) -> tuple[list[str], str]:
|
||||||
timeout=20,
|
timeout=20,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_items(day: dict) -> list[str]:
|
||||||
items = []
|
items = []
|
||||||
for day in data.get("days", []):
|
|
||||||
if day.get("date") != date_str:
|
|
||||||
continue
|
|
||||||
for entry in day.get("menu_items", []):
|
for entry in day.get("menu_items", []):
|
||||||
if entry.get("is_section_title") or entry.get("is_station_header"):
|
if entry.get("is_section_title") or entry.get("is_station_header"):
|
||||||
continue
|
continue
|
||||||
food = entry.get("food")
|
food = entry.get("food")
|
||||||
if food and food.get("name"):
|
if food and food.get("name"):
|
||||||
items.append(food["name"].strip())
|
items.append(food["name"].strip())
|
||||||
|
return items
|
||||||
return items, date_str
|
|
||||||
|
|
||||||
|
|
||||||
def send_sms(message: str, phone: str) -> None:
|
def get_week_menu(date_str: str | None = None) -> list[dict]:
|
||||||
gmail_user = os.environ["GMAIL_USER"]
|
"""Returns every day in the school-published week containing date_str,
|
||||||
gmail_pass = os.environ["GMAIL_APP_PASSWORD"]
|
as [{"date": "YYYY-MM-DD", "items": [...]}, ...] in the API's own order.
|
||||||
gateway = os.environ.get("CARRIER_GATEWAY", "vtext.com")
|
Days with no posted menu (weekends, breaks, holidays) have an empty items list."""
|
||||||
to_addr = f"{phone}@{gateway}"
|
if date_str is None:
|
||||||
|
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
msg = EmailMessage()
|
data = _fetch_week(date_str)
|
||||||
msg["From"] = gmail_user
|
return [
|
||||||
msg["To"] = to_addr
|
{"date": day.get("date"), "items": _extract_items(day)}
|
||||||
msg.set_content(message)
|
for day in data.get("days", [])
|
||||||
|
if day.get("date")
|
||||||
log.info(f"Sending email-to-SMS → {to_addr}")
|
]
|
||||||
with smtplib.SMTP("smtp.gmail.com", 587) as smtp:
|
|
||||||
smtp.starttls()
|
|
||||||
smtp.login(gmail_user, gmail_pass)
|
|
||||||
smtp.send_message(msg)
|
|
||||||
log.info("Sent.")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
phone = os.environ.get("PHONE_NUMBER", "").strip()
|
|
||||||
if not phone:
|
|
||||||
log.error("PHONE_NUMBER environment variable is not set.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
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} "
|
|
||||||
f"(Kyrene de la Mariposa — may be a break or holiday)."
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
log.exception("Failed to fetch menu")
|
|
||||||
message = f"Error fetching Mariposa lunch menu: {e}"
|
|
||||||
|
|
||||||
# SMS via email has a ~160 char limit per message segment; keep it short
|
|
||||||
if len(message) > 1400:
|
|
||||||
message = message[:1397] + "..."
|
|
||||||
|
|
||||||
log.info(f"Message:\n{message}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
send_sms(message, phone)
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"Failed to send SMS: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue