calendar: consume the scout-calendar feed instead of local events.json

Events now come from EVENTS_FEED_URL (the scout-calendar container,
which serves Radicale mike/site73 as JSON). The last good copy is
cached to /data/events-cache.json and served whenever the feed is down
or empty; 'Nothing on the books' remains the cold-start floor only.
The round-trip test proved the feed reproduces all 32 rows of
app/events.json byte-identically, so nothing visible changes today.

Removes app/events.json, the first-boot seed copy, and the local load
path; README calendar section rewritten; compose gains EVENTS_FEED_URL
from the Portainer stack env.
This commit is contained in:
Claude
2026-08-29 11:33:43 -04:00
parent 7d2c241126
commit 27f1a2104b
4 changed files with 58 additions and 253 deletions
+48 -15
View File
@@ -1,4 +1,4 @@
import json, os, shutil, datetime, urllib.request, urllib.parse
import json, os, time, datetime, urllib.request, urllib.parse
from pathlib import Path
from fastapi import FastAPI, Form
from fastapi.responses import HTMLResponse, RedirectResponse
@@ -11,15 +11,16 @@ import admin_api
# ---------------------------------------------------------------------------
# Pack & Troop 73 - greenlanescouts73.org
# Calendar lives in ONE file: /data/events.json (seeded from app/events.json on
# first boot). Edit that file on the host (or ask Claude) and every page that
# shows dates updates on the next refresh. No rebuild needed.
# Calendar comes from the scout-calendar JSON feed (EVENTS_FEED_URL) -
# Radicale is the source of truth, edited in any calendar app. The last good
# copy is cached to /data/events-cache.json and served whenever the feed is
# down, so a feed outage never blanks the site.
# ---------------------------------------------------------------------------
DATA = Path("/data"); DATA.mkdir(parents=True, exist_ok=True)
LEADS = DATA / "leads.jsonl"
EVENTS_LIVE = DATA / "events.json"
EVENTS_SEED = Path(__file__).parent / "events.json"
EVENTS_FEED_URL = os.environ.get("EVENTS_FEED_URL", "").strip()
EVENTS_CACHE = DATA / "events-cache.json"
NTFY_URL = os.environ.get("NTFY_URL", "")
# Lead pipeline lives IN THIS REPO (no dependency on the scouting73-form
# container): direct Google Sheet append + ntfy push, same sheet and topic.
@@ -38,23 +39,55 @@ app.mount("/static", StaticFiles(directory="/app/static"), name="static")
store.init(backfill_jsonl=LEADS)
app.include_router(admin_api.router)
if not EVENTS_LIVE.exists() and EVENTS_SEED.exists():
try: shutil.copyfile(EVENTS_SEED, EVENTS_LIVE)
except Exception: pass
MB = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']
MF = ['January','February','March','April','May','June','July','August','September','October','November','December']
DW = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']
DWU = ['MON','TUE','WED','THU','FRI','SAT','SUN']
_FEED_TTL = 300 # seconds between feed fetches; a failure also waits this long
_feed_state = {"events": None, "fetched": 0.0}
def _fetch_feed():
rq = urllib.request.Request(EVENTS_FEED_URL, headers={"User-Agent": "scout-website"})
with urllib.request.urlopen(rq, timeout=5) as resp:
events = json.load(resp).get("events", [])
if not isinstance(events, list) or not events:
raise ValueError("feed returned no events")
return events
def load_events():
for p in (EVENTS_LIVE, EVENTS_SEED):
"""Events come from the scout-calendar feed. The last good copy is cached
to /data and served whenever the feed is unreachable or empty - the pages
never go blank because the feed had a bad moment. With no feed and no
cache yet, pages fall back to their 'Nothing on the books' empty state."""
now = time.time()
st = _feed_state
if st["events"] is not None and now - st["fetched"] < _FEED_TTL:
return st["events"]
if EVENTS_FEED_URL:
try:
with open(p, encoding="utf-8") as f:
return json.load(f).get("events", [])
events = _fetch_feed()
st.update(events=events, fetched=now)
try:
tmp = EVENTS_CACHE.with_suffix(".tmp")
tmp.write_text(json.dumps({"events": events}), encoding="utf-8")
tmp.replace(EVENTS_CACHE)
except Exception:
pass
return events
except Exception:
continue
return []
pass
if st["events"] is not None: # feed down: keep serving stale, retry after TTL
st["fetched"] = now
return st["events"]
try:
with open(EVENTS_CACHE, encoding="utf-8") as f:
events = json.load(f).get("events", [])
except Exception:
events = []
if events:
st.update(events=events, fetched=now)
return events
def _dates(ev):
s = datetime.date.fromisoformat(ev["date"])