Jul 28 - Update schedule page's referred time

This commit is contained in:
2026-07-28 15:09:12 -04:00
parent 4bb345da39
commit 81aeeac3d2
6 changed files with 203 additions and 18 deletions
+20 -2
View File
@@ -223,8 +223,26 @@ shows only `is_published` topics (sections with no published topics are hidden).
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.
- **One appointment per slot**, enforced in three places that share the same
helpers (`taken_slots`, `available_slots`, `demo_day_status` in `app.py`):
1. `GET /demo/slots?date=YYYY-MM-DD` → JSON `{open, reason, slots[]}`;
`static/js/demo-booking.js` rebuilds the time `<select>` on every date
change, so a booked time is never offered. Closed days (weekend / past /
beyond `DEMO_MAX_DAYS_AHEAD`) come back `open:false` with the reason, and
the picker is emptied + disabled. Stale responses are dropped by a request
counter; a fetch failure fails OPEN (keeps the list, server still decides).
2. The server renders only free slots when the form already has a date (so a
re-render after a validation error can't offer a slot taken meanwhile), and
the whole grid when it doesn't — the no-JS path still works.
3. `POST /demo` remains the authority: a (date, time) with a non-`cancelled`
row is rejected with "that time was just taken". Cancelling frees the slot.
- The endpoint is public and read-only: it reveals which times are free —
exactly what the form shows anyway — and nothing about who booked them.
- Two people submitting the same free slot in the same instant can still both
pass the check-then-insert; the second one is simply a duplicate row for the
owner to sort out. Closing that needs a DB-level constraint (a generated
`slot_key` NULL-ed for cancelled rows + UNIQUE index), which we have not
added.
- **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;
+9 -2
View File
@@ -309,8 +309,15 @@ 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.
`DEMO_MAX_DAYS_AHEAD`).
**Only free times are offered.** When the customer picks a date, the time
dropdown reloads to show just that day's remaining slots — anything already
booked disappears, so two people can't choose the same appointment. Weekends and
out-of-range dates say why they're unavailable. If a time is taken while someone
is still filling the form in, it drops off the list with a note when they change
the date, and the server refuses it on submit regardless. Marking a request
**cancelled** in the admin panel puts its slot back on offer.
**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
+68 -11
View File
@@ -9,7 +9,9 @@ from logging.handlers import RotatingFileHandler
import bleach
from bleach.css_sanitizer import CSSSanitizer
from flask import Flask, flash, redirect, render_template, request, url_for
from flask import (
Flask, flash, jsonify, redirect, render_template, request, url_for,
)
from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect
from markupsafe import Markup
@@ -200,6 +202,40 @@ def demo_slots(config):
return slots
def taken_slots(day):
"""Times already booked on `day`. Cancelled requests free their slot."""
rows = (
DemoRequest.query
.with_entities(DemoRequest.preferred_time)
.filter(DemoRequest.preferred_date == day,
DemoRequest.status != "cancelled")
.all()
)
return {row[0] for row in rows}
def available_slots(config, day):
"""Bookable times left on `day` — the full grid minus what's taken."""
if day is None:
return demo_slots(config)
booked = taken_slots(day)
return [slot for slot in demo_slots(config) if slot not in booked]
def demo_day_status(config, day):
"""Why a date can't be booked, or None if it's open. Shared by the form and
the /demo/slots endpoint so both give the same answer."""
today = date.today()
if day < today:
return "That date has already passed."
if day > today + timedelta(days=config["DEMO_MAX_DAYS_AHEAD"]):
return (f"Please pick a date within the next "
f"{config['DEMO_MAX_DAYS_AHEAD']} days.")
if day.weekday() >= 5:
return "Demonstrations run Monday through Friday."
return None
def _rate_limited(ip, limit):
"""True if `ip` has already submitted `limit` requests in the last hour."""
if limit <= 0:
@@ -350,15 +386,9 @@ def create_app():
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.")
closed = demo_day_status(cfg, wanted)
if closed:
errors.append(closed)
if form["preferred_time"] not in slots:
errors.append("Please pick an available time.")
@@ -395,9 +425,19 @@ def create_app():
_notify_demo(app, req)
return redirect(url_for("demo_thanks"))
# Show only what's still free on the date in hand, so a re-render after
# an error can't offer a slot that was taken in the meantime. With no
# date chosen yet the full grid is listed and the JS narrows it as soon
# as one is picked (and without JS the checks above are the backstop).
chosen = None
if form.get("preferred_date"):
try:
chosen = datetime.strptime(form["preferred_date"], "%Y-%m-%d").date()
except ValueError:
chosen = None
return render_template(
"demo.html",
slots=slots,
slots=available_slots(cfg, chosen) if chosen else slots,
form=form,
min_date=today.isoformat(),
max_date=max_date.isoformat(),
@@ -405,6 +445,23 @@ def create_app():
demo_url=cfg["DEMO_CONTACT_URL"],
)
@app.route("/demo/slots")
def demo_slots_api():
"""Times still bookable on ?date=YYYY-MM-DD, for the form's time picker.
Public and read-only: it exposes which slots are free, which is exactly
what the form shows anyway, and nothing about who booked them."""
cfg = app.config
raw = request.args.get("date", "")
try:
day = datetime.strptime(raw, "%Y-%m-%d").date()
except ValueError:
return jsonify(ok=False, error="Invalid date."), 400
closed = demo_day_status(cfg, day)
if closed:
return jsonify(ok=True, date=raw, open=False, reason=closed, slots=[])
return jsonify(ok=True, date=raw, open=True, slots=available_slots(cfg, day))
@app.route("/demo/thanks")
def demo_thanks():
return render_template("demo_thanks.html", demo_url=app.config["DEMO_CONTACT_URL"])
+3
View File
@@ -271,6 +271,9 @@ body{
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)}
/* availability messages from demo-booking.js (slot taken, day closed, …) */
.hint--warn{color:#8C5A12;font-weight:500}
.field select:disabled{background:var(--paper-2);color:var(--muted);cursor:not-allowed}
.book__submit{
align-self:flex-start;margin-top:6px;padding:16px 38px;border:none;cursor:pointer;
+95
View File
@@ -0,0 +1,95 @@
/* Demo booking form: keep the time picker in step with the chosen date.
*
* Pick a date -> ask /demo/slots what's still free that day -> rebuild the time
* dropdown from the answer, so a slot someone else has already booked is never
* offered. Weekends and out-of-range dates come back closed, with the reason.
*
* Progressive enhancement: without JS (or if the fetch fails) the select keeps
* the full server-rendered list and the POST handler still rejects a taken slot
* — the server is the authority either way, this just stops the customer
* choosing a time that was never going to be accepted.
*/
(function () {
'use strict';
var form = document.querySelector('[data-slots-url]');
if (!form) return;
var url = form.getAttribute('data-slots-url');
var dateEl = document.getElementById('preferred_date');
var timeEl = document.getElementById('preferred_time');
var noteEl = document.getElementById('slot-note');
if (!dateEl || !timeEl || !noteEl) return;
var placeholder = 'Select a time…';
var baseNote = noteEl.textContent; // e.g. "Eastern Time."
var request = 0; // drops out-of-order responses
function note(message, kind) {
noteEl.textContent = message;
noteEl.className = 'hint' + (kind ? ' hint--' + kind : '');
}
function fill(slots, keep) {
timeEl.innerHTML = '';
var first = document.createElement('option');
first.value = '';
first.textContent = slots.length ? placeholder : 'No times available';
timeEl.appendChild(first);
slots.forEach(function (slot) {
var option = document.createElement('option');
option.value = slot;
option.textContent = slot;
if (slot === keep) option.selected = true;
timeEl.appendChild(option);
});
timeEl.disabled = slots.length === 0;
}
function load() {
var value = dateEl.value;
if (!value) {
note(baseNote);
return;
}
var ticket = ++request;
var wanted = timeEl.value;
timeEl.disabled = true;
note('Checking availability…');
fetch(url + '?date=' + encodeURIComponent(value), {
headers: { 'Accept': 'application/json' }
}).then(function (res) {
return res.json().then(function (body) {
if (!res.ok) throw new Error(body.error || 'Could not load times.');
return body;
});
}).then(function (data) {
if (ticket !== request) return; // a newer date won the race
if (!data.open) {
fill([], null);
note(data.reason || 'That date is not available.', 'warn');
return;
}
fill(data.slots, wanted);
if (!data.slots.length) {
note('Every time on that day is booked — please choose another date.', 'warn');
} else if (wanted && data.slots.indexOf(wanted) === -1) {
// The time they had chosen went while they were filling the form in.
note('That time has just been booked. Please pick another.', 'warn');
} else {
note(baseNote);
}
}).catch(function () {
if (ticket !== request) return;
// Fail open: leave whatever is in the select and let the server decide.
timeEl.disabled = false;
note("Couldn't check availability — we'll confirm your time by email.", 'warn');
});
}
dateEl.addEventListener('change', load);
// A date can survive a failed submit or a browser restore.
if (dateEl.value) load();
})();
+8 -3
View File
@@ -40,7 +40,8 @@
{% endif %}
{% endwith %}
<form class="book__form" method="post" action="{{ url_for('demo') }}" novalidate>
<form class="book__form" method="post" action="{{ url_for('demo') }}" novalidate
data-slots-url="{{ url_for('demo_slots_api') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- honeypot: hidden from people, irresistible to bots -->
<div class="book__hp" aria-hidden="true">
@@ -83,12 +84,14 @@
<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>
<option value="">{{ 'Select a time…' if slots else 'No times available' }}</option>
{% for slot in slots %}
<option value="{{ slot }}" {{ 'selected' if form.preferred_time == slot }}>{{ slot }}</option>
{% endfor %}
</select>
<p class="hint">{{ tz_label }}.</p>
<!-- Only free slots are listed: the server filters by the chosen date,
and demo-booking.js refreshes this list whenever the date changes. -->
<p class="hint" id="slot-note">{{ tz_label }}.</p>
</div>
</div>
@@ -108,6 +111,8 @@
<footer class="cta cta--slim">
<p class="cta__legal">© LT Services Inc. · JQC Quality Control Program</p>
</footer>
<script src="{{ asset_url('js/demo-booking.js') }}" defer></script>
</body>
</html>