Jul 28 - Update schedule page's referred time
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -309,8 +309,15 @@ 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.
|
||||
`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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user