import html, json, os, re, time, datetime, urllib.request, urllib.parse from pathlib import Path from fastapi import FastAPI, Form, Request from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles # Internal record store + admin API. The store is the source of truth for # anything a family submits; see store.py for why. import store import admin_api import documents # --------------------------------------------------------------------------- # Pack & Troop 73 - greenlanescouts73.org # 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_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. SHEET_ID = os.environ.get("SHEET_ID", "").strip() SA_FILE = os.environ.get("SA_FILE", "/app/service_account.json") NTFY_BASE = os.environ.get("NTFY_BASE", "").rstrip("/") NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "scouting") NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "").strip() SHEET_HEADERS = ["Timestamp", "Parent Name", "Email", "Phone", "Children (name + current grade)", "Interested In", "How They Heard", "Comments"] # --------------------------------------------------------------------------- # The seasonal surface. # # These are the only strings on the site that go stale on a schedule rather # than when something actually changes. They live here so a meeting time move # is one edit instead of twelve, and so the next person can see the whole # seasonal surface at a glance rather than grepping for it. # # This is NOT a CMS and must not grow into one. Anything with a date in it # belongs in the calendar feed, not here - a recruitment banner should read # the next join night and render nothing when there is not one. Anything # else is evergreen copy and belongs inline where git log can answer what # the site said during recruitment. # --------------------------------------------------------------------------- PROGRAM_YEAR = "2026-2027" MEETING_DAY = "Tuesday" MEETING_DAYS = "Tuesdays" MEETING_DAY_ABBR = "Tue" PACK_TIME = "6:00 PM" TROOP_TIME = "7:30 PM" PACK_CLOCK = "6:00" TROOP_CLOCK = "7:30" app = FastAPI(title="Pack & Troop 73") app.mount("/static", StaticFiles(directory="/app/static"), name="static") # Creates the schema if absent and backfills the legacy leads.jsonl once. store.init(backfill_jsonl=LEADS) app.include_router(admin_api.router) 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(): """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: 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: 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"]) e = datetime.date.fromisoformat(ev.get("end", ev["date"])) return s, e def next_up(units, n=3): today = datetime.date.today() out = [] for ev in load_events(): u = ev.get("unit", "pack") if u not in units and u != "both": continue s, e = _dates(ev) if e >= today: out.append((s, ev)) out.sort(key=lambda t: t[0]) return [ev for _, ev in out[:n]] def unit_label(u): return {"troop": "TROOP", "both": "ALL 73"}.get(u, "PACK") def ev_meta(ev, s): return " · ".join(x for x in [DW[s.weekday()], ev.get("time"), ev.get("location")] if x) def next_up_cards(events, show_unit=False): rows = [] for ev in events: s, _ = _dates(ev) chips = "" if show_unit: chips += f'{unit_label(ev.get("unit","pack"))}' if ev.get("badge"): chips += f'{ev["badge"]}' rows.append(f'''
{MB[s.month-1]}
{s.day}
{ev["title"]}{chips}
{ev_meta(ev, s)}
''') return "\n".join(rows) CSS = """ *{box-sizing:border-box}html{scroll-behavior:smooth} body{margin:0;background:#FBF8F1;color:#232838;font-family:'Public Sans',system-ui,sans-serif;line-height:1.6} a{color:#35608F}a:hover{color:#1E2F52}p{text-wrap:pretty} ::selection{background:#F0AD2D;color:#1E2F52} .wrap{max-width:1160px;margin:0 auto;padding:0 24px} .arch{font-family:'Archivo',sans-serif} header.nav{position:sticky;top:0;z-index:60;background:rgba(251,248,241,.94);backdrop-filter:blur(10px);border-bottom:1px solid #E8E1D0} .navrow{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:70px;flex-wrap:wrap} .brand{display:flex;align-items:center;gap:12px;text-decoration:none;padding:10px 0} .brand img{width:48px;height:48px;border-radius:50%;object-fit:cover;display:block;box-shadow:0 1px 4px rgba(16,25,44,.3)} .brand .b1{font-family:'Archivo';font-weight:800;font-size:15px;letter-spacing:.06em;color:#1E2F52;display:block;line-height:1.25} .brand .b2{font-size:11px;color:#6A7280;letter-spacing:.02em;display:block;line-height:1.25} nav.links{display:flex;align-items:center;gap:26px;flex-wrap:wrap} nav.links a{text-decoration:none;font-weight:600;font-size:15px;color:#333D52;padding:6px 0} nav.links a:hover{color:#1E2F52} nav.links a.on{font-weight:700;color:#1E2F52;border-bottom:2px solid #F0AD2D} nav.links a.joinbtn{display:inline-block;line-height:1.2;font-family:'Archivo';font-weight:700;font-size:15px;background:#F0AD2D;color:#1E2F52 !important;padding:11px 22px;border-radius:999px;border-bottom:none !important} nav.links a.joinbtn:hover{background:#DB9714} .hero{position:relative;display:flex;align-items:flex-end;min-height:520px} .hero .wrap{padding-top:110px;padding-bottom:58px;width:100%} .eyebrow{font-family:'Archivo';font-weight:700;font-size:13px;letter-spacing:.2em;text-transform:uppercase;margin-bottom:12px} .eyebrow.gold{color:#F7C556}.eyebrow.dark{color:#B07B12} .hero h1{margin:0 0 16px;font-family:'Archivo';font-weight:800;font-size:clamp(2.4rem,5vw,4rem);line-height:1.05;letter-spacing:-.015em;color:#fff;max-width:15ch} .hero p{margin:0;font-size:clamp(1.05rem,1.7vw,1.2rem);color:rgba(255,255,255,.94);max-width:620px} .cta{display:inline-block;font-family:'Archivo';font-weight:700;font-size:16px;background:#F0AD2D;color:#1E2F52;padding:15px 30px;border-radius:999px;text-decoration:none} .cta:hover{background:#F7C556;color:#1E2F52} .cta.ghost{background:transparent;color:#fff;border:2px solid rgba(255,255,255,.85);padding:13px 28px} .cta.ghost:hover{background:rgba(255,255,255,.14);color:#fff} h2.sec{margin:0 0 14px;font-family:'Archivo';font-weight:800;font-size:clamp(1.7rem,3vw,2.4rem);letter-spacing:-.01em;color:#1E2F52} .lead{margin:0 0 40px;color:#5A6272;font-size:1.08rem;max-width:640px} .facts{background:#fff;border-radius:16px;box-shadow:0 18px 40px rgba(16,25,44,.12);display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:1px;overflow:hidden;border:1px solid #EBE4D4;position:relative;z-index:5;margin-top:-44px} .facts>div{padding:22px 26px;background:#fff} .facts .fl{font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:#8B93A3;margin-bottom:4px} .facts .fv{font-family:'Archivo';font-weight:700;font-size:17px;color:#1E2F52} .grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:28px} .grid3{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:18px} .grid4{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:24px} .gridcards{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px} .pcard{text-decoration:none;background:#fff;border:1px solid #EBE4D4;border-radius:18px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 6px 18px rgba(16,25,44,.06);transition:transform .15s;color:inherit} .pcard:hover{transform:translateY(-4px);color:inherit} .pcard img{width:100%;height:270px;object-fit:cover;display:block} .pcard .pad{padding:26px 28px 30px;display:flex;flex-direction:column;gap:10px} .pcard h3{margin:0;font-family:'Archivo';font-weight:800;font-size:1.45rem;color:#1E2F52} .chip{font-size:12px;font-weight:700;letter-spacing:.08em;padding:4px 12px;border-radius:999px;white-space:nowrap} .chip.gold{background:#F3EBD8;color:#7A5B00}.chip.blue{background:#E3E9F2;color:#1E2F52} .pcard p{margin:0;color:#5A6272} .plink{font-family:'Archivo';font-weight:700;color:#35608F} .evcard{display:flex;gap:16px;align-items:center;background:#fff;border:1px solid #EBE4D4;border-radius:14px;padding:16px 18px} .datechip{width:60px;min-width:60px;text-align:center;background:#1E2F52;border-radius:12px;padding:9px 4px} .dc-m{font-size:11px;font-weight:700;letter-spacing:.12em;color:#F7C556} .dc-d{font-family:'Archivo';font-weight:800;font-size:21px;color:#fff;line-height:1.15} .ev-t{font-family:'Archivo';font-weight:700;font-size:16px;color:#1E2F52;display:flex;gap:10px;align-items:center;flex-wrap:wrap} .ev-m{color:#6A7280;font-size:14px} .uchip{font-size:11px;font-weight:700;letter-spacing:.08em;padding:3px 10px;border-radius:999px;white-space:nowrap} .uchip-n{background:#EEF1F6;color:#33415E} .uchip-pack{background:#F3EBD8;color:#7A5B00}.uchip-troop{background:#F6E3DA;color:#8A3B1D}.uchip-both{background:#E3E9F2;color:#1E2F52} .badge{font-size:11px;font-weight:700;letter-spacing:.08em;background:#F0AD2D;color:#1E2F52;padding:3px 10px;border-radius:999px;white-space:nowrap} .noev{background:#fff;border:1px dashed #CBD2DC;border-radius:14px;padding:22px 24px;color:#5A6272;font-size:15px} .steps{display:flex;flex-direction:column;gap:22px} .step{display:flex;gap:18px} .step .n{font-family:'Archivo';font-weight:800;font-size:28px;color:#F0AD2D;min-width:44px} .step .t{font-family:'Archivo';font-weight:700;font-size:17px;color:#1E2F52;margin-bottom:2px} .step .d{color:#5A6272;font-size:15px} .photoband img{width:100%;height:250px;object-fit:cover;border-radius:14px;display:block} .volband{background:#1E2F52;border-radius:20px;overflow:hidden;display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr))} .volband img{width:100%;height:100%;min-height:320px;object-fit:cover;display:block} .volband .pad{padding:46px 44px;display:flex;flex-direction:column;justify-content:center} .volband h2{margin:0 0 14px;font-family:'Archivo';font-weight:800;font-size:clamp(1.7rem,3vw,2.3rem);color:#fff;letter-spacing:-.01em} .volband p{margin:0 0 12px;color:#D8DFEA;font-size:15.5px;line-height:1.7} .vchip{font-size:12.5px;font-weight:700;background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.22);color:#fff;padding:6px 14px;border-radius:999px} .band{position:relative;padding:96px 24px;text-align:center} .band h2{margin:0 0 14px;font-family:'Archivo';font-weight:800;font-size:clamp(1.9rem,3.6vw,2.7rem);color:#fff;letter-spacing:-.01em} .band p{margin:0 auto 28px;color:rgba(255,255,255,.92);font-size:1.08rem;max-width:560px} .rank{border-radius:14px;padding:20px 18px;border:1px solid #EBE4D4} .rank .rl{font-size:11px;font-weight:700;letter-spacing:.12em;margin-bottom:6px;color:#B07B12} .rank .rn{font-family:'Archivo';font-weight:800;font-size:1.15rem;color:#1E2F52;margin-bottom:4px} .rank .rd{font-size:13.5px;color:#6A7280} .rankgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px} .r1{background:linear-gradient(180deg,#fff,#FFFBF2);border-top:5px solid #F7C556} .r2{background:linear-gradient(180deg,#fff,#FEF7E8);border-top:5px solid #F0AD2D} .r3{background:linear-gradient(180deg,#fff,#FDF2DC);border-top:5px solid #E29A25} .r4{background:linear-gradient(180deg,#fff,#FBEDD2);border-top:5px solid #D4842A}.r4 .rl{color:#A86A15} .r5{background:linear-gradient(180deg,#fff,#F9E6D0);border-top:5px solid #C4552D}.r5 .rl{color:#A86A15} .r6{background:linear-gradient(180deg,#fff,#F3DECD);border-top:5px solid #8A3B1D}.r6 .rl{color:#8A3B1D} .r7{background:linear-gradient(160deg,#1E2F52,#2C4470);border:1px solid #1E2F52;border-top:5px solid #F0AD2D} .r7 .rl{color:#F7C556}.r7 .rn{color:#fff}.r7 .rd{color:#C3CBDA} .ycard{background:#fff;border:1px solid #EBE4D4;border-radius:16px;overflow:hidden} .ycard img{width:100%;height:190px;object-fit:cover;display:block} .ycard .pad{padding:20px 22px} .ycard .t{font-family:'Archivo';font-weight:800;font-size:1.1rem;color:#1E2F52;margin-bottom:4px} .ycard .d{font-size:14.5px;color:#5A6272} .infocard{background:#fff;border:1px solid #EBE4D4;border-radius:18px;overflow:hidden;display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));align-items:stretch} .infocard .pad{padding:34px 36px} .infocard img{width:100%;height:100%;min-height:300px;object-fit:cover;display:block} .rrow{display:flex;gap:12px;align-items:baseline} .rrow .k{font-family:'Archivo';font-weight:800;color:#F0AD2D;font-size:1.05rem;min-width:96px} .rrow .v{color:#333D52;font-size:15px} .calhead{background:#1E2F52;padding:72px 0 64px} .calhead h1{margin:0 0 18px;font-family:'Archivo';font-weight:800;font-size:clamp(2.4rem,5vw,4rem);line-height:1.02;letter-spacing:-.015em;color:#fff} .calhead .sub{margin:0 0 34px;color:#C3CBDA;font-size:1.08rem;max-width:640px} .rhythm{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px} .rhythm>div{background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.14);border-radius:14px;padding:18px 22px} .rhythm .t{font-family:'Archivo';font-weight:800;color:#F7C556;font-size:1.05rem;margin-bottom:2px} .rhythm .d{color:#D8DFEA;font-size:14px} select.unitsel{font-family:'Archivo';font-weight:700;font-size:15px;color:#1E2F52;background:#fff;border:1.5px solid #CBD2DC;border-radius:12px;padding:12px 18px;cursor:pointer;min-width:240px} .mcard{background:#fff;border:1px solid #EBE4D4;border-radius:16px;padding:26px 28px} .mhead{display:flex;align-items:baseline;justify-content:space-between;border-bottom:2px solid #F0AD2D;padding-bottom:10px;margin-bottom:16px} .mhead .mn{font-family:'Archivo';font-weight:800;font-size:1.35rem;color:#1E2F52} .mhead .my{font-size:13px;font-weight:700;color:#8B93A3} .mrow{display:flex;gap:14px;align-items:baseline} .mrow .ml{font-family:'Archivo';font-weight:800;color:#B07B12;min-width:84px;font-size:14px} .mrow .mt{font-size:15px;color:#232838} .mrow .mm{color:#5A6272} .note{background:#F3EBD8;border:1px solid #E8DCBB;border-radius:14px;padding:18px 24px;color:#6B5510;font-size:14.5px} form.lead{background:#fff;border:1px solid #EBE4D4;border-radius:18px;padding:32px 32px 34px;display:grid;gap:18px;box-shadow:0 14px 34px rgba(16,25,44,.08)} form.lead .ft{font-family:'Archivo';font-weight:800;font-size:1.3rem;color:#1E2F52} .frow{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px} label{display:block;font-weight:600;font-size:14px;margin-bottom:6px;color:#333D52} input,select,textarea{width:100%;padding:12px 13px;border:1px solid #CBD2DC;border-radius:9px;font:inherit;background:#fff} input:focus,select:focus,textarea:focus{outline:2px solid #35608F;border-color:#35608F} textarea{resize:vertical} button.cta{border:none;cursor:pointer} .req{color:#C4552D} .success{background:#E4F2E4;border:1px solid #9CC79C;color:#1E5A2E;border-radius:12px;padding:16px 20px;font-weight:600;margin-bottom:18px} .faq{background:#fff;border:1px solid #EBE4D4;border-radius:16px;padding:24px 26px} .faq .q{font-family:'Archivo';font-weight:800;font-size:1.05rem;color:#1E2F52;margin-bottom:6px} .faq .a{font-size:14.5px;color:#5A6272} footer{background:#1E2F52;color:#C3CBDA;padding:52px 0 40px;margin-top:auto} .footgrid{display:flex;justify-content:space-between;gap:36px;flex-wrap:wrap} footer .fh{font-family:'Archivo';font-weight:700;color:#F7C556;font-size:13px;letter-spacing:.14em;margin-bottom:12px} footer a{color:#D8DFEA;text-decoration:none;font-size:14px} footer a:hover{color:#fff} footer a.gold{color:#F7C556;font-weight:700} footer a.fb{display:inline-flex;align-items:center;gap:8px} .docrow{display:flex;gap:16px;align-items:center;background:#fff;border:1px solid #EBE4D4;border-radius:14px;padding:16px 18px;text-decoration:none;color:inherit;transition:transform .12s,border-color .12s} .docrow:hover{transform:translateY(-2px);border-color:#CBD2DC;color:inherit} .docicon{font-family:'Archivo';font-weight:800;font-size:12px;letter-spacing:.06em;color:#fff;background:#1E2F52;border-radius:10px;padding:13px 0;width:58px;min-width:58px;text-align:center} .docmain{flex:1;min-width:0} .doct{font-family:'Archivo';font-weight:700;font-size:16px;color:#1E2F52;display:flex;gap:10px;align-items:center;flex-wrap:wrap} .docd{display:block;margin-top:3px;color:#6A7280;font-size:14px} .docmeta{font-size:12.5px;font-weight:600;color:#8B93A3;white-space:nowrap} @media(max-width:560px){.docrow{flex-wrap:wrap}.docmeta{width:100%;padding-left:74px}} .annc{border-bottom:1px solid;font-family:'Public Sans',system-ui,sans-serif} .annc-info{background:#F3EBD8;border-color:#E8DCBB;color:#6B5510} .annc-urgent{background:#F6E3DA;border-color:#DFB9A4;color:#8A3B1D} .anncrow{display:flex;align-items:flex-start;gap:9px;padding:11px 0} .topstick{position:sticky;top:0;z-index:60} .topstick header.nav{position:static} .topstick:has(.annc[open]){position:static} .annc summary{cursor:pointer;list-style:none;-webkit-tap-highlight-color:transparent} .annc summary::-webkit-details-marker{display:none} .annc summary:focus-visible{outline:2px solid currentColor;outline-offset:-3px} .anncico,.anncchev{flex:none;display:flex;align-items:center;margin-top:1px} .anncchev{transition:transform .15s ease} .annc[open] .anncchev{transform:rotate(180deg)} .anncline{flex:1;min-width:0;font-size:14px;line-height:1.4;overflow-wrap:anywhere} .ancctop{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden} @media(max-width:560px){.ancctop{-webkit-line-clamp:3}} .anncopen{display:none} .annc[open] .ancctop{display:none} .annc[open] .anncopen{display:block;font-weight:600} .annccount{flex:none;margin-top:1px;font-size:11px;font-weight:700;padding:1px 7px;border-radius:999px;border:1px solid currentColor} .annc[open] .annccount{display:none} .anncbody{max-height:40vh;overflow-y:auto;padding-bottom:12px} .anncitem{padding:10px 0 0;font-size:14px;line-height:1.5;overflow-wrap:anywhere} .anncitem+.anncitem{border-top:1px solid rgba(0,0,0,.09);padding-top:10px;margin-top:10px} .anncitem a{color:inherit;text-decoration:underline;font-size:13px} .ancclink{flex:none;color:inherit;text-decoration:underline;font-size:13px;white-space:nowrap} """ ICO_INFO = ('') ICO_URGENT = ('') ICO_CHEV = ('') def _annc_item(a): """One announcement in the expanded list. Text is escaped; the link is rendered only from a url store.create_announcement already validated.""" out = '
%s' % html.escape(a["message"]) if a.get("link_url"): out += '
%s' % ( html.escape(a["link_url"], quote=True), html.escape(a.get("link_text") or "More details")) return out + "
" def announcement_bar(): """Render the live announcements, or nothing at all. FAILS OPEN, deliberately. The admin API fails closed because it serves family phone numbers; this is the opposite case. A broken announcement must never take down the public homepage, so any error here renders an empty string and logs. Collapsed clamps to two lines, three on a phone, because one line cut the actual location out of a venue change - the only part of the notice that mattered. A single notice does not clamp at all: it has no expand affordance, so clamping it would lose text with no way to recover it. Collapsed shows the top-ranked notice plus a count. Expanded lists all of them. Native
, so there is no JavaScript and nothing to load - which matters on a read-only rootfs with no build step. The single-notice case, which is nearly always the case, renders with no chevron and no count: it must not look like a widget. """ try: items = store.active_announcements() except Exception as e: print("announcement_bar: %s" % e, flush=True) return "" if not items: return "" top = items[0] level = "urgent" if any(i["level"] == "urgent" for i in items) else "info" ico = ICO_URGENT if level == "urgent" else ICO_INFO label = "Urgent notice" if level == "urgent" else "Notice" if len(items) == 1: link = "" if top.get("link_url"): link = ('%s' % ( html.escape(top["link_url"], quote=True), html.escape(top.get("link_text") or "More details"))) return ('
' '%s' '%s%s' '
' % (level, label, ico, html.escape(top["message"]), link)) body = "".join(_annc_item(i) for i in items) return ('
' '%s' '%s' '%d notices' '+%d' '%s' '
%s
' % (level, ico, html.escape(top["message"]), len(items), len(items) - 1, ICO_CHEV, body)) def page(title, body, active=""): def on(k): return ' class="on"' if active == k else "" return f""" {title}
{announcement_bar()}
{body} """ def hero(img, pos, eyebrow, h1, sub, extra="", minh=520): return f"""
{eyebrow}

{h1}

{sub}

{extra}
""" @app.get("/health") def health(): return {"status": "ok"} @app.get("/", response_class=HTMLResponse) def home(): up = next_up({"pack", "troop"}, 3) ev_html = next_up_cards(up, show_unit=True) or '
Nothing on the books right now. New dates land on the shared calendar first, so check back soon or message us.
' body = f""" {hero('autumn-hike.jpg', 'center', 'Family Scouting · Zieglerville, PA', 'Where kids find their trail.', 'A Cub Scout pack and a Scout troop for kindergarten through age 17. Camp, build, lead, and grow alongside friends, right here in the Perkiomen Valley.', '
Join usSee the year
', 640)}
Meetings
{MEETING_DAY_ABBR} · Pack {PACK_CLOCK} · Troop {TROOP_CLOCK}
Where
St. Luke's Lutheran Church
Ages
Kindergarten to 17
Everyone
Every kid welcome · first visit free
Coming up at 73

Next up at 73.

{ev_html}
This list updates itself from the shared 73 calendar.
See the full {PROGRAM_YEAR} calendar →
New here?

Your first visit, step by step.

01
Say hello
Send the interest form and a real leader (a local parent, not a call center) replies within a day or two.
02
Drop in on a {MEETING_DAY}
Come watch a meeting, no commitment and nothing to bring. Kids usually join the fun within ten minutes.
03
Take it at your pace
Register when your family is ready. We help with uniforms, costs, and every question in between.
Start with the form →
Scouts building model rockets A Lion Cub Scout working on a craft A scout climbing at the crag
Parents and scouts exploring a trail together
Parents make it go

We run on volunteers.

Every derby track, campfire, and trail snack table is parent-powered. Nobody shows up knowing how; training is provided and the pack will happily teach you the knots.

Help comes in every size: one chocolate booth shift, planning a hike, running a derby heat, or leading a den. The more hands we have, the better the year gets for every kid.

One-shift helpersHike plannersDerby pit crewDen leaders
Raise your hand →

Your first campfire is waiting.

Tell us a little about your child and we will save you a seat at the next meeting.

Join Pack & Troop 73
""" return page("Pack & Troop 73 · Family Scouting in Zieglerville, PA", body, "home") @app.get("/cubs", response_class=HTMLResponse) def cubs(): up = next_up({"pack"}, 3) ev_html = next_up_cards(up) or '
Nothing on the books right now. New pack dates land on the shared calendar first, so check back soon or message us.
' body = f""" {hero('circle-game.jpg', 'center 30%', 'Pack 73 · Grades K-5', 'Little kids. Big adventures.', 'Cub Scouts is where it starts: derby cars, first hikes, campfire songs, and a den of friends who cheer each other on.')}
How it works

A den for every grade, a pack for every family.

Scouts meet in small grade-level dens, and the whole pack comes together for the big stuff: derbies, campouts, and award nights. Meetings are {MEETING_DAYS} at {PACK_TIME} at St. Luke's, so siblings in different dens land on the same night.

A parent or guardian stays for every Cub Scout meeting and event; it is a do-it-together program. Lions and Tigers work hands-on with their grown-up, while older dens mostly need you in the room (and extra hands are always put to work).

A Lion Cub Scout working on a craft
The trail through Cub Scouts

Six ranks, kindergarten to crossover.

KINDERGARTEN
Lion
Big firsts with a grown-up along for every step.
1ST GRADE
Tiger
Games, field trips, and the first real badges.
2ND GRADE
Wolf
More independence, more time on the trail.
3RD GRADE
Bear
Whittling chips, science, and real outdoor skills.
4TH GRADE
Webelos
Camping like the big kids and leading the little ones.
5TH GRADE
Arrow of Light
The bridge to the troop, crossing over each spring.
A year in the life

The traditions your kid will talk about all week.

Painting pinewood derby cars
Derby season, twice
Boxcar Derby in September, the classic Pinewood Derby in February. Build it, paint it, race it.
Setting up tents at the fall campout
Family campouts
Fall and spring weekends under the stars at Green Lane Park and Camp Garrison, whole families welcome.
Scouts on a monthly pack hike
Monthly pack hikes
A different trailhead almost every month, from the Perkiomen Trail to Green Lane. Strollers welcome.
Scouts with model rockets
Movie nights & more
Rockets, Trunk or Treat, Blue & Gold banquet, Mario Party night, and a kickball party to close the year.
On the calendar

Next up for Pack 73.

{ev_html}
See the full pack calendar →
Keeping it affordable

The famous chocolate booth.

You will find us selling chocolate bars at the Green Lane Farmers Market on Saturday mornings in season. Fundraisers like the booth keep dues low and help every scout get to camp. Cost should never keep a kid out of Scouting; if it is a stretch, talk to us.

{MEETING_DAYS}Den and pack meetings, {PACK_TIME} at St. Luke's
MonthlyWeekend pack hike, usually 1:00 PM
SaturdaysChocolate booth at the Green Lane Farmers Market, 7:00 AM in season

Come try a {MEETING_DAY}.

Your first visit is free and easy: just show up and see if it clicks. Most kids know by the end of the night.

Tell us you're coming
""" return page("Cub Scouts · Pack 73 · Zieglerville, PA", body, "cubs") @app.get("/troop", response_class=HTMLResponse) def troop(): up = next_up({"troop"}, 3) ev_html = next_up_cards(up) or '
Nothing on the books right now. New troop dates land on the shared calendar first, so check back soon or message us.
' body = f""" {hero('troop-hero.jpg', 'center 35%', 'Troop 73 · Ages 11-17', 'Youth led. Outdoors first.', 'The troop is where kids run the show: planning trips, leading patrols, and learning the kind of confidence you cannot teach indoors.')}
How it works

The scouts run it. Really.

Troop 73 is organized into patrols that elect their leaders, plan the trips, cook the meals, and fix their own mistakes. Trained, background-checked adults coach from the back row.

We meet {MEETING_DAYS} at {TROOP_TIME} at St. Luke's, right after the pack, so scouting families land on one night. Every kid ages 11 to 17 is welcome, with or without a Cub Scout past. (Officially we charter as a Scouts BSA troop. Around here, it's just the troop.)

Founded in spring 2026 at St. Luke's, the troop is built by the scouts in it; the traditions are theirs to invent.

Scouts around the campfire circle at dusk
The trail through the troop

Seven ranks, first knot to Eagle.

STOP 1
Scout
The basics, the oath, and the handshake. Everyone starts here.
STOP 2
Tenderfoot
First campout skills: knots, flags, and first aid.
STOP 3
Second Class
Navigation, outdoor cooking, and safe swimming.
STOP 4
First Class
The full outdoor toolkit, ready to lead a patrol.
STOP 5
Star
Leadership roles and merit badges start stacking up.
STOP 6
Life
Leading service projects and mentoring younger scouts.
THE SUMMIT
Eagle
Scouting's highest rank, earned with a big service project of their own.
A year in the life

Weekends worth putting the phone down for.

Scouts relaxing at a campsite
Campouts in every season
Fall, winter, and spring weekends. Scouts plan the menu, pack the trailer, and run the camp.
Scouts whitewater rafting
On the water
Canoe and river trips when the weather warms, including the troop's May river run.
A scout on a climbing wall
Climb, build, navigate
Climbing, orienteering, camp gadgets, and the Klondike winter derby: skills merit badges are made of.
Scouts doing a community service project
Service & Courts of Honor
Projects that make the town better, then a Court of Honor where every rank gets its moment.
On the calendar

Next up for Troop 73.

{ev_html}
See the full troop calendar →
The rhythm

One night a week, one adventure a month.

{MEETING_DAYS}Troop meeting, {TROOP_TIME} at St. Luke's, right after the pack
Most monthsA weekend campout, trip, or service project
All yearMerit badges and rank work at each scout's own pace

Registration plus modest troop dues; fundraisers keep costs low and gear can be borrowed. Cost never keeps a kid off the trail.

Scouts in uniform at a Court of Honor

No experience needed. Curiosity helps.

Plenty of great scouts never spent a day in Cub Scouts. If your kid is 11 to 17 and likes the idea of a weekend outdoors, they belong here.

Come watch a {MEETING_DAY}.

The troop takes over at {TROOP_TIME} at St. Luke's. Drop in, meet the patrols, stay if it clicks.

Message the troop
""" return page("The Troop · Troop 73 · Zieglerville, PA", body, "troop") @app.get("/calendar", response_class=HTMLResponse) def calendar(unit: str = "all"): if unit not in ("all", "pack", "troop"): unit = "all" groups = {} for ev in load_events(): u = ev.get("unit", "pack") if unit != "all" and u != unit and u != "both": continue s, e = _dates(ev) key = s.year * 12 + s.month groups.setdefault(key, []).append((s, e, ev)) cards = [] for key in sorted(groups): rows = [] for s, e, ev in sorted(groups[key], key=lambda t: t[0]): label = f"{DWU[s.weekday()]} {s.day}" + (f"-{e.day}" if e != s else "") u = ev.get("unit", "pack") badge = f' {ev["badge"]}' if ev.get("badge") else "" meta = " · ".join(x for x in [ev.get("time"), ev.get("location")] if x) meta = f' · {meta}' if meta else "" rows.append(f'
{label}{ev["title"]} {unit_label(u)}{badge}{meta}
') y, m = divmod(key - 1, 12) cards.append(f'
{MF[m]}{y}
{"".join(rows)}
') sel = {u: " selected" if unit == u else "" for u in ("all", "pack", "troop")} body = f"""
Pack & Troop 73 program year

The {PROGRAM_YEAR} year, at a glance.

The highlights below are open to visiting families. Come to any of them, or just show up on a {MEETING_DAY}.

Pack {MEETING_DAYS} · {PACK_TIME}
Pack or den meetings at St. Luke's Lutheran Church (off on school holidays)
Troop {MEETING_DAYS} · {TROOP_TIME}
Troop 73 meets right after the pack, same building
About monthly · weekends
Pack hikes at rotating trailheads, usually 1:00 PM
Saturdays in season · 7:00 AM
Chocolate booth at the Green Lane Farmers Market
{"".join(cards)}
Dates occasionally shift. Registered families get the live TeamSnap calendar with every den and troop meeting; the newest info always wins there.
""" return page(f"{PROGRAM_YEAR} Calendar · Pack & Troop 73", body, "cal") # --------------------------------------------------------------------------- # Documents. Files come from the manifest in /docs (see documents.py); they are # deliberately served through this route rather than a static mount, so member # login can gate them later without a single public URL changing. # --------------------------------------------------------------------------- def doc_row(d): label, _ = documents.kind(d) u = d["unit"] chip = f' {unit_label(u)}' if u != "both" else "" desc = f'{d["description"]}' if d["description"] else "" meta = " · ".join(x for x in [documents.size_text(d), documents.updated_text(d)] if x) return (f'' f'{label}' f'{d["title"]}{chip}{desc}' f'{meta}') @app.get("/documents", response_class=HTMLResponse) def documents_index(): groups = documents.listing() if groups: cards = [] for cat, rows in groups: blurb = f'

{cat["blurb"]}

' if cat["blurb"] else "" cards.append(f'

{cat["title"]}

{blurb}' f'
{"".join(doc_row(d) for d in rows)}
') inner = "".join(cards) else: inner = '
Nothing posted here yet. Forms and handouts will land on this page as the program year gets going.
' body = f"""
Pack & Troop 73

Forms and documents.

Everything worth handing out, in one place. These links are permanent, so a QR code or a printed handout keeps working even after the file behind it is replaced.

{inner}
Can't find something, or need a form in another format? Message us on Facebook or catch a leader on a {MEETING_DAY} night.
""" return page("Forms & Documents · Pack & Troop 73", body, "docs") @app.get("/documents/{slug}") def document_file(slug: str): d = documents.find(slug) if d is None: body = """
Pack & Troop 73

That document isn't here.

The link may be out of date, or the file may have been taken down. Everything currently posted is on the documents page.

See all documents
""" return HTMLResponse(page("Not found · Pack & Troop 73", body, "docs"), status_code=404) _, mime = documents.kind(d) headers = {"X-Robots-Tag": "noindex, nofollow"} if documents.noindex(d) else None return FileResponse(d["path"], media_type=mime, filename=d["path"].name, content_disposition_type=documents.disposition(d), headers=headers) FAQ = [ ("What does it cost?", "Annual registration plus modest pack or troop dues. Fundraisers like our chocolate booth keep costs down, and quiet help is available. Cost never keeps a kid out of Pack 73; just talk to us."), ("Do parents stay at meetings?", "Yes: a parent or guardian stays at every Cub Scout meeting and event. Lions and Tigers pair up with their grown-up for the hands-on parts; for older dens, being in the room is enough. Troop scouts (11 to 17) can be dropped off."), ("Which program fits my kid?", "K through 5th grade join Pack 73's dens. Ages 11 to 17 join Troop 73. Fifth graders get the best of both: Arrow of Light now, crossover to the troop in spring."), ("Can we join mid-year?", f"Any {MEETING_DAY} works. Badges and adventures meet kids where they are, so a January start is just as good as a September one."), ("What about uniforms and gear?", "Start with sneakers and a water bottle. Uniform pieces come gradually, and we keep a stash of hand-me-downs. Nobody needs camping gear to start."), ("Who can join?", "Every kid. Pack 73 and Troop 73 are open to all children and teens, whatever their background or beliefs. If your kid wants to camp, build, and belong, there's a spot for them."), ] SOURCE_JS = """""" def thanks_body(): return f"""
Thanks! Your note is on its way. A leader will be in touch soon.
Join Pack & Troop 73

We got it. Here is what happens next.

No commitment, and your first visit is always free. There is nothing else you need to do - we will be in touch with the next meeting date.

1Your form is in. It went straight to our volunteer leaders' phones.
2Hear back within a day or two from a real Pack or Troop 73 parent, with the right den or troop and directions.
3Visit a {MEETING_DAY} at St. Luke's: Pack {PACK_CLOCK}, Troop {TROOP_CLOCK}. Sneakers and a water bottle are all you need.
If a couple of days go by with nothing, check your spam folder, then send the form again. We would rather hear twice than miss you.
See the year ahead What a meeting looks like
Scouts cheering during a pack game
""" def join_body(error=""): err_html = ( '
' + error + "
") if error else "" faq_html = "".join(f'
{q}
{a}
' for q, a in FAQ) return f"""
Join Pack & Troop 73

Come see a meeting. That's it.

Tell us a little about your child and a leader will reach out with the right den or troop, what to expect, and directions. No commitment, and your first visit is always free.

1Send the form. It goes straight to our volunteer leaders' phones.
2Hear back within a day or two from a real Pack or Troop 73 parent.
3Visit a {MEETING_DAY} at St. Luke's: Pack {PACK_CLOCK}, Troop {TROOP_CLOCK}. Sneakers recommended.
Scouts cheering during a pack game
Interest form
{err_html}
List all kids you're considering. Any grade K-12 welcome.
We only use this to reply about Scouting. No newsletters, no sharing.
{SOURCE_JS}
New-family FAQ

The questions every new family asks.

{faq_html}

See you on a {MEETING_DAY}.

St. Luke's Lutheran Church, Zieglerville · Pack {PACK_CLOCK}, Troop {TROOP_CLOCK}. Look for the kids having the most fun in the building.

""" @app.get("/join", response_class=HTMLResponse) def join_get(sent: int = 0): if sent: return page("Thanks · Pack & Troop 73", thanks_body()) return page("Join us · Pack & Troop 73", join_body()) @app.get("/contact") def contact_redirect(): return RedirectResponse(url="/join", status_code=301) def sheet_append(rec): import gspread from google.oauth2.service_account import Credentials creds = Credentials.from_service_account_file(SA_FILE, scopes=["https://www.googleapis.com/auth/spreadsheets"]) sheet = gspread.authorize(creds).open_by_key(SHEET_ID).sheet1 vals = sheet.get_all_values() if not vals: sheet.append_row(SHEET_HEADERS) else: if len(vals[0]) == 7: sheet.update_cell(1, 8, "Comments") # The header used to read "fall grade". Repair it in place rather than # leaving the live sheet disagreeing with the form label. if len(vals[0]) >= 5 and vals[0][4] != SHEET_HEADERS[4]: sheet.update_cell(1, 5, SHEET_HEADERS[4]) # The submission time, not the time of the write. A replay months later must # not relabel a lead as arriving today. stamp = (rec.get("ts") or "")[:16].replace("T", " ") \ or datetime.datetime.now().strftime("%Y-%m-%d %H:%M") sheet.append_row([ stamp, rec["parent_name"], rec["email"], rec["phone"], rec["children"], rec["interested_in"], rec["source"], rec["message"], ]) def pipeline_notify(rec, sheet_error=None): """Push to the shared 'scouting' ntfy topic (JSON endpoint, UTF-8 safe).""" if not NTFY_BASE: return False lines = [ "Email: %s" % rec["email"], "Phone: %s" % (rec["phone"] or "(none given)"), "Kids: %s" % rec["children"], "Wants: %s" % rec["interested_in"], ] if rec["source"]: lines.append("Heard via: %s" % rec["source"]) if rec["message"]: lines.append("Note: %s" % rec["message"]) if sheet_error: lines.append("") lines.append("!! NOT saved to the sheet: %s" % sheet_error) lines.append("!! This push is the only copy - record it manually.") headers = {"Content-Type": "application/json"} if NTFY_TOKEN: headers["Authorization"] = "Bearer " + NTFY_TOKEN try: body = json.dumps({ "topic": NTFY_TOPIC, "title": "New 73 lead - %s" % rec["parent_name"], "message": "\n".join(lines), "tags": ["tent", "inbox_tray"], "priority": 5 if sheet_error else 4, }).encode() rq = urllib.request.Request(NTFY_BASE, data=body, headers=headers) with urllib.request.urlopen(rq, timeout=5) as resp: return resp.status < 400 except Exception: return False # --- form integrity -------------------------------------------------------- # 2026-08-30: three bot submissions landed with both