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
137
CLAUDE.md
137
CLAUDE.md
|
|
@ -1,57 +1,82 @@
|
|||
# Lunch Notifier
|
||||
|
||||
Single-file Flask app that scrapes the daily Mariposa (Kyrene school
|
||||
district) lunch menu and texts it to a list of phone numbers via
|
||||
email-to-SMS carrier gateways (e.g. `4805551234@vtext.com` for Verizon).
|
||||
Runs as one Docker container on the user's Unraid home server.
|
||||
Single-file Flask app that scrapes the weekly Mariposa (Kyrene school
|
||||
district) lunch menu and pushes each day to a Home Assistant calendar as an
|
||||
all-day event. Runs as one Docker container on the user's Unraid home
|
||||
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
|
||||
|
||||
- **`app.py`** — everything: Flask web UI/API, APScheduler-style job
|
||||
scheduling (`schedule` library), and the email-to-SMS send logic
|
||||
(`smtplib` via Gmail, app password auth). The HTML/CSS/JS frontend is
|
||||
embedded as a Python string constant (`HTML`) rather than a separate
|
||||
- **`app.py`** — everything: Flask web UI/API, `schedule`-library job
|
||||
scheduling, and the Home Assistant webhook push. The HTML/CSS/JS frontend
|
||||
is embedded as a Python string constant (`HTML`) rather than a separate
|
||||
template file — no build step, no template engine.
|
||||
- **`scraper.py`** — fetches and parses the school's published lunch menu.
|
||||
- **`scheduler.py`** — day-of-week/time scheduling helpers used by `app.py`.
|
||||
- Settings (phone numbers, send time, days, vacation date ranges) persist
|
||||
to `/data/settings.json`, not environment variables — env vars
|
||||
(`PHONE_NUMBER`, `RUN_TIME`, `RUN_DAYS`) are defaults used only the very
|
||||
first time, before a settings file exists.
|
||||
- Vacation ranges suppress sends without disabling the schedule — checked
|
||||
fresh on every run via `is_on_vacation()`.
|
||||
- **Home Assistant integration (2026-09-02, HA side built 2026-09-03):** if
|
||||
`HA_WEBHOOK_URL` is set, every successful menu fetch also POSTs
|
||||
`{"date": ..., "items": [...]}` to that webhook, independent of whether
|
||||
the SMS send itself succeeds. 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`), producing an all-day event with the menu as
|
||||
the description (bulleted). This makes the scrape here the single
|
||||
source of truth — HA doesn't poll or re-scrape anything.
|
||||
`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 either way: re-pushing the same
|
||||
date's menu creates a second event. 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
|
||||
- **`scraper.py`** — fetches and parses the school's published lunch menu
|
||||
from Nutrislice's API. `get_week_menu(date_str)` returns every day in the
|
||||
API's week-granular response as `[{"date": ..., "items": [...]}, ...]`
|
||||
(the API returns a full week regardless of which single date you ask
|
||||
for). Days with no posted menu (weekends, breaks, holidays) come back
|
||||
with an empty `items` list rather than being omitted.
|
||||
- Settings (calendar pull schedule) persist to `/data/settings.json`, not
|
||||
environment variables — env vars (`CALENDAR_RUN_TIME`, `CALENDAR_RUN_DAYS`)
|
||||
are defaults used only the very first time, before a settings file exists.
|
||||
`load_settings()` merges the file's contents *over* `_DEFAULTS` rather
|
||||
than returning the file verbatim — this matters because the server's
|
||||
settings.json predates this schema (it's from the SMS era) and lacks
|
||||
`calendar_run_time`/`calendar_run_days` entirely; without the merge, the
|
||||
scheduler would silently come up with zero jobs after every deploy until
|
||||
someone happened to re-save the Schedule card in the UI. Verified via a
|
||||
migration test (old-schema settings.json + fresh container → correct
|
||||
defaults filled in, `next_calendar_run` populated).
|
||||
- **No vacation/no-push date ranges (removed 2026-09-03, briefly existed).**
|
||||
A day with no posted menu already produces an empty `items` list from
|
||||
`get_week_menu()`, and `_push_calendar()` already skips pushing when
|
||||
`items` is empty — that's sufficient for "don't create an event for a day
|
||||
with nothing happening." A separate vacation-range setting to *additionally*
|
||||
suppress days the school did post a menu for was judged unnecessary
|
||||
complexity and deleted at the user's request; don't re-add it without
|
||||
being asked.
|
||||
- **Home Assistant integration (built 2026-09-02/03, calendar-only pivot
|
||||
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`
|
||||
shows whether HA is even receiving the webhook.
|
||||
- **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.
|
||||
- **Independent weekly-pull scheduler (2026-09-03):** calendar pulls run on
|
||||
their own schedule (`calendar_run_time`/`calendar_run_days` in settings,
|
||||
`schedule` jobs tagged `"calendar"`), completely decoupled from any other
|
||||
job — there's nothing else in this app anymore, but the tag/isolation
|
||||
pattern (`schedule.clear("calendar")` instead of a bare `schedule.clear()`)
|
||||
is deliberate so a future second schedule wouldn't stomp this one.
|
||||
`push_week_to_calendar()` fetches the whole current school week and
|
||||
pushes every day with a posted menu. Default is Sunday 18:00 (one
|
||||
firing/week) — the UI warns that picking more than one day/week creates
|
||||
duplicate events for days already covered, since there's no dedup.
|
||||
- **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
|
||||
|
||||
|
|
@ -60,10 +85,10 @@ pip install -r requirements.txt
|
|||
python3 app.py
|
||||
```
|
||||
|
||||
Needs `GMAIL_USER` and `GMAIL_APP_PASSWORD` env vars to actually send
|
||||
(a Gmail **app password**, not the account password — required since Gmail
|
||||
disallows plain SMTP auth with the real password). Visit
|
||||
`http://localhost:2323`.
|
||||
Needs `HA_WEBHOOK_URL` set to actually push anywhere; without it the app
|
||||
still runs and previews fine, `GET /api/integrations` just reports
|
||||
`ha_webhook_configured: false` and pushes resolve to `"not configured"`.
|
||||
Visit `http://localhost:2323`.
|
||||
|
||||
## 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`)
|
||||
triggers `.forgejo/workflows/deploy.yml`, which syncs into
|
||||
`/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
|
||||
volume-mounted `/data` inside the container) holds phone numbers, schedule,
|
||||
and vacation ranges — this is live user data, not deploy-managed. `.env`
|
||||
holds `GMAIL_USER`/`GMAIL_APP_PASSWORD`/`PHONE_NUMBER` etc. and is
|
||||
server-only, gitignored, never committed.
|
||||
volume-mounted `/data` inside the container) holds the calendar pull
|
||||
schedule — this is live user data, not deploy-managed. `.env` holds
|
||||
`HA_WEBHOOK_URL` and is server-only, gitignored, never committed.
|
||||
|
||||
**Migration note (2026-08-25):** this container originally used a Docker
|
||||
**named volume** (`lunch-notifier_lunch-settings`) instead of a bind mount.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue