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:
@@ -6,16 +6,16 @@ Replaces the `scoutpoc` proof of concept (claude-workspace/stacks/scoutpoc).
|
|||||||
|
|
||||||
## Updating the calendar (the part you'll actually do)
|
## Updating the calendar (the part you'll actually do)
|
||||||
|
|
||||||
All dates on the site (home "Next up," both program pages, the Calendar page) render from **one JSON file**, read fresh on every request:
|
All dates on the site (home "Next up," both program pages, the Calendar page) come from the **scout-calendar feed** (claude-workspace/stacks/scout-calendar), which serves the Radicale collection `mike/site73` as JSON:
|
||||||
|
|
||||||
```
|
```
|
||||||
/srv/scout-website/data/events.json ← the LIVE file. Edit this.
|
EVENTS_FEED_URL ← Portainer stack env; http://scout-calendar:8000/events.json
|
||||||
app/events.json ← seed only; copied to /data on first boot.
|
/data/events-cache.json ← last good copy, served automatically whenever the feed is down
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit the live file on the host (or ask Claude), save, refresh the site. **No rebuild, no redeploy.**
|
Edit the calendar in any CalDAV client pointed at Radicale (or ask Claude), and the site follows within ~5 minutes. **No file edits, no rebuild, no redeploy.** The public ICS families can subscribe to is https://calendar.greenlanescouts73.org/calendar.ics.
|
||||||
|
|
||||||
Event format (one object per event):
|
Event fields the feed emits (one object per event):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"date": "2026-10-17", "end": "2026-10-18", "time": "from 4:00 PM",
|
{"date": "2026-10-17", "end": "2026-10-18", "time": "from 4:00 PM",
|
||||||
@@ -26,15 +26,12 @@ Event format (one object per event):
|
|||||||
- `date` required (YYYY-MM-DD). `end` only for multi-day events.
|
- `date` required (YYYY-MM-DD). `end` only for multi-day events.
|
||||||
- `unit`: `"pack"`, `"troop"`, or `"both"` (default pack). Drives the Pack/Troop calendar filter and each page's "Next up."
|
- `unit`: `"pack"`, `"troop"`, or `"both"` (default pack). Drives the Pack/Troop calendar filter and each page's "Next up."
|
||||||
- `badge`: optional shouty label (`FALL OPEN HOUSE`, `OVERNIGHT`, `THE BIG ONE`).
|
- `badge`: optional shouty label (`FALL OPEN HOUSE`, `OVERNIGHT`, `THE BIG ONE`).
|
||||||
- Past events are hidden from "Next up" automatically; keep or prune them in the file, your call.
|
- Past events are hidden from "Next up" automatically; the Calendar page shows the whole program year.
|
||||||
- TeamSnap (team 8198615) stays the source of truth; this file is the public snapshot.
|
- TeamSnap (team 8198615) stays the source of truth for registered families; Radicale `mike/site73` is the source for this public site.
|
||||||
|
|
||||||
To change the **seed** for future rebuilds too, edit `app/events.json` here in Gitea and redeploy the stack.
|
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `app/app.py` — FastAPI app, all five pages server-rendered (Home, /cubs, /troop, /calendar?unit=, /join). `/contact` 301s to `/join`.
|
- `app/app.py` — FastAPI app, all five pages server-rendered (Home, /cubs, /troop, /calendar?unit=, /join). `/contact` 301s to `/join`.
|
||||||
- `app/events.json` — calendar seed (see above).
|
|
||||||
- `docker-compose.yml` — Portainer Repository stack definition (port 8131).
|
- `docker-compose.yml` — Portainer Repository stack definition (port 8131).
|
||||||
- Images: **not in the repo.** Bind-mounted read-only from `/srv/scout-website-assets/img`. Source photos live on the NAS (`Scouting-Recruitment-Images`, `scouting-comms/pack-73/assets/social`); web-sized copies in `Scouting-Recruitment-Images/_web`.
|
- Images: **not in the repo.** Bind-mounted read-only from `/srv/scout-website-assets/img`. Source photos live on the NAS (`Scouting-Recruitment-Images`, `scouting-comms/pack-73/assets/social`); web-sized copies in `Scouting-Recruitment-Images/_web`.
|
||||||
|
|
||||||
|
|||||||
+48
-15
@@ -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 pathlib import Path
|
||||||
from fastapi import FastAPI, Form
|
from fastapi import FastAPI, Form
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
@@ -11,15 +11,16 @@ import admin_api
|
|||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Pack & Troop 73 - greenlanescouts73.org
|
# Pack & Troop 73 - greenlanescouts73.org
|
||||||
# Calendar lives in ONE file: /data/events.json (seeded from app/events.json on
|
# Calendar comes from the scout-calendar JSON feed (EVENTS_FEED_URL) -
|
||||||
# first boot). Edit that file on the host (or ask Claude) and every page that
|
# Radicale is the source of truth, edited in any calendar app. The last good
|
||||||
# shows dates updates on the next refresh. No rebuild needed.
|
# 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)
|
DATA = Path("/data"); DATA.mkdir(parents=True, exist_ok=True)
|
||||||
LEADS = DATA / "leads.jsonl"
|
LEADS = DATA / "leads.jsonl"
|
||||||
EVENTS_LIVE = DATA / "events.json"
|
EVENTS_FEED_URL = os.environ.get("EVENTS_FEED_URL", "").strip()
|
||||||
EVENTS_SEED = Path(__file__).parent / "events.json"
|
EVENTS_CACHE = DATA / "events-cache.json"
|
||||||
NTFY_URL = os.environ.get("NTFY_URL", "")
|
NTFY_URL = os.environ.get("NTFY_URL", "")
|
||||||
# Lead pipeline lives IN THIS REPO (no dependency on the scouting73-form
|
# Lead pipeline lives IN THIS REPO (no dependency on the scouting73-form
|
||||||
# container): direct Google Sheet append + ntfy push, same sheet and topic.
|
# 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)
|
store.init(backfill_jsonl=LEADS)
|
||||||
app.include_router(admin_api.router)
|
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']
|
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']
|
MF = ['January','February','March','April','May','June','July','August','September','October','November','December']
|
||||||
DW = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']
|
DW = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']
|
||||||
DWU = ['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():
|
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:
|
try:
|
||||||
with open(p, encoding="utf-8") as f:
|
events = _fetch_feed()
|
||||||
return json.load(f).get("events", [])
|
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:
|
except Exception:
|
||||||
continue
|
pass
|
||||||
return []
|
return events
|
||||||
|
except Exception:
|
||||||
|
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):
|
def _dates(ev):
|
||||||
s = datetime.date.fromisoformat(ev["date"])
|
s = datetime.date.fromisoformat(ev["date"])
|
||||||
|
|||||||
-226
@@ -1,226 +0,0 @@
|
|||||||
{
|
|
||||||
"updated": "2026-08-24",
|
|
||||||
"note": "One event per object. Home shows the next 3 automatically; Calendar groups by month with Pack/Troop filters. Fields: date (YYYY-MM-DD), end (optional), time (optional), title, location (optional), unit ('pack' | 'troop' | 'both'; default pack), badge (optional short label).",
|
|
||||||
"events": [
|
|
||||||
{
|
|
||||||
"date": "2026-08-29",
|
|
||||||
"time": "1:00 PM",
|
|
||||||
"title": "Pack Hike",
|
|
||||||
"location": "Plank Road City Park",
|
|
||||||
"unit": "pack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2026-09-01",
|
|
||||||
"time": "3:00 PM",
|
|
||||||
"title": "Canoe Day",
|
|
||||||
"unit": "troop"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2026-09-01",
|
|
||||||
"time": "6:30 PM",
|
|
||||||
"title": "Kickoff Party",
|
|
||||||
"location": "Green Lane Park, Morrow Pavilion",
|
|
||||||
"unit": "both"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2026-09-12",
|
|
||||||
"time": "10:00 AM",
|
|
||||||
"title": "Lower Frederick Community Day",
|
|
||||||
"location": "PV Middle School West",
|
|
||||||
"unit": "both"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2026-09-19",
|
|
||||||
"time": "7:00 AM",
|
|
||||||
"title": "Farmers Market chocolate booth",
|
|
||||||
"location": "Green Lane Park",
|
|
||||||
"unit": "pack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2026-09-20",
|
|
||||||
"time": "1:00 PM",
|
|
||||||
"title": "Pack Hike",
|
|
||||||
"location": "Perkiomen Trail, Foy Park",
|
|
||||||
"unit": "pack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2026-09-22",
|
|
||||||
"time": "6:00 PM",
|
|
||||||
"title": "Boxcar Derby",
|
|
||||||
"location": "St. Luke's",
|
|
||||||
"unit": "pack",
|
|
||||||
"badge": "FALL OPEN HOUSE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2026-09-26",
|
|
||||||
"time": "10:30 AM",
|
|
||||||
"title": "Upper Frederick Community Day",
|
|
||||||
"location": "Municipal Building",
|
|
||||||
"unit": "both"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2026-10-02",
|
|
||||||
"time": "6:00 PM",
|
|
||||||
"title": "Parent & Scout Movie Night",
|
|
||||||
"location": "St. Luke's",
|
|
||||||
"unit": "pack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2026-10-10",
|
|
||||||
"title": "Haunted Hayride (scouts performing)",
|
|
||||||
"location": "Green Lane Park",
|
|
||||||
"unit": "pack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2026-10-17",
|
|
||||||
"time": "7:00 AM",
|
|
||||||
"title": "Farmers Market chocolate booth",
|
|
||||||
"location": "Green Lane Park",
|
|
||||||
"unit": "pack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2026-10-17",
|
|
||||||
"end": "2026-10-18",
|
|
||||||
"time": "from 4:00 PM",
|
|
||||||
"title": "Fall Campout",
|
|
||||||
"location": "Green Lane Park",
|
|
||||||
"unit": "both",
|
|
||||||
"badge": "OVERNIGHT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2026-10-27",
|
|
||||||
"time": "6:00 PM",
|
|
||||||
"title": "Trunk or Treat pack meeting",
|
|
||||||
"location": "St. Luke's",
|
|
||||||
"unit": "pack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2026-11-07",
|
|
||||||
"time": "1:00 PM",
|
|
||||||
"title": "Pack Hike",
|
|
||||||
"location": "Schwenksville",
|
|
||||||
"unit": "pack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2026-11-21",
|
|
||||||
"time": "3:00 PM",
|
|
||||||
"title": "Gettysburg Trip",
|
|
||||||
"unit": "troop"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2026-12-05",
|
|
||||||
"time": "1:00 PM",
|
|
||||||
"title": "Pack Hike",
|
|
||||||
"location": "Perkiomen Trail, Crusher Road",
|
|
||||||
"unit": "pack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2026-12-11",
|
|
||||||
"time": "6:00 PM",
|
|
||||||
"title": "Parent & Scout Movie Night",
|
|
||||||
"location": "St. Luke's",
|
|
||||||
"unit": "pack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2026-12-12",
|
|
||||||
"end": "2026-12-13",
|
|
||||||
"title": "Lock In",
|
|
||||||
"unit": "troop"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2027-01-16",
|
|
||||||
"title": "Pack Mario Party",
|
|
||||||
"location": "St. Luke's",
|
|
||||||
"unit": "pack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2027-01-16",
|
|
||||||
"end": "2027-01-17",
|
|
||||||
"title": "Cabin Camping",
|
|
||||||
"location": "French Creek State Park",
|
|
||||||
"unit": "troop"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2027-01-23",
|
|
||||||
"time": "1:00 PM",
|
|
||||||
"title": "Pack Hike",
|
|
||||||
"location": "Red Trail, Pennsburg",
|
|
||||||
"unit": "pack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2027-02-06",
|
|
||||||
"title": "Blue & Gold banquet",
|
|
||||||
"location": "St. Luke's",
|
|
||||||
"unit": "pack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2027-02-20",
|
|
||||||
"title": "Pinewood Derby",
|
|
||||||
"location": "St. Luke's",
|
|
||||||
"unit": "pack",
|
|
||||||
"badge": "THE BIG ONE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2027-02-27",
|
|
||||||
"time": "1:00 PM",
|
|
||||||
"title": "Pack Hike",
|
|
||||||
"location": "East Greenville",
|
|
||||||
"unit": "pack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2027-03-13",
|
|
||||||
"time": "1:00 PM",
|
|
||||||
"title": "Pack Hike",
|
|
||||||
"location": "Black Rock Rd, Royersford",
|
|
||||||
"unit": "pack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2027-03-19",
|
|
||||||
"time": "6:00 PM",
|
|
||||||
"title": "Parent & Scout Movie Night",
|
|
||||||
"location": "St. Luke's",
|
|
||||||
"unit": "pack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2027-04-10",
|
|
||||||
"title": "Pack Spring Campout",
|
|
||||||
"location": "Camp Garrison",
|
|
||||||
"unit": "pack",
|
|
||||||
"badge": "OVERNIGHT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2027-04-24",
|
|
||||||
"title": "Arrow of Light Crossover & Awards",
|
|
||||||
"location": "St. Luke's",
|
|
||||||
"unit": "both"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2027-05-08",
|
|
||||||
"time": "1:00 PM",
|
|
||||||
"title": "Pack Bike Ride",
|
|
||||||
"location": "Swamp Creek Park",
|
|
||||||
"unit": "pack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2027-05-21",
|
|
||||||
"time": "6:00 PM",
|
|
||||||
"title": "Parent & Scout Movie Night",
|
|
||||||
"location": "St. Luke's",
|
|
||||||
"unit": "pack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2027-05-25",
|
|
||||||
"time": "6:00 PM",
|
|
||||||
"title": "Kickball Party",
|
|
||||||
"location": "St. Luke's",
|
|
||||||
"unit": "pack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "2027-05-29",
|
|
||||||
"time": "1:00 PM",
|
|
||||||
"title": "Pack Hike",
|
|
||||||
"location": "Perkiomenville",
|
|
||||||
"unit": "pack"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
+3
-2
@@ -6,8 +6,8 @@
|
|||||||
# - no env_file: NTFY_URL comes from the Portainer stack env
|
# - no env_file: NTFY_URL comes from the Portainer stack env
|
||||||
# Images live OUTSIDE the repo at /srv/scout-website-assets/img (bind-mounted
|
# Images live OUTSIDE the repo at /srv/scout-website-assets/img (bind-mounted
|
||||||
# read-only) so photos never bloat clones.
|
# read-only) so photos never bloat clones.
|
||||||
# The live, hand-editable calendar is /srv/scout-website/data/events.json
|
# The calendar comes from the scout-calendar feed (EVENTS_FEED_URL, set in
|
||||||
# (seeded from app/events.json on first boot; see README).
|
# the Portainer stack env); /data/events-cache.json is the stale fallback.
|
||||||
|
|
||||||
name: scout-website
|
name: scout-website
|
||||||
services:
|
services:
|
||||||
@@ -25,6 +25,7 @@ services:
|
|||||||
- NTFY_TOPIC=${NTFY_TOPIC}
|
- NTFY_TOPIC=${NTFY_TOPIC}
|
||||||
- NTFY_TOKEN=${NTFY_TOKEN}
|
- NTFY_TOKEN=${NTFY_TOKEN}
|
||||||
- ADMIN_TOKEN=${ADMIN_TOKEN}
|
- ADMIN_TOKEN=${ADMIN_TOKEN}
|
||||||
|
- EVENTS_FEED_URL=${EVENTS_FEED_URL}
|
||||||
volumes:
|
volumes:
|
||||||
- /srv/scout-website/data:/data
|
- /srv/scout-website/data:/data
|
||||||
- /srv/scout-website/secrets/service_account.json:/app/service_account.json:ro
|
- /srv/scout-website/secrets/service_account.json:/app/service_account.json:ro
|
||||||
|
|||||||
Reference in New Issue
Block a user