pdf-quiz-generator/backend/alembic/versions/d4e5f6a7b8c9_invite_codes.py
Daniel 2267f53b55 feat: the player is a box; sharing and sign-up are the administrator's
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
2026-09-11 19:17:35 +02:00

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")