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