import json, os, shutil, datetime, urllib.request, urllib.parse
from pathlib import Path
from fastapi import FastAPI, Form
from fastapi.responses import 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
# ---------------------------------------------------------------------------
# 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.
# ---------------------------------------------------------------------------
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"
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 + fall grade)", "Interested In", "How They Heard", "Comments"]
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)
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']
def load_events():
for p in (EVENTS_LIVE, EVENTS_SEED):
try:
with open(p, encoding="utf-8") as f:
return json.load(f).get("events", [])
except Exception:
continue
return []
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'''
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 Tuesdays at 6:00 PM 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. 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.
TuesdaysDen and pack meetings, 6:00 PM at St. Luke's
MonthlyWeekend pack hike, usually 1:00 PM
SaturdaysChocolate booth at the Green Lane Farmers Market, 7:00 AM
Come try a Tuesday.
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 Tuesdays at 7:30 PM 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 2026-2027 year, at a glance.
The highlights below are open to visiting families. Come to any of them, or just show up on a Tuesday.
Pack Tuesdays · 6:00 PM
Pack or den meetings at St. Luke's Lutheran Church (off on school holidays)
Troop Tuesdays · 7:30 PM
Troop 73 meets right after the pack, same building
About monthly · weekends
Pack hikes at rotating trailheads, usually 1:00 PM
Saturdays · 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("2026-2027 Calendar · Pack & Troop 73", body, "cal")
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?", "Any Tuesday 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 join_body(sent=False):
success = '
Thanks! Your note is on its way. A leader will be in touch soon.
' if sent 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 Tuesday at St. Luke's: Pack 6:00, Troop 7:30. Sneakers recommended.
{success}
{SOURCE_JS}
New-family FAQ
The questions every new family asks.
{faq_html}
See you Tuesday.
St. Luke's Lutheran Church, Zieglerville · Pack 6:00, Troop 7:30. Look for the kids having the most fun in the building.
"""
@app.get("/join", response_class=HTMLResponse)
def join_get(sent: int = 0):
return page("Join us · Pack & Troop 73", join_body(bool(sent)))
@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)
elif len(vals[0]) == 7:
sheet.update_cell(1, 8, "Comments")
# 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
@app.post("/join")
def join_post(parent_name: str = Form(...), email: str = Form(...), phone: str = Form(""),
children: str = Form(""), interested_in: str = Form(""), source: str = Form(""),
source_place: str = Form(""), source_other: str = Form(""), message: str = Form("")):
heard_from = source.strip()
heard_from_detail = ""
if source == "Other school or daycare" and source_place.strip():
heard_from_detail = source_place.strip()
source = "School/Daycare: " + heard_from_detail
elif source == "Other" and source_other.strip():
heard_from_detail = source_other.strip()
source = "Other: " + heard_from_detail
rec = {"ts": datetime.datetime.now().isoformat(timespec="seconds"),
"parent_name": parent_name.strip(), "email": email.strip(), "phone": phone.strip(),
"children": children.strip() or "(not given)",
"interested_in": interested_in.strip() or "(not given)",
"source": source.strip() or "greenlanescouts73.org", "message": message.strip()}
try:
with open(LEADS, "a", encoding="utf-8") as f:
f.write(json.dumps(rec) + "\n")
except Exception:
pass
# Land it internally FIRST. Everything below this point is a mirror of a
# row that already exists locally, and each mirror's outcome is recorded
# against the record so a failure is replayable instead of just shouted.
record_id = None
try:
record_id = store.insert_lead(
parent_name=rec["parent_name"], email=rec["email"], phone=rec["phone"],
interested_in=rec["interested_in"], children=rec["children"],
heard_from=heard_from, heard_from_detail=heard_from_detail,
message=rec["message"], payload=rec, submitted_at=rec["ts"])
except Exception as e:
print("STORE WRITE FAILED for %s <%s>: %s" % (rec["parent_name"], rec["email"], e), flush=True)
# Register every mirror as pending BEFORE attempting it. If the process dies
# or a call never runs, the gap is visible and replayable instead of the row
# simply having no mirror record at all.
if record_id:
for _t in store.TARGETS:
store.set_mirror_pending(record_id, _t)
sheet_error = None
try:
sheet_append(rec)
except Exception as e:
sheet_error = str(e)
print("SHEET WRITE FAILED for %s <%s>: %s" % (rec["parent_name"], rec["email"], e), flush=True)
if record_id:
store.set_mirror(record_id, "google_sheet", sheet_error is None, sheet_error)
delivered = pipeline_notify(rec, sheet_error)
if record_id:
store.set_mirror(record_id, "ntfy", bool(delivered),
None if delivered else "ntfy push not delivered")
if sheet_error and not delivered and NTFY_URL:
# last-ditch push on the fallback topic so a lead is never silently lost
try:
b = ("LEAD (sheet+push failed): %s (%s) | Kids: %s | %s | %s" % (
rec["parent_name"], rec["email"], rec["children"], rec["interested_in"], rec["source"])).encode()
rq = urllib.request.Request(NTFY_URL, data=b, headers={"Title": "greenlanescouts73.org lead", "Tags": "tent,warning"})
urllib.request.urlopen(rq, timeout=3)
except Exception:
pass
return RedirectResponse(url="/join?sent=1", status_code=303)