diff --git a/app/Dockerfile b/app/Dockerfile index 8e1f8ee..ee89588 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -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 diff --git a/app/__pycache__/app.cpython-313.pyc b/app/__pycache__/app.cpython-313.pyc index f7ecee6..0ca86d8 100644 Binary files a/app/__pycache__/app.cpython-313.pyc and b/app/__pycache__/app.cpython-313.pyc differ diff --git a/app/app.py b/app/app.py index 24adf95..5e6f4fe 100644 --- a/app/app.py +++ b/app/app.py @@ -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.', '
Join us this fallSee the year
', 640)} -
+
Meetings
Tue · Pack 6:00 · Troop 7:00
Where
St. Luke's Lutheran Church
Ages
Kindergarten to 17
@@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index d65c5a7..4cb6cab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,8 +20,13 @@ services: - "8132:8000" environment: - NTFY_URL=${NTFY_URL} + - SHEET_ID=${SHEET_ID} + - NTFY_BASE=${NTFY_BASE} + - NTFY_TOPIC=${NTFY_TOPIC} + - NTFY_TOKEN=${NTFY_TOKEN} volumes: - /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 networks: - default