From ea9458395dc216ec37dd1ebe95db855538260c39 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Tue, 28 Jul 2026 14:18:22 -0400 Subject: [PATCH] Jul 28 - Update the demo request button, and add demo registration page --- .env.example | 30 +++++- CLAUDE.md | 46 +++++++- README.md | 48 ++++++++- add_demo.sql | 20 ++++ admin.py | 83 ++++++++++++++- app.py | 213 ++++++++++++++++++++++++++++++++++++- config.py | 32 +++++- mailer.py | 84 +++++++++++++++ schema.sql | 16 +++ static/css/admin.css | 13 +++ static/css/style.css | 79 ++++++++++++++ templates/admin/audit.html | 2 +- templates/admin/base.html | 2 + templates/admin/demos.html | 91 ++++++++++++++++ templates/demo.html | 113 ++++++++++++++++++++ templates/demo_thanks.html | 43 ++++++++ templates/index.html | 3 +- 17 files changed, 905 insertions(+), 13 deletions(-) create mode 100644 add_demo.sql create mode 100644 mailer.py create mode 100644 templates/admin/demos.html create mode 100644 templates/demo.html create mode 100644 templates/demo_thanks.html diff --git a/.env.example b/.env.example index 0b17796..5d6a07f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index b757914..a34a8cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:00–16: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) diff --git a/README.md b/README.md index 7263c89..a3ed190 100644 --- a/README.md +++ b/README.md @@ -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:00–16: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. diff --git a/add_demo.sql b/add_demo.sql new file mode 100644 index 0000000..7f1af0c --- /dev/null +++ b/add_demo.sql @@ -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; diff --git a/admin.py b/admin.py index 1ba6602..86209f3 100644 --- a/admin.py +++ b/admin.py @@ -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//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//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 = ( diff --git a/app.py b/app.py index 971b917..ec5c4a1 100644 --- a/app.py +++ b/app.py @@ -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"} diff --git a/config.py b/config.py index 8110353..592f178 100644 --- a/config.py +++ b/config.py @@ -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") diff --git a/mailer.py b/mailer.py new file mode 100644 index 0000000..f93465f --- /dev/null +++ b/mailer.py @@ -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 diff --git a/schema.sql b/schema.sql index 37f6f1e..2044d20 100644 --- a/schema.sql +++ b/schema.sql @@ -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. -- ------------------------------------------------------------------ diff --git a/static/css/admin.css b/static/css/admin.css index 486fd2e..5297005 100644 --- a/static/css/admin.css +++ b/static/css/admin.css @@ -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); diff --git a/static/css/style.css b/static/css/style.css index a7eaad3..df5a419 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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 ---------- */ diff --git a/templates/admin/audit.html b/templates/admin/audit.html index 8e2a21f..9054323 100644 --- a/templates/admin/audit.html +++ b/templates/admin/audit.html @@ -17,7 +17,7 @@ href="{{ url_for('admin.audit', action=val, entity=entity) }}">{{ label }} {% endfor %} · - {% set entities = [('', 'All types'), ('section','section'), ('topic','topic')] %} + {% set entities = [('', 'All types'), ('section','section'), ('topic','topic'), ('demo','demo')] %} {% for val, label in entities %} {{ label }} diff --git a/templates/admin/base.html b/templates/admin/base.html index 6c13b06..af8e631 100644 --- a/templates/admin/base.html +++ b/templates/admin/base.html @@ -16,6 +16,8 @@