pdf-quiz-generator/backend/app/models/invite.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

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)