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
34 lines
1.2 KiB
Python
34 lines
1.2 KiB
Python
"""Invite codes, for a site that is not open to register
|
|
|
|
Revision ID: d4e5f6a7b8c9
|
|
Revises: c3d4e5f6a7b8
|
|
"""
|
|
import sqlalchemy as sa
|
|
from alembic import op
|
|
|
|
revision = "d4e5f6a7b8c9"
|
|
down_revision = "c3d4e5f6a7b8"
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
if "invite_codes" in sa.inspect(op.get_bind()).get_table_names():
|
|
return
|
|
op.create_table(
|
|
"invite_codes",
|
|
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
|
sa.Column("code", sa.String(length=32), nullable=False, unique=True),
|
|
sa.Column("note", sa.String(length=200), nullable=True),
|
|
sa.Column("created_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
|
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
|
|
sa.Column("used_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
|
sa.Column("used_at", sa.DateTime(), nullable=True),
|
|
sa.Column("revoked_at", sa.DateTime(), nullable=True),
|
|
)
|
|
op.create_index("ix_invite_codes_code", "invite_codes", ["code"], unique=True)
|
|
|
|
|
|
def downgrade() -> None:
|
|
if "invite_codes" in sa.inspect(op.get_bind()).get_table_names():
|
|
op.drop_table("invite_codes")
|