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
+29 -1
View File
@@ -7,9 +7,37 @@ DB_NAME=jqc_features
# Or set a full URI and it overrides the parts above:
# DATABASE_URL=mysql+pymysql://user:pass@127.0.0.1:3306/jqc_features?charset=utf8mb4
# Target for the "Request a demo" button
# Fallback "email us directly" link shown under the booking form
DEMO_CONTACT_URL=mailto:info@ltservicesinc.com
# --- Demo appointments (/demo) ---
# Owner notification address. Blank -> no mail, requests still land in /admin/demos.
DEMO_NOTIFY_EMAIL=info@ltservicesinc.com
# Email the customer a confirmation copy too (1/0)
DEMO_CONFIRM_CUSTOMER=1
# Bookable window and slot size
DEMO_HOUR_START=8
DEMO_HOUR_END=17
DEMO_SLOT_MINUTES=30
DEMO_MAX_DAYS_AHEAD=90
DEMO_TIMEZONE_LABEL=Eastern Time
# Max form submissions per IP per hour (per gunicorn worker)
DEMO_RATE_LIMIT=5
# --- Outgoing mail (SMTP). Leave SMTP_HOST blank to disable mail entirely. ---
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
# STARTTLS on port 587:
SMTP_USE_TLS=1
# Implicit TLS on port 465 (set SMTP_USE_TLS=0 if you use this):
SMTP_USE_SSL=0
SMTP_TIMEOUT=20
# From address; defaults to SMTP_USER when blank
MAIL_FROM=
MAIL_SUBJECT_PREFIX=[JQC]
# --- Admin panel ---
# Sign session cookies + CSRF tokens. Generate: python3 -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=change_me_to_a_long_random_string
+43 -3
View File
@@ -25,10 +25,14 @@ Ubuntu 24.04. Fonts via Google Fonts (Bricolage Grotesque, IBM Plex Sans/Mono).
## Structure
- `app.py` — factory, models (`Section`, `Topic`, `AuditLog`), `log_action()`,
ProxyFix wrap, `_configure_auth_logger()`, public routes `/`, `/healthz`.
- `app.py` — factory, models (`Section`, `Topic`, `DemoRequest`, `AuditLog`),
`log_action()`, ProxyFix wrap, `_configure_auth_logger()`, demo helpers
(`demo_slots`, `_rate_limited`, `_notify_demo`), public routes `/`, `/demo`,
`/demo/thanks`, `/healthz`.
- `mailer.py` — stdlib SMTP sender; `send_email()` / `send_email_async()`.
- `admin.py` — blueprint `/admin`: session login, section/topic CRUD, `/admin/audit`
read-only audit viewer (filter by action/entity, 50/page).
read-only audit viewer (filter by action/entity, 50/page), `/admin/demos`
appointment inbox (+ status / delete).
- `config.py` — env-driven config + `_load_dotenv()` (no-expansion loader).
- `templates/` — public `index.html`; `admin/` base+login+dashboard+forms.
- `static/css/style.css` (public), `static/css/admin.css`, `static/js/main.js`.
@@ -42,6 +46,8 @@ Ubuntu 24.04. Fonts via Google Fonts (Bricolage Grotesque, IBM Plex Sans/Mono).
link_label, media_type[none|image|video|embed], media_url, media_caption,
sort_order, is_published)`
- `audit_log(id, actor, action, entity, entity_id, detail, created_at)`
- `demo_request(id, name, company, email, phone, preferred_date, preferred_time,
message, status[new|scheduled|done|cancelled], source_ip, created_at)`
Public page orders sections by `sort_order, num`; topics by `sort_order`, and
shows only `is_published` topics (sections with no published topics are hidden).
@@ -165,11 +171,45 @@ shows only `is_published` topics (sections with no published topics are hidden).
Google Fonts — that's why libs are vendored. Fonts still load fine on the real
server; they degrade to system fonts if blocked.
## Demo appointments (public booking + owner notification)
- CTA button now goes to **`/demo`** (a real form), not the `mailto:`.
`DEMO_CONTACT_URL` survives as the "email us directly" fallback link.
- `GET/POST /demo` → `templates/demo.html`; success redirects to
`/demo/thanks` (POST-redirect-GET, so a refresh can't double-book).
- Slots come from `demo_slots()` — `DEMO_HOUR_START/END` + `DEMO_SLOT_MINUTES`
(default 08:0016:30 every 30 min). Server-side validation is the real gate:
the posted time must be in that list, the date must parse, be today-or-later,
within `DEMO_MAX_DAYS_AHEAD`, and be a weekday. Dates/times are stored exactly
as picked; `DEMO_TIMEZONE_LABEL` is display-only (no tz conversion anywhere).
- **One appointment per slot:** a request for a (date, time) that already has a
non-`cancelled` row is rejected with a flash. Cancelling frees the slot.
- **Abuse control:** hidden `website` honeypot (filled → fake success, nothing
stored) + per-IP hourly cap `DEMO_RATE_LIMIT` held in `_demo_hits` (in-memory,
therefore per gunicorn worker — it's a nuisance filter, not a DDoS defence;
use nginx `limit_req` if that ever matters). CSRF via the global `CSRFProtect`.
- **Notification:** `_notify_demo()` mails `DEMO_NOTIFY_EMAIL` with the details
and a link to `/admin/demos`, `Reply-To` set to the customer; with
`DEMO_CONFIRM_CUSTOMER=1` the customer also gets a copy. Both go through
`send_email_async()` on a daemon thread **after** the row is committed —
mail is best-effort and never raises, so a dead SMTP host loses the email,
never the appointment. Blank `SMTP_HOST`/`MAIL_FROM` = mail silently off
(logged via `jqc.mail`); requests still land in the admin inbox.
- Header values are CR/LF-stripped (`_clean_header`) so a submitted name can't
inject SMTP headers.
- **Admin inbox** `/admin/demos`: default view shows `new` + `scheduled`,
soonest first; chips filter by status; per-row status select and delete.
Nav badge counts `new`. Status POSTs redirect via a `back_status` form field —
never `request.referrer` (open-redirect). Every create/status/delete writes an
audit row with entity `demo` (the audit filter accepts it).
## Deploy delta cheatsheet
```bash
sudo mysql < add_admin.sql # audit_log (existing DBs)
sudo mysql < add_publish.sql # topic.is_published (existing DBs)
sudo mysql < add_demo.sql # demo_request table (existing DBs)
# .env: DEMO_NOTIFY_EMAIL + SMTP_HOST/PORT/USER/PASSWORD/MAIL_FROM for notifications
# .env: SECRET_KEY, ADMIN_USERNAME, ADMIN_PASSWORD_HASH, SESSION_COOKIE_SECURE=1
sudo ./venv/bin/pip install -r requirements.txt # bleach[css] + tinycss2 (CSS sanitize)
sudo -u jqc mkdir -p static/uploads # in-body image uploads (writable)
+46 -2
View File
@@ -7,10 +7,14 @@ photos, or videos.
Stack: **Flask + MySQL + Gunicorn + systemd + Nginx** on Ubuntu 24.04.
```
app.py Flask app + Section/Topic models + routes
config.py env-driven config (DB + demo contact URL)
app.py Flask app + Section/Topic/DemoRequest models + routes
admin.py /admin panel (content CRUD, audit log, demo inbox)
mailer.py stdlib SMTP notifications (best-effort, threaded)
config.py env-driven config (DB, demo booking, SMTP)
schema.sql MySQL DDL + seed content (safe to re-run)
add_demo.sql additive migration: demo_request table
templates/index.html
templates/demo.html booking form (+ demo_thanks.html)
static/css/style.css
static/js/main.js accordion expand/collapse
gunicorn.conf.py
@@ -257,3 +261,43 @@ This jail bans credential-guessing that reaches the password check (a real
browser session with a valid CSRF token). Dumb bots that POST without a CSRF
token get an HTTP 400 and never reach the check — they can't guess a password
anyway. To also throttle those, add an nginx `limit_req` on `/admin/login`.
---
## Demo appointments
The **Request a demo** button at the bottom of the public page opens `/demo`, a
booking form: name, company, email, phone, preferred date + time slot, and a
message. Valid submissions are stored in `demo_request` and the owner is
emailed; the customer gets a confirmation copy.
```bash
sudo mysql < add_demo.sql # existing databases only; schema.sql has it too
```
Then set in `.env` and restart (`sudo systemctl restart jqc-features`):
```
DEMO_NOTIFY_EMAIL=info@ltservicesinc.com
SMTP_HOST=smtp.yourprovider.com
SMTP_PORT=587
SMTP_USER=notifications@ltservicesinc.com
SMTP_PASSWORD=...
MAIL_FROM=notifications@ltservicesinc.com
DEMO_TIMEZONE_LABEL=Eastern Time
```
Bookable hours default to 08:0016:30 in 30-minute slots, weekdays, up to 90
days ahead (`DEMO_HOUR_START`, `DEMO_HOUR_END`, `DEMO_SLOT_MINUTES`,
`DEMO_MAX_DAYS_AHEAD`). A slot already taken by a non-cancelled request can't be
booked twice.
**Mail is best-effort:** it goes out on a background thread *after* the request
is saved, and a failure is logged rather than shown to the visitor. With
`SMTP_HOST` blank no mail is sent at all — requests still appear in the admin
panel, so nothing is ever lost.
Manage them at **`/admin/demos`**: open requests first, filter by status
(`new` / `scheduled` / `done` / `cancelled`), set a status or delete. The nav
badge counts unhandled ones. Every action is written to the audit log under the
`demo` type.
+20
View File
@@ -0,0 +1,20 @@
-- Additive migration: demo appointment requests from the public page.
-- Idempotent — safe to re-run.
USE jqc_features;
CREATE TABLE IF NOT EXISTS demo_request (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120) NOT NULL,
company VARCHAR(160) NULL,
email VARCHAR(200) NOT NULL,
phone VARCHAR(40) NULL,
preferred_date DATE NOT NULL,
preferred_time VARCHAR(5) NOT NULL, -- 'HH:MM', local business hours
message TEXT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'new', -- new/scheduled/done/cancelled
source_ip VARCHAR(45) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_demo_slot (preferred_date, preferred_time),
KEY idx_demo_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+81 -2
View File
@@ -11,7 +11,10 @@ from flask import (
)
from werkzeug.security import check_password_hash
from app import db, log_action, sanitize_html, AuditLog, Section, Topic
from app import (
db, log_action, sanitize_html, AuditLog, DemoRequest, DEMO_STATUSES,
Section, Topic,
)
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
@@ -40,6 +43,19 @@ def _sniff_image(head):
return None
@admin_bp.context_processor
def inject_pending_demos():
"""Badge count on the nav. Only queried for a signed-in admin, so the login
page never touches the database."""
if not session.get("admin"):
return {"pending_demos": 0}
try:
return {"pending_demos": DemoRequest.query.filter(
DemoRequest.status == "new").count()}
except Exception: # noqa: BLE001 - a missing table must not 500 the panel
return {"pending_demos": 0}
# ------------------------------------------------------------------ auth
def login_required(view):
@wraps(view)
@@ -130,6 +146,69 @@ def dashboard():
return render_template("admin/dashboard.html", sections=sections)
# ------------------------------------------------------------------ demo requests
@admin_bp.route("/demos")
@login_required
def demos():
"""Appointment requests from the public /demo form, soonest first."""
page = _int(request.args.get("page"), 1)
if page < 1:
page = 1
status = request.args.get("status", "")
q = DemoRequest.query
if status in DEMO_STATUSES:
q = q.filter(DemoRequest.status == status)
elif status == "":
# Default view hides what's already dealt with.
q = q.filter(DemoRequest.status.in_(("new", "scheduled")))
pagination = (
q.order_by(DemoRequest.preferred_date.asc(),
DemoRequest.preferred_time.asc(), DemoRequest.id.asc())
.paginate(page=page, per_page=50, error_out=False)
)
new_count = DemoRequest.query.filter(DemoRequest.status == "new").count()
return render_template(
"admin/demos.html",
pagination=pagination,
requests=pagination.items,
status=status,
statuses=DEMO_STATUSES,
new_count=new_count,
)
@admin_bp.route("/demo/<int:req_id>/status", methods=["POST"])
@login_required
def demo_status(req_id):
req = DemoRequest.query.get_or_404(req_id)
new_status = request.form.get("status", "")
if new_status not in DEMO_STATUSES:
abort(400)
req.status = new_status
db.session.commit()
log_action(session.get("admin"), "update", "demo", req.id,
f"{new_status}: {req.name} · {req.when}")
flash(f"Request from {req.name} marked {new_status}.", "ok")
# Come back to the same filtered view (never trust Referer for a redirect).
back = request.form.get("back_status", "")
return redirect(url_for("admin.demos",
status=back if back in DEMO_STATUSES else ""))
@admin_bp.route("/demo/<int:req_id>/delete", methods=["POST"])
@login_required
def demo_delete(req_id):
req = DemoRequest.query.get_or_404(req_id)
name, rid, when = req.name, req.id, req.when
db.session.delete(req)
db.session.commit()
log_action(session.get("admin"), "delete", "demo", rid, f"{name} · {when}")
flash(f"Request from {name} deleted.", "ok")
return redirect(url_for("admin.demos"))
# ------------------------------------------------------------------ audit log
@admin_bp.route("/audit")
@login_required
@@ -143,7 +222,7 @@ def audit():
q = AuditLog.query
if action in ("create", "update", "delete"):
q = q.filter(AuditLog.action == action)
if entity in ("section", "topic"):
if entity in ("section", "topic", "demo"):
q = q.filter(AuditLog.entity == entity)
pagination = (
+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"}
+31 -1
View File
@@ -16,9 +16,39 @@ class Config:
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = {"pool_pre_ping": True, "pool_recycle": 280}
# Public contact button target shown in the closing CTA.
# Public contact button target shown in the closing CTA. Still used as the
# "or just email us" fallback link under the booking form.
DEMO_CONTACT_URL = os.environ.get("DEMO_CONTACT_URL", "mailto:info@ltservicesinc.com")
# --- Demo appointment requests ---
# Where the owner notification is sent. Blank -> no mail is sent, the
# request is still stored and visible in /admin/demos.
DEMO_NOTIFY_EMAIL = os.environ.get("DEMO_NOTIFY_EMAIL", "")
# Send the customer a "we got it" copy as well.
DEMO_CONFIRM_CUSTOMER = os.environ.get("DEMO_CONFIRM_CUSTOMER", "1") == "1"
# Bookable window, local business hours. Slots are generated every
# DEMO_SLOT_MINUTES from DEMO_HOUR_START up to (not including) DEMO_HOUR_END.
DEMO_HOUR_START = int(os.environ.get("DEMO_HOUR_START", "8"))
DEMO_HOUR_END = int(os.environ.get("DEMO_HOUR_END", "17"))
DEMO_SLOT_MINUTES = int(os.environ.get("DEMO_SLOT_MINUTES", "30"))
# How far ahead a customer may book, in days.
DEMO_MAX_DAYS_AHEAD = int(os.environ.get("DEMO_MAX_DAYS_AHEAD", "90"))
# Label only — dates/times are stored exactly as the customer picked them.
DEMO_TIMEZONE_LABEL = os.environ.get("DEMO_TIMEZONE_LABEL", "Local time")
# Max requests accepted from one IP per hour (per gunicorn worker).
DEMO_RATE_LIMIT = int(os.environ.get("DEMO_RATE_LIMIT", "5"))
# --- Outgoing mail (SMTP) ---
SMTP_HOST = os.environ.get("SMTP_HOST", "")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
SMTP_USER = os.environ.get("SMTP_USER", "")
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "1") == "1" # STARTTLS on 587
SMTP_USE_SSL = os.environ.get("SMTP_USE_SSL", "0") == "1" # implicit TLS on 465
SMTP_TIMEOUT = int(os.environ.get("SMTP_TIMEOUT", "20"))
MAIL_FROM = os.environ.get("MAIL_FROM", "") or SMTP_USER
MAIL_SUBJECT_PREFIX = os.environ.get("MAIL_SUBJECT_PREFIX", "[JQC]")
# --- Admin / session ---
# SECRET_KEY signs session cookies and CSRF tokens. MUST be set in production.
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-insecure-change-me")
+84
View File
@@ -0,0 +1,84 @@
"""Minimal SMTP sender for owner/customer notifications.
Deliberately stdlib-only (smtplib + email) no new dependency, no queue, no
broker. Mail is sent on a daemon thread so a slow or dead SMTP host never makes
the visitor wait, and a failure is logged rather than raised: the demo request
is already committed to the database by the time we get here, so the owner can
always see it in /admin/demos even if mail is misconfigured.
"""
import logging
import smtplib
import threading
from email.message import EmailMessage
from email.utils import formataddr, parseaddr
mail_log = logging.getLogger("jqc.mail")
def mail_enabled(config):
"""Mail can only go out with a host and a From address configured."""
return bool(config.get("SMTP_HOST") and config.get("MAIL_FROM"))
def _clean_header(value):
"""Strip CR/LF so a user-supplied name can't inject extra headers."""
return " ".join(str(value or "").split())[:200]
def send_email(config, to, subject, body, reply_to=None, reply_name=None):
"""Send one plain-text mail. Returns True on success, False otherwise.
Never raises callers treat mail as best-effort."""
if not mail_enabled(config) or not to:
mail_log.info("Mail skipped (SMTP not configured): %s", _clean_header(subject))
return False
prefix = config.get("MAIL_SUBJECT_PREFIX", "")
msg = EmailMessage()
msg["Subject"] = _clean_header(f"{prefix} {subject}".strip())
msg["From"] = config["MAIL_FROM"]
msg["To"] = to
if reply_to:
addr = parseaddr(_clean_header(reply_to))[1]
if addr:
msg["Reply-To"] = formataddr((_clean_header(reply_name), addr))
msg.set_content(body)
host = config["SMTP_HOST"]
port = config.get("SMTP_PORT", 587)
timeout = config.get("SMTP_TIMEOUT", 20)
try:
if config.get("SMTP_USE_SSL"):
server = smtplib.SMTP_SSL(host, port, timeout=timeout)
else:
server = smtplib.SMTP(host, port, timeout=timeout)
with server:
server.ehlo()
if config.get("SMTP_USE_TLS") and not config.get("SMTP_USE_SSL"):
server.starttls()
server.ehlo()
if config.get("SMTP_USER"):
server.login(config["SMTP_USER"], config.get("SMTP_PASSWORD", ""))
server.send_message(msg)
mail_log.info("Mail sent to %s: %s", to, msg["Subject"])
return True
except Exception as exc: # noqa: BLE001 - mail must never break the request
mail_log.error("Mail to %s failed: %s", to, exc)
return False
def send_email_async(config, to, subject, body, reply_to=None, reply_name=None):
"""Fire-and-forget wrapper. `config` is copied to a plain dict first so the
thread never touches the Flask app/request context."""
cfg = {k: config.get(k) for k in (
"SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASSWORD", "SMTP_USE_TLS",
"SMTP_USE_SSL", "SMTP_TIMEOUT", "MAIL_FROM", "MAIL_SUBJECT_PREFIX",
)}
thread = threading.Thread(
target=send_email,
args=(cfg, to, subject, body),
kwargs={"reply_to": reply_to, "reply_name": reply_name},
daemon=True,
)
thread.start()
return thread
+16
View File
@@ -46,6 +46,22 @@ CREATE TABLE IF NOT EXISTS audit_log (
KEY idx_audit_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS demo_request (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120) NOT NULL,
company VARCHAR(160) NULL,
email VARCHAR(200) NOT NULL,
phone VARCHAR(40) NULL,
preferred_date DATE NOT NULL,
preferred_time VARCHAR(5) NOT NULL, -- 'HH:MM', local business hours
message TEXT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'new', -- new/scheduled/done/cancelled
source_ip VARCHAR(45) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_demo_slot (preferred_date, preferred_time),
KEY idx_demo_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ------------------------------------------------------------------
-- Seed data (idempotent). Re-running updates content in place.
-- ------------------------------------------------------------------
+13
View File
@@ -215,6 +215,19 @@ form{display:inline}
.badge--update{background:#FBF0E1;color:#8A5A12;border-color:#F0D9B3}
.badge--delete{background:#FBEAE6;color:#9C3325;border-color:#F0C7BE}
/* demo requests: per-row status + delete controls */
.inline-form{display:inline-flex;align-items:center;gap:6px;margin:0 0 6px}
.mini-select{
font:inherit;font-size:.82rem;color:var(--ink);background:var(--card);
border:1px solid var(--hair);border-radius:8px;padding:5px 8px;
}
.mini-select:focus{outline:none;border-color:var(--aqua)}
.anav__count{
display:inline-block;margin-left:6px;padding:1px 7px;border-radius:100px;
background:var(--aqua);color:#04201E;font-family:"IBM Plex Mono",monospace;
font-size:.68rem;font-weight:600;vertical-align:middle;
}
.pager{display:flex;align-items:center;justify-content:center;gap:14px;margin-top:20px}
.pager__info{
font-family:"IBM Plex Mono",monospace;font-size:.8rem;color:var(--muted);
+79
View File
@@ -217,6 +217,82 @@ body{
font-family:"IBM Plex Mono",monospace;font-size:.72rem;letter-spacing:.06em;
padding:0 28px 40px;margin:0;
}
.cta__alt{
margin:18px 0 0;font-size:.86rem;color:rgba(247,245,240,.6);
}
.cta__alt a{color:var(--aqua);text-decoration:none;border-bottom:1px solid rgba(23,176,166,.4)}
.cta__alt a:hover{border-bottom-color:var(--aqua)}
.cta--slim{margin-top:0;padding-top:40px}
/* ---------- BOOKING (demo appointment) ---------- */
.book{
position:relative;z-index:1;max-width:680px;margin:0 auto;
padding:56px 28px 72px;
}
.book__back{
display:inline-block;font-family:"IBM Plex Mono",monospace;font-size:.78rem;
letter-spacing:.06em;color:var(--muted);text-decoration:none;margin-bottom:34px;
}
.book__back:hover{color:var(--aqua-deep)}
.book__kicker{
font-family:"IBM Plex Mono",monospace;font-size:.76rem;letter-spacing:.16em;
text-transform:uppercase;color:var(--aqua-deep);margin:0 0 14px;
}
.book__title{
font-family:"Bricolage Grotesque",sans-serif;font-weight:800;
font-size:clamp(2rem,6vw,3.1rem);line-height:1.02;letter-spacing:-.03em;margin:0;
}
.book__lede{color:var(--ink-soft);margin:18px 0 0;max-width:54ch}
.book__head{margin-bottom:38px}
.book__alerts{
border:1px solid #E4B7B7;background:#FBEDED;border-radius:var(--radius);
padding:14px 18px;margin-bottom:26px;
}
.book__alert{margin:0;color:#8C2F2F;font-size:.92rem}
.book__alert + .book__alert{margin-top:8px}
.book__form{display:flex;flex-direction:column;gap:22px}
.book__hp{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}
.field{display:flex;flex-direction:column;gap:8px;min-width:0}
.field-row{display:grid;grid-template-columns:1fr 1fr;gap:22px}
.field label{
font-family:"IBM Plex Mono",monospace;font-size:.76rem;letter-spacing:.08em;
text-transform:uppercase;color:var(--muted);
}
.field .req{color:var(--signal)}
.field input,.field select,.field textarea{
font:inherit;font-size:1rem;color:var(--ink);background:var(--white);
border:1px solid var(--hair);border-radius:10px;padding:12px 14px;width:100%;
transition:border-color .2s var(--ease),box-shadow .2s var(--ease);
}
.field textarea{resize:vertical;min-height:110px}
.field input:focus,.field select:focus,.field textarea:focus{
outline:none;border-color:var(--aqua);box-shadow:0 0 0 3px rgba(23,176,166,.16);
}
.hint{margin:0;font-size:.8rem;color:var(--muted)}
.book__submit{
align-self:flex-start;margin-top:6px;padding:16px 38px;border:none;cursor:pointer;
background:var(--aqua);color:#04201E;border-radius:100px;
font-family:"IBM Plex Mono",monospace;font-weight:600;font-size:.92rem;letter-spacing:.03em;
transition:transform .25s var(--ease),box-shadow .25s var(--ease);
}
.book__submit:hover{transform:translateY(-2px);box-shadow:0 12px 30px -10px rgba(23,176,166,.6)}
.book__submit:focus-visible{outline:2px solid var(--aqua-deep);outline-offset:3px}
.book__fallback{margin:0;font-size:.9rem;color:var(--muted)}
.book__fallback a{color:var(--aqua-deep)}
.book--done{text-align:center;padding-top:88px}
.book--done .book__lede{margin-left:auto;margin-right:auto}
.book--done .book__fallback{margin-top:22px}
.book--done .book__back{margin:34px 0 0}
.book__stamp{
width:64px;height:64px;margin:0 auto 26px;border-radius:50%;
background:var(--aqua);display:grid;place-items:center;
}
.book__stamp svg{width:30px;height:30px;fill:none;stroke:var(--white);stroke-width:2.4;
stroke-linecap:round;stroke-linejoin:round}
/* ---------- RESPONSIVE ---------- */
@media (max-width:760px){
@@ -226,6 +302,9 @@ body{
.block{grid-template-columns:1fr;gap:22px;padding:40px 0}
.block__rail{position:static}
.report{padding:0 22px}
.book{padding:40px 22px 56px}
.field-row{grid-template-columns:1fr}
.book__submit{align-self:stretch;text-align:center}
}
/* ---------- A11Y ---------- */
+1 -1
View File
@@ -17,7 +17,7 @@
href="{{ url_for('admin.audit', action=val, entity=entity) }}">{{ label }}</a>
{% endfor %}
<span class="filters__sep">·</span>
{% set entities = [('', 'All types'), ('section','section'), ('topic','topic')] %}
{% set entities = [('', 'All types'), ('section','section'), ('topic','topic'), ('demo','demo')] %}
{% for val, label in entities %}
<a class="filter-chip {{ 'is-active' if entity == val }}"
href="{{ url_for('admin.audit', action=action, entity=val) }}">{{ label }}</a>
+2
View File
@@ -16,6 +16,8 @@
<nav class="anav">
<a class="anav__brand" href="{{ url_for('admin.dashboard') }}">JQC<span>admin</span></a>
<div class="anav__right">
<a class="anav__link" href="{{ url_for('admin.demos') }}">Demo requests{% if pending_demos %}<span
class="anav__count">{{ pending_demos }}</span>{% endif %}</a>
<a class="anav__link" href="{{ url_for('admin.audit') }}">Audit</a>
<a class="anav__link" href="{{ url_for('index') }}" target="_blank" rel="noopener">View site ↗</a>
<span class="anav__user">{{ session.get('admin') }}</span>
+91
View File
@@ -0,0 +1,91 @@
{% extends "admin/base.html" %}
{% block title %}Demo requests{% endblock %}
{% block content %}
<header class="page-head">
<div>
<h1>Demo requests</h1>
<p class="muted">Appointments booked from the public page, soonest first.</p>
</div>
<a class="btn btn--ghost" href="{{ url_for('admin.dashboard') }}">← Dashboard</a>
</header>
<div class="filters">
<span class="filters__label">Show</span>
{% set views = [('', 'Open'), ('new','new'), ('scheduled','scheduled'), ('done','done'), ('cancelled','cancelled')] %}
{% for val, label in views %}
<a class="filter-chip {{ 'is-active' if status == val }}"
href="{{ url_for('admin.demos', status=val) }}">{{ label }}</a>
{% endfor %}
{% if new_count %}<span class="filters__sep">·</span><span class="muted">{{ new_count }} new</span>{% endif %}
</div>
{% if requests %}
<div class="table-wrap">
<table class="audit-table">
<thead>
<tr>
<th>Requested for</th>
<th>Contact</th>
<th>Company</th>
<th>Message</th>
<th>Received (UTC)</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for r in requests %}
<tr>
<td class="mono nowrap">{{ r.preferred_date.strftime('%Y-%m-%d') }} {{ r.preferred_time }}</td>
<td>
<strong>{{ r.name }}</strong><br>
<a href="mailto:{{ r.email }}">{{ r.email }}</a>
{% if r.phone %}<br><span class="mono">{{ r.phone }}</span>{% endif %}
</td>
<td>{{ r.company or '—' }}</td>
<td>{{ r.message or '' }}</td>
<td class="mono nowrap">{{ r.created_at.strftime('%Y-%m-%d %H:%M') if r.created_at else '—' }}</td>
<td><span class="badge badge--{{ 'create' if r.status == 'new' else ('update' if r.status == 'scheduled' else 'delete') }}">{{ r.status }}</span></td>
<td class="nowrap">
<form method="post" action="{{ url_for('admin.demo_status', req_id=r.id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="back_status" value="{{ status }}">
<select name="status" class="mini-select">
{% for s in statuses %}
<option value="{{ s }}" {{ 'selected' if r.status == s }}>{{ s }}</option>
{% endfor %}
</select>
<button class="btn btn--ghost btn--sm" type="submit">Set</button>
</form>
<form method="post" action="{{ url_for('admin.demo_delete', req_id=r.id) }}" class="inline-form"
onsubmit="return confirm('Delete the request from {{ r.name }}?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn--danger btn--sm" type="submit">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pagination.pages > 1 %}
<nav class="pager">
{% if pagination.has_prev %}
<a class="btn btn--ghost btn--sm" href="{{ url_for('admin.demos', page=pagination.prev_num, status=status) }}">← Previous</a>
{% else %}
<span class="btn btn--ghost btn--sm is-disabled">← Previous</span>
{% endif %}
<span class="pager__info">Page {{ pagination.page }} of {{ pagination.pages }}</span>
{% if pagination.has_next %}
<a class="btn btn--ghost btn--sm" href="{{ url_for('admin.demos', page=pagination.next_num, status=status) }}">Next →</a>
{% else %}
<span class="btn btn--ghost btn--sm is-disabled">Next →</span>
{% endif %}
</nav>
{% endif %}
{% else %}
<div class="empty">No demo requests{{ ' with this status' if status }}.</div>
{% endif %}
{% endblock %}
+113
View File
@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Request a demo — JQC · LT Services</title>
<meta name="description" content="Book a live demonstration of LT Services' JQC quality control platform.">
<meta name="robots" content="noindex">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700;12..96,800&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="{{ asset_url('css/style.css') }}">
</head>
<body>
<div class="grain" aria-hidden="true"></div>
<main class="book">
<a class="book__back" href="{{ url_for('index') }}">← Back to JQC features</a>
<header class="book__head">
<p class="book__kicker">Live demonstration</p>
<h1 class="book__title">Book a demo</h1>
<p class="book__lede">
Pick a day and time that suits you and we'll walk you through JQC — inspections, issue tracking, SLA alerts
and reporting — on your own facilities' terms. Monday to Friday, {{ tz_label }}.
</p>
</header>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="book__alerts" role="alert">
{% for cat, msg in messages %}
<p class="book__alert book__alert--{{ cat }}">{{ msg }}</p>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form class="book__form" method="post" action="{{ url_for('demo') }}" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- honeypot: hidden from people, irresistible to bots -->
<div class="book__hp" aria-hidden="true">
<label>Website<input type="text" name="website" tabindex="-1" autocomplete="off"></label>
</div>
<div class="field">
<label for="name">Your name <span class="req">*</span></label>
<input id="name" name="name" type="text" maxlength="120" required autocomplete="name"
value="{{ form.name or '' }}">
</div>
<div class="field-row">
<div class="field">
<label for="company">Company / facility</label>
<input id="company" name="company" type="text" maxlength="160" autocomplete="organization"
value="{{ form.company or '' }}">
</div>
<div class="field">
<label for="phone">Phone</label>
<input id="phone" name="phone" type="tel" maxlength="40" autocomplete="tel"
value="{{ form.phone or '' }}">
</div>
</div>
<div class="field">
<label for="email">Email <span class="req">*</span></label>
<input id="email" name="email" type="email" maxlength="200" required autocomplete="email"
value="{{ form.email or '' }}">
<p class="hint">We'll send a confirmation here.</p>
</div>
<div class="field-row">
<div class="field">
<label for="preferred_date">Preferred date <span class="req">*</span></label>
<input id="preferred_date" name="preferred_date" type="date" required
min="{{ min_date }}" max="{{ max_date }}" value="{{ form.preferred_date or '' }}">
<p class="hint">Weekdays only.</p>
</div>
<div class="field">
<label for="preferred_time">Preferred time <span class="req">*</span></label>
<select id="preferred_time" name="preferred_time" required>
<option value="">Select a time…</option>
{% for slot in slots %}
<option value="{{ slot }}" {{ 'selected' if form.preferred_time == slot }}>{{ slot }}</option>
{% endfor %}
</select>
<p class="hint">{{ tz_label }}.</p>
</div>
</div>
<div class="field">
<label for="message">Anything we should prepare?</label>
<textarea id="message" name="message" rows="4" maxlength="2000"
placeholder="Number of sites, current QC process, people who'll join…">{{ form.message or '' }}</textarea>
</div>
<button class="book__submit" type="submit">Request this appointment</button>
<p class="book__fallback">
Prefer email? <a href="{{ demo_url }}">Contact us directly</a>.
</p>
</form>
</main>
<footer class="cta cta--slim">
<p class="cta__legal">© LT Services Inc. · JQC Quality Control Program</p>
</footer>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Request received — JQC · LT Services</title>
<meta name="robots" content="noindex">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700;12..96,800&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="{{ asset_url('css/style.css') }}">
</head>
<body>
<div class="grain" aria-hidden="true"></div>
<main class="book book--done">
<div class="book__stamp" aria-hidden="true">
<svg viewBox="0 0 24 24">
<polyline points="4 12 10 18 20 6" />
</svg>
</div>
<h1 class="book__title">Request received</h1>
<p class="book__lede">
Thank you — your appointment request is with us. We've emailed you a copy, and someone from LT Services
will confirm the time shortly.
</p>
<p class="book__fallback">
Need to change something? <a href="{{ demo_url }}">Email us</a> or
<a href="{{ url_for('demo') }}">submit another time</a>.
</p>
<a class="book__back" href="{{ url_for('index') }}">← Back to JQC features</a>
</main>
<footer class="cta cta--slim">
<p class="cta__legal">© LT Services Inc. · JQC Quality Control Program</p>
</footer>
</body>
</html>
+2 -1
View File
@@ -110,7 +110,8 @@
<div class="cta__inner">
<p class="cta__kicker">Paperless · Maintenance-free · No subscription</p>
<h2 class="cta__title">LT would be glad to run a live demonstration.</h2>
<a class="cta__btn" href="{{ demo_url }}">Request a demo</a>
<a class="cta__btn" href="{{ url_for('demo') }}">Request a demo</a>
<p class="cta__alt">Pick a day and time — or <a href="{{ demo_url }}">email us directly</a>.</p>
</div>
<p class="cta__legal">© LT Services Inc. · JQC Quality Control Program</p>
</footer>