Jul 28 - Update the demo request button, and add demo registration page

This commit is contained in:
2026-07-28 14:18:22 -04:00
parent 6b904aead9
commit ea9458395d
17 changed files with 905 additions and 13 deletions
+211 -2
View File
@@ -1,17 +1,22 @@
import logging
import os
from datetime import datetime
import re
import threading
import time
from collections import defaultdict
from datetime import date, datetime, timedelta
from logging.handlers import RotatingFileHandler
import bleach
from bleach.css_sanitizer import CSSSanitizer
from flask import Flask, render_template
from flask import Flask, flash, redirect, render_template, request, url_for
from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect
from markupsafe import Markup
from werkzeug.middleware.proxy_fix import ProxyFix
from config import Config
from mailer import send_email_async
db = SQLAlchemy()
csrf = CSRFProtect()
@@ -65,6 +70,31 @@ class Topic(db.Model):
return "youtube.com" in u or "youtu.be" in u
class DemoRequest(db.Model):
"""A demo appointment requested from the public page. `preferred_date` /
`preferred_time` are what the customer picked (no timezone conversion —
they're read back as the site's advertised local business hours)."""
__tablename__ = "demo_request"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
company = db.Column(db.String(160))
email = db.Column(db.String(200), nullable=False)
phone = db.Column(db.String(40))
preferred_date = db.Column(db.Date, nullable=False)
preferred_time = db.Column(db.String(5), nullable=False) # 'HH:MM'
message = db.Column(db.Text)
status = db.Column(db.String(16), nullable=False, default="new")
source_ip = db.Column(db.String(45))
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
@property
def when(self):
return f"{self.preferred_date:%a %b %d, %Y} at {self.preferred_time}"
DEMO_STATUSES = ("new", "scheduled", "done", "cancelled")
class AuditLog(db.Model):
__tablename__ = "audit_log"
id = db.Column(db.Integer, primary_key=True)
@@ -145,6 +175,85 @@ def sanitize_html(raw):
return cleaned
# ---------------------------------------------------------------- demo form
# Loose sanity check only — the real proof an address works is the confirmation
# mail the customer receives. Rejecting exotic-but-valid addresses is worse than
# accepting a typo the owner can see and follow up on.
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s.]+(\.[^@\s.]+)+$")
# In-memory per-IP throttle. Per gunicorn worker (not shared), which is fine:
# it exists to stop a bored visitor spamming the form, not a distributed flood.
# nginx limit_req is the right tool if that ever matters.
_demo_hits = defaultdict(list)
_demo_hits_lock = threading.Lock()
def demo_slots(config):
"""Bookable clock times as 'HH:MM', e.g. 08:00, 08:30, ... 16:30."""
start = max(0, min(23, config.get("DEMO_HOUR_START", 8)))
end = max(start + 1, min(24, config.get("DEMO_HOUR_END", 17)))
step = max(5, config.get("DEMO_SLOT_MINUTES", 30))
slots, minute = [], start * 60
while minute < end * 60:
slots.append(f"{minute // 60:02d}:{minute % 60:02d}")
minute += step
return slots
def _rate_limited(ip, limit):
"""True if `ip` has already submitted `limit` requests in the last hour."""
if limit <= 0:
return False
now = time.time()
with _demo_hits_lock:
hits = [t for t in _demo_hits[ip] if now - t < 3600]
if len(hits) >= limit:
_demo_hits[ip] = hits
return True
hits.append(now)
_demo_hits[ip] = hits
return False
def _notify_demo(app, req):
"""Mail the owner about a new appointment, and (optionally) confirm to the
customer. Both are fire-and-forget: the row is already committed."""
cfg = app.config
admin_link = request.url_root.rstrip("/") + url_for("admin.demos")
owner_body = (
"A new demo appointment was requested from the JQC features site.\n\n"
f"When: {req.when} ({cfg['DEMO_TIMEZONE_LABEL']})\n"
f"Name: {req.name}\n"
f"Company: {req.company or ''}\n"
f"Email: {req.email}\n"
f"Phone: {req.phone or ''}\n\n"
f"Message:\n{req.message or ''}\n\n"
f"Manage requests: {admin_link}\n"
)
send_email_async(
cfg, cfg.get("DEMO_NOTIFY_EMAIL"),
f"Demo request — {req.name} · {req.when}",
owner_body,
reply_to=req.email, reply_name=req.name,
)
if cfg.get("DEMO_CONFIRM_CUSTOMER"):
send_email_async(
cfg, req.email,
"We received your demo request",
(
f"Hi {req.name},\n\n"
"Thanks for your interest in JQC — LT Services' Janitorial "
"Quality Control program.\n\n"
f"You asked for a demonstration on {req.when} "
f"({cfg['DEMO_TIMEZONE_LABEL']}). We'll confirm that time by "
"email or phone shortly; if it turns out not to work on our "
"side we'll suggest the nearest alternative.\n\n"
"— LT Services Inc.\n"
),
)
def _configure_auth_logger(app):
"""A dedicated 'jqc.auth' logger writing one line per login attempt to a
file fail2ban watches. Kept separate from the app log so the filter regex
@@ -200,6 +309,106 @@ def create_app():
demo_url=app.config["DEMO_CONTACT_URL"],
)
@app.route("/demo", methods=["GET", "POST"])
def demo():
"""Public demo-appointment form. On success the request is stored and
the owner is notified by mail (best-effort, on a background thread)."""
cfg = app.config
slots = demo_slots(cfg)
today = date.today()
max_date = today + timedelta(days=cfg["DEMO_MAX_DAYS_AHEAD"])
form = {}
if request.method == "POST":
f = request.form
form = {k: f.get(k, "").strip() for k in (
"name", "company", "email", "phone", "preferred_date",
"preferred_time", "message",
)}
errors = []
# Honeypot: a hidden field only a bot fills in. Pretend success so
# the bot doesn't learn to work around it.
if f.get("website", "").strip():
return redirect(url_for("demo_thanks"))
client_ip = request.remote_addr or "-"
if _rate_limited(client_ip, cfg["DEMO_RATE_LIMIT"]):
errors.append(
"Too many requests from this connection. Please try again "
"later or email us directly."
)
if not form["name"]:
errors.append("Please tell us your name.")
if not EMAIL_RE.match(form["email"] or ""):
errors.append("Please enter a valid email address.")
try:
wanted = datetime.strptime(form["preferred_date"], "%Y-%m-%d").date()
except ValueError:
wanted = None
errors.append("Please pick a date for the demonstration.")
if wanted:
if wanted < today:
errors.append("Please pick a date that hasn't passed yet.")
elif wanted > max_date:
errors.append(
f"Please pick a date within the next "
f"{cfg['DEMO_MAX_DAYS_AHEAD']} days."
)
elif wanted.weekday() >= 5:
errors.append("Demonstrations run Monday through Friday.")
if form["preferred_time"] not in slots:
errors.append("Please pick an available time.")
# One appointment per slot. Cancelled ones free the slot again.
if not errors and DemoRequest.query.filter(
DemoRequest.preferred_date == wanted,
DemoRequest.preferred_time == form["preferred_time"],
DemoRequest.status != "cancelled",
).first():
errors.append("That time was just taken — please pick another.")
if errors:
for msg in errors:
flash(msg, "error")
else:
req = DemoRequest(
name=form["name"][:120],
company=form["company"][:160] or None,
email=form["email"][:200],
phone=form["phone"][:40] or None,
preferred_date=wanted,
preferred_time=form["preferred_time"],
message=form["message"][:2000] or None,
status="new",
source_ip=client_ip[:45],
)
db.session.add(req)
db.session.commit()
log_action(
"public", "create", "demo", req.id,
f"{req.name} · {req.when}",
)
_notify_demo(app, req)
return redirect(url_for("demo_thanks"))
return render_template(
"demo.html",
slots=slots,
form=form,
min_date=today.isoformat(),
max_date=max_date.isoformat(),
tz_label=cfg["DEMO_TIMEZONE_LABEL"],
demo_url=cfg["DEMO_CONTACT_URL"],
)
@app.route("/demo/thanks")
def demo_thanks():
return render_template("demo_thanks.html", demo_url=app.config["DEMO_CONTACT_URL"])
@app.route("/healthz")
def healthz():
return {"status": "ok"}