self-contained lead pipeline (direct sheet+ntfy, +Comments col); fix joinbtn specificity + facts flex shrink-wrap

This commit is contained in:
claude
2026-08-24 13:53:57 -04:00
parent 27fcdd3d2b
commit 5e3eeb9243
4 changed files with 83 additions and 32 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
FROM python:3.12-slim
RUN pip install --no-cache-dir fastapi "uvicorn[standard]" python-multipart
RUN pip install --no-cache-dir fastapi "uvicorn[standard]" python-multipart gspread
WORKDIR /app
COPY . /app
RUN mkdir -p /app/static/img
Binary file not shown.
+77 -31
View File
@@ -16,9 +16,15 @@ LEADS = DATA / "leads.jsonl"
EVENTS_LIVE = DATA / "events.json"
EVENTS_SEED = Path(__file__).parent / "events.json"
NTFY_URL = os.environ.get("NTFY_URL", "")
# Same lead pipeline as the flyer form (scouting73.thewichersfamily.com):
# forward /join posts to that container's /submit (Google Sheet + ntfy push).
FORM_UPSTREAM = os.environ.get("FORM_UPSTREAM", "http://scouting73-form:8000/submit")
# 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")
@@ -93,8 +99,8 @@ 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}
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}
a.joinbtn:hover{background:#DB9714}
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}
@@ -257,7 +263,7 @@ def home():
{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.',
'<div style="display:flex;gap:14px;flex-wrap:wrap;margin-top:28px"><a class="cta" href="/join">Join us this fall</a><a class="cta ghost" href="/calendar">See the year</a></div>', 640)}
<div class="wrap"><div class="facts">
<div class="wrap" style="width:100%"><div class="facts">
<div><div class="fl">Meetings</div><div class="fv">Tue · Pack 6:00 · Troop 7:00</div></div>
<div><div class="fl">Where</div><div class="fv">St. Luke's Lutheran Church</div></div>
<div><div class="fl">Ages</div><div class="fv">Kindergarten to 17</div></div>
@@ -541,6 +547,57 @@ def join_get(sent: int = 0):
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")
sheet.append_row([
datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
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 Pack 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(""),
@@ -551,38 +608,27 @@ def join_post(parent_name: str = Form(...), email: str = Form(...), phone: str =
source = "Other: " + source_other.strip()
rec = {"ts": datetime.datetime.now().isoformat(timespec="seconds"),
"parent_name": parent_name.strip(), "email": email.strip(), "phone": phone.strip(),
"children": children.strip(), "interested_in": interested_in.strip(),
"source": source.strip(), "message": message.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
# Forward into the shared lead pipeline (Google Sheet + ntfy on the 'scouting'
# topic), same as scouting73.thewichersfamily.com. The comment rides along in
# the source column so it reaches the sheet and the phone push.
src = rec["source"] or "greenlanescouts73.org"
if rec["message"]:
src = src + " | Note: " + rec["message"][:300]
forwarded = False
sheet_error = None
try:
data = urllib.parse.urlencode({
"parent_name": rec["parent_name"], "email": rec["email"], "phone": rec["phone"],
"children": rec["children"] or "(not given)",
"interested_in": rec["interested_in"] or "(not given)", "source": src,
}).encode()
rq = urllib.request.Request(FORM_UPSTREAM, data=data)
with urllib.request.urlopen(rq, timeout=6) as resp:
forwarded = resp.status < 400
except Exception:
forwarded = False
if not forwarded and NTFY_URL:
# fallback push so a lead is never silently lost
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)
delivered = pipeline_notify(rec, sheet_error)
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 = ("New Scout interest: %s (%s) - %s | Kids: %s | %s" % (
rec["parent_name"], rec["email"], rec["interested_in"] or "program TBD",
rec["children"] or "?", src)).encode()
rq = urllib.request.Request(NTFY_URL, data=b, headers={"Title": "greenlanescouts73.org lead (sheet forward FAILED)", "Tags": "tent,warning"})
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