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 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 WORKDIR /app
COPY . /app COPY . /app
RUN mkdir -p /app/static/img 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_LIVE = DATA / "events.json"
EVENTS_SEED = Path(__file__).parent / "events.json" EVENTS_SEED = Path(__file__).parent / "events.json"
NTFY_URL = os.environ.get("NTFY_URL", "") NTFY_URL = os.environ.get("NTFY_URL", "")
# Same lead pipeline as the flyer form (scouting73.thewichersfamily.com): # Lead pipeline lives IN THIS REPO (no dependency on the scouting73-form
# forward /join posts to that container's /submit (Google Sheet + ntfy push). # container): direct Google Sheet append + ntfy push, same sheet and topic.
FORM_UPSTREAM = os.environ.get("FORM_UPSTREAM", "http://scouting73-form:8000/submit") 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 = FastAPI(title="Pack & Troop 73")
app.mount("/static", StaticFiles(directory="/app/static"), name="static") 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{text-decoration:none;font-weight:600;font-size:15px;color:#333D52;padding:6px 0}
nav.links a:hover{color:#1E2F52} nav.links a:hover{color:#1E2F52}
nav.links a.on{font-weight:700;color:#1E2F52;border-bottom:2px solid #F0AD2D} 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} 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}
a.joinbtn:hover{background:#DB9714} nav.links a.joinbtn:hover{background:#DB9714}
.hero{position:relative;display:flex;align-items:flex-end;min-height:520px} .hero{position:relative;display:flex;align-items:flex-end;min-height:520px}
.hero .wrap{padding-top:110px;padding-bottom:58px;width:100%} .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{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.', {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.', '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 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">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">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> <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(): def contact_redirect():
return RedirectResponse(url="/join", status_code=301) 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") @app.post("/join")
def join_post(parent_name: str = Form(...), email: str = Form(...), phone: str = Form(""), def join_post(parent_name: str = Form(...), email: str = Form(...), phone: str = Form(""),
children: str = Form(""), interested_in: str = Form(""), source: 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() source = "Other: " + source_other.strip()
rec = {"ts": datetime.datetime.now().isoformat(timespec="seconds"), rec = {"ts": datetime.datetime.now().isoformat(timespec="seconds"),
"parent_name": parent_name.strip(), "email": email.strip(), "phone": phone.strip(), "parent_name": parent_name.strip(), "email": email.strip(), "phone": phone.strip(),
"children": children.strip(), "interested_in": interested_in.strip(), "children": children.strip() or "(not given)",
"source": source.strip(), "message": message.strip()} "interested_in": interested_in.strip() or "(not given)",
"source": source.strip() or "greenlanescouts73.org", "message": message.strip()}
try: try:
with open(LEADS, "a", encoding="utf-8") as f: with open(LEADS, "a", encoding="utf-8") as f:
f.write(json.dumps(rec) + "\n") f.write(json.dumps(rec) + "\n")
except Exception: except Exception:
pass pass
# Forward into the shared lead pipeline (Google Sheet + ntfy on the 'scouting' sheet_error = None
# 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
try: try:
data = urllib.parse.urlencode({ sheet_append(rec)
"parent_name": rec["parent_name"], "email": rec["email"], "phone": rec["phone"], except Exception as e:
"children": rec["children"] or "(not given)", sheet_error = str(e)
"interested_in": rec["interested_in"] or "(not given)", "source": src, print("SHEET WRITE FAILED for %s <%s>: %s" % (rec["parent_name"], rec["email"], e), flush=True)
}).encode() delivered = pipeline_notify(rec, sheet_error)
rq = urllib.request.Request(FORM_UPSTREAM, data=data) if sheet_error and not delivered and NTFY_URL:
with urllib.request.urlopen(rq, timeout=6) as resp: # last-ditch push on the fallback topic so a lead is never silently lost
forwarded = resp.status < 400
except Exception:
forwarded = False
if not forwarded and NTFY_URL:
# fallback push so a lead is never silently lost
try: try:
b = ("New Scout interest: %s (%s) - %s | Kids: %s | %s" % ( b = ("LEAD (sheet+push failed): %s (%s) | Kids: %s | %s | %s" % (
rec["parent_name"], rec["email"], rec["interested_in"] or "program TBD", rec["parent_name"], rec["email"], rec["children"], rec["interested_in"], rec["source"])).encode()
rec["children"] or "?", src)).encode() rq = urllib.request.Request(NTFY_URL, data=b, headers={"Title": "greenlanescouts73.org lead", "Tags": "tent,warning"})
rq = urllib.request.Request(NTFY_URL, data=b, headers={"Title": "greenlanescouts73.org lead (sheet forward FAILED)", "Tags": "tent,warning"})
urllib.request.urlopen(rq, timeout=3) urllib.request.urlopen(rq, timeout=3)
except Exception: except Exception:
pass pass
+5
View File
@@ -20,8 +20,13 @@ services:
- "8132:8000" - "8132:8000"
environment: environment:
- NTFY_URL=${NTFY_URL} - NTFY_URL=${NTFY_URL}
- SHEET_ID=${SHEET_ID}
- NTFY_BASE=${NTFY_BASE}
- NTFY_TOPIC=${NTFY_TOPIC}
- NTFY_TOKEN=${NTFY_TOKEN}
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-assets/img:/app/static/img:ro - /srv/scout-website-assets/img:/app/static/img:ro
networks: networks:
- default - default