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,
|
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
|
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).
|
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
|
- **One appointment per slot**, enforced in three places that share the same
|
||||||
non-`cancelled` row is rejected with a flash. Cancelling frees the slot.
|
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
|
- **Abuse control:** hidden `website` honeypot (filled → fake success, nothing
|
||||||
stored) + per-IP hourly cap `DEMO_RATE_LIMIT` held in `_demo_hits` (in-memory,
|
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;
|
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
|
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`,
|
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
|
`DEMO_MAX_DAYS_AHEAD`).
|
||||||
booked twice.
|
|
||||||
|
**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
|
**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
|
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
|
import bleach
|
||||||
from bleach.css_sanitizer import CSSSanitizer
|
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_sqlalchemy import SQLAlchemy
|
||||||
from flask_wtf import CSRFProtect
|
from flask_wtf import CSRFProtect
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
@@ -200,6 +202,40 @@ def demo_slots(config):
|
|||||||
return slots
|
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):
|
def _rate_limited(ip, limit):
|
||||||
"""True if `ip` has already submitted `limit` requests in the last hour."""
|
"""True if `ip` has already submitted `limit` requests in the last hour."""
|
||||||
if limit <= 0:
|
if limit <= 0:
|
||||||
@@ -350,15 +386,9 @@ def create_app():
|
|||||||
wanted = None
|
wanted = None
|
||||||
errors.append("Please pick a date for the demonstration.")
|
errors.append("Please pick a date for the demonstration.")
|
||||||
if wanted:
|
if wanted:
|
||||||
if wanted < today:
|
closed = demo_day_status(cfg, wanted)
|
||||||
errors.append("Please pick a date that hasn't passed yet.")
|
if closed:
|
||||||
elif wanted > max_date:
|
errors.append(closed)
|
||||||
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:
|
if form["preferred_time"] not in slots:
|
||||||
errors.append("Please pick an available time.")
|
errors.append("Please pick an available time.")
|
||||||
@@ -395,9 +425,19 @@ def create_app():
|
|||||||
_notify_demo(app, req)
|
_notify_demo(app, req)
|
||||||
return redirect(url_for("demo_thanks"))
|
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(
|
return render_template(
|
||||||
"demo.html",
|
"demo.html",
|
||||||
slots=slots,
|
slots=available_slots(cfg, chosen) if chosen else slots,
|
||||||
form=form,
|
form=form,
|
||||||
min_date=today.isoformat(),
|
min_date=today.isoformat(),
|
||||||
max_date=max_date.isoformat(),
|
max_date=max_date.isoformat(),
|
||||||
@@ -405,6 +445,23 @@ def create_app():
|
|||||||
demo_url=cfg["DEMO_CONTACT_URL"],
|
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")
|
@app.route("/demo/thanks")
|
||||||
def demo_thanks():
|
def demo_thanks():
|
||||||
return render_template("demo_thanks.html", demo_url=app.config["DEMO_CONTACT_URL"])
|
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);
|
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)}
|
.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{
|
.book__submit{
|
||||||
align-self:flex-start;margin-top:6px;padding:16px 38px;border:none;cursor:pointer;
|
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 %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% 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() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<!-- honeypot: hidden from people, irresistible to bots -->
|
<!-- honeypot: hidden from people, irresistible to bots -->
|
||||||
<div class="book__hp" aria-hidden="true">
|
<div class="book__hp" aria-hidden="true">
|
||||||
@@ -83,12 +84,14 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="preferred_time">Preferred time <span class="req">*</span></label>
|
<label for="preferred_time">Preferred time <span class="req">*</span></label>
|
||||||
<select id="preferred_time" name="preferred_time" required>
|
<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 %}
|
{% for slot in slots %}
|
||||||
<option value="{{ slot }}" {{ 'selected' if form.preferred_time == slot }}>{{ slot }}</option>
|
<option value="{{ slot }}" {{ 'selected' if form.preferred_time == slot }}>{{ slot }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -108,6 +111,8 @@
|
|||||||
<footer class="cta cta--slim">
|
<footer class="cta cta--slim">
|
||||||
<p class="cta__legal">© LT Services Inc. · JQC Quality Control Program</p>
|
<p class="cta__legal">© LT Services Inc. · JQC Quality Control Program</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script src="{{ asset_url('js/demo-booking.js') }}" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user