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
28 lines
1.2 KiB
Python
28 lines
1.2 KiB
Python
from datetime import datetime
|
|
|
|
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
|
|
|
|
from app.database import Base
|
|
|
|
|
|
class InviteCode(Base):
|
|
"""A code an administrator issues so one person can register.
|
|
|
|
Single use by default: the point of invite-only is knowing who came in, and
|
|
a code that works forever is a password shared by everyone who has seen it.
|
|
A code is never deleted once used — who it let in is the record worth
|
|
keeping.
|
|
"""
|
|
|
|
__tablename__ = "invite_codes"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
code = Column(String(32), unique=True, nullable=False, index=True)
|
|
note = Column(String(200), nullable=True) # who it was meant for
|
|
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
#: Set when someone registers with it. Present means spent.
|
|
used_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
used_at = Column(DateTime, nullable=True)
|
|
#: An administrator can withdraw a code that has not been used.
|
|
revoked_at = Column(DateTime, nullable=True)
|