The quiz player is a box the height of the window. The question used to
scroll the whole page, which took the session rail and the navigation off
screen exactly when you wanted them; now each column scrolls on its own
and the bar — Exit session, Previous, Next, Review — stays put.
Two site-wide switches, together under Settings → Site policy because
both are the administrator's and both apply to everyone:
* Sharing can be turned off. That stops new links being made; one
already handed to somebody keeps working, since revoking it would
break something a learner has already given away.
* Sign-up can be made invite-only, with single-use codes carrying a
note of who each is for and, afterwards, who it let in. A spent code
is kept rather than deleted — that record is the point of invite-only.
The alphabet has no O/0 or I/1/l, because these get read aloud.
The registration form asks for a code only when the site needs one, via
an unauthenticated policy endpoint — it has to know before there is an
account to ask with. It never says whether a given code is valid before
the account exists, which would make it somewhere to guess them. The
first account is always allowed, or a new install would lock itself out
before an administrator existed to issue a code.
Flags fall back to their defaults when Redis is down, in the safe
direction each way: sharing keeps working, sign-up does not silently
open.
Found on the way: the registration form's three labels named nothing —
no `for`, no wrapping — so a screen reader announced unlabelled boxes.
Backend 261/261, frontend 328/328.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
67 lines
2.2 KiB
Python
67 lines
2.2 KiB
Python
"""Invite codes: issuing them, checking one, and spending it.
|
|
|
|
Kept out of the routers because two of them need it — registration spends a
|
|
code, administration issues them — and the rule for "usable" is the kind of
|
|
thing that must have exactly one definition.
|
|
"""
|
|
import secrets
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models.invite import InviteCode
|
|
from app.models.user import User
|
|
|
|
#: Unambiguous when read aloud or copied: no O/0, no I/1/l.
|
|
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
LENGTH = 10
|
|
|
|
|
|
def generate_code() -> str:
|
|
return "".join(secrets.choice(ALPHABET) for _ in range(LENGTH))
|
|
|
|
|
|
def create(db: Session, created_by: int | None, note: str | None = None) -> InviteCode:
|
|
# Retried rather than trusted: the column is unique, and a collision is
|
|
# cheaper to avoid than to explain.
|
|
for _ in range(5):
|
|
code = generate_code()
|
|
if not db.query(InviteCode.id).filter(InviteCode.code == code).first():
|
|
row = InviteCode(code=code, note=(note or None), created_by=created_by)
|
|
db.add(row)
|
|
db.commit()
|
|
db.refresh(row)
|
|
return row
|
|
raise RuntimeError("Could not allocate an unused invite code")
|
|
|
|
|
|
def usable(db: Session, code: str | None) -> InviteCode | None:
|
|
"""The code, if it exists and has neither been spent nor withdrawn."""
|
|
cleaned = (code or "").strip().upper()
|
|
if not cleaned:
|
|
return None
|
|
row = db.query(InviteCode).filter(InviteCode.code == cleaned).first()
|
|
if row is None or row.used_by is not None or row.revoked_at is not None:
|
|
return None
|
|
return row
|
|
|
|
|
|
def spend(db: Session, invite: InviteCode, user: User) -> None:
|
|
invite.used_by = user.id
|
|
invite.used_at = datetime.utcnow()
|
|
db.commit()
|
|
|
|
|
|
def as_json(row: InviteCode, users: dict[int, User]) -> dict:
|
|
used_by = users.get(row.used_by)
|
|
return {
|
|
"id": row.id,
|
|
"code": row.code,
|
|
"note": row.note,
|
|
"created_at": row.created_at,
|
|
"used_at": row.used_at,
|
|
"used_by_name": getattr(used_by, "name", None),
|
|
"used_by_email": getattr(used_by, "email", None),
|
|
"revoked_at": row.revoked_at,
|
|
"status": "used" if row.used_by else "revoked" if row.revoked_at else "open",
|
|
}
|