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 nearby
import documents
import identity
import auth
# ---------------------------------------------------------------------------
# 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"
# Since 2026-09-04 the seven meeting words above are DEFAULTS. The live values
# come from the units table (edited by leaders through the console, P2) via
# identity.meeting_words(), applied by the middleware below before every
# request. A unit row that cannot supply a word leaves the default standing,
# so the constants are still the right place to read what the site falls back
# to - and PROGRAM_YEAR stays a plain constant.
_MEETING_DEFAULTS = dict(MEETING_DAY=MEETING_DAY, MEETING_DAYS=MEETING_DAYS,
MEETING_DAY_ABBR=MEETING_DAY_ABBR, PACK_TIME=PACK_TIME,
TROOP_TIME=TROOP_TIME, PACK_CLOCK=PACK_CLOCK, TROOP_CLOCK=TROOP_CLOCK)
_meeting_cache = {"at": 0.0}
MEETING_TTL = 30
def refresh_meeting_words(force=False):
"""Re-derive the meeting words from the units table, at most every
MEETING_TTL seconds. The page templates are f-strings that read these
module globals when a route runs, so updating the globals is the whole
wiring - no template changes. Any failure keeps the defaults."""
global MEETING_DAY, MEETING_DAYS, MEETING_DAY_ABBR, PACK_TIME, TROOP_TIME, PACK_CLOCK, TROOP_CLOCK
now = time.monotonic()
if not force and now - _meeting_cache["at"] < MEETING_TTL:
return
_meeting_cache["at"] = now
try:
w = identity.meeting_words(identity.list_units(), _MEETING_DEFAULTS)
except Exception as e:
print("meeting words: keeping defaults: %s" % e, flush=True)
return
MEETING_DAY, MEETING_DAYS, MEETING_DAY_ABBR = w["MEETING_DAY"], w["MEETING_DAYS"], w["MEETING_DAY_ABBR"]
PACK_TIME, TROOP_TIME = w["PACK_TIME"], w["TROOP_TIME"]
PACK_CLOCK, TROOP_CLOCK = w["PACK_CLOCK"], w["TROOP_CLOCK"]
app = FastAPI(title="Pack & Troop 73")
app.mount("/static", StaticFiles(directory="/app/static"), name="static")
@app.middleware("http")
async def _meeting_words_middleware(request, call_next):
refresh_meeting_words()
return await call_next(request)
# 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'''
%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}
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.',
'
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.
"""
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).
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.
Derby season, twice
Boxcar Derby in September, the classic Pinewood Derby in February. Build it, paint it, race it.
Family campouts
Fall and spring weekends under the stars at Green Lane Park and Camp Garrison, whole families welcome.
Monthly pack hikes
A different trailhead almost every month, from the Perkiomen Trail to Green Lane. Strollers welcome.
Movie nights & more
Rockets, Trunk or Treat, Blue & Gold banquet, Mario Party night, and a kickball party to close the year.
You will find us selling chocolate bars at the Green Lane Farmers Market on Saturday mornings from May through October. 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.
When you'll find 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, 8:00 AM, May through October
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.
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.
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.
Campouts in every season
Fall, winter, and spring weekends. Scouts plan the menu, pack the trailer, and run the camp.
On the water
Canoe and river trips when the weather warms, including the troop's May river run.
Climb, build, navigate
Climbing, orienteering, camp gadgets, and the Klondike winter derby: skills merit badges are made of.
Service & Courts of Honor
Projects that make the town better, then a Court of Honor where every rank gets its moment.
"""
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(request: Request):
viewer = auth.current_person(request)
units = documents.units_for(viewer)
groups = documents.listing(units)
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.
'
if units:
signin_note = ('
'
'Signed in, so anything posted for families is included above. '
'Your account
')
else:
signin_note = ('
'
'Some handouts are posted for registered families only. '
'Sign in if you have an account.
')
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.
{signin_note}"""
return page("Forms & Documents · Pack & Troop 73", body, "docs")
@app.get("/documents/{slug}")
def document_file(slug: str, request: Request):
# A members document the viewer cannot reach is indistinguishable from one
# that does not exist. That is deliberate: 404, never 403, because a 403
# confirms the document to someone holding nothing but a guessed slug.
d = documents.find(slug, documents.units_for(auth.current_person(request)))
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.
"""
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)
# ---------------------------------------------------------------------------
# Find a unit. Other people's units, from nearby_units (see nearby.py). There
# are deliberately NO entry points elsewhere on the site yet - reachable by
# direct link only until the placement is settled. noindex, so a courtesy
# directory never outranks another unit for its own name.
# ---------------------------------------------------------------------------
@app.get("/find-a-unit", response_class=HTMLResponse)
def find_a_unit():
h = hero("morning-trail.jpg", "center 62%",
"Continental District \u00b7 Scouting America",
"Find a unit near you.",
"Packs, troops, a ship and an Exploring club within a short drive of "
"Zieglerville, so you can pick the one that fits your family.",
minh=440)
return HTMLResponse(page("Find a Unit Near You \u00b7 Pack & Troop 73",
nearby.body_html(h), "nearby"),
headers={"X-Robots-Tag": "noindex, nofollow"})
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."),
("What if the time or place doesn't work for us?", 'Have a look at the other packs and troops nearby. Between them they meet on most weeknights. What matters is that your kid gets to have fun and pick up skills worth keeping, whether that\'s here or down the road.'),
]
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.
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.
{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