pdf-quiz-generator/backend/app/services/invites.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

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",
}