Jul 28 - Update the demo request button, and add demo registration page
This commit is contained in:
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user