Single sign-on wrote a random string nobody would ever know. That reads as "has a password" to everything that asks — so Settings demanded a current password before it would let those accounts set their first, and the only way through was to click "forgot password" for a password they never had. The same trap was waiting for anybody who only ever signs in with a code. Null says the true thing. Signing in refuses an account with no password the way it refuses a wrong one, because which accounts have one is not a question that endpoint answers. Setting a first password asks for no current one; changing an existing password still does. `/auth/me` reports whether there is one at all and nothing about it, because Settings has to choose between "Set a password" and "Change password" and cannot tell from the outside. The random strings already written are left alone. They are unguessable, so nothing can sign in with them, and clearing them would mean deciding from outside which accounts were meant to have one. Identity is the email address throughout, so the three ways in are three ways into the same account: single sign-on, a code, or a password — and a person may acquire or drop the third at any point without losing the other two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
56 lines
2.9 KiB
Python
56 lines
2.9 KiB
Python
from datetime import datetime
|
|
|
|
from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Boolean
|
|
from sqlalchemy.orm import relationship
|
|
from sqlalchemy.sql import false as sa_false
|
|
|
|
from app.database import Base
|
|
from app.models.exam import Exam # noqa — users.active_exam_id FK needs the table in metadata.
|
|
|
|
|
|
class User(Base):
|
|
__tablename__ = "users"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
email = Column(String, unique=True, index=True, nullable=False)
|
|
# Null where there is no password at all. Somebody who arrived through
|
|
# single sign-on, or who only ever signs in with a code, has never chosen
|
|
# one — and storing a random string they can never guess made that
|
|
# indistinguishable from having one, so Settings asked them for a current
|
|
# password before it would let them set their first.
|
|
hashed_password = Column(String, nullable=True)
|
|
name = Column(String, nullable=False)
|
|
role = Column(String, default="user") # admin, moderator, user
|
|
# Which exam the learner is studying for; scopes the bank they see.
|
|
active_exam_id = Column(Integer, ForeignKey("exams.id", ondelete="SET NULL"), nullable=True)
|
|
is_unthrottled = Column(Integer, default=0) # 1 = exempt from rate limits
|
|
reminders_disabled = Column(Boolean, default=False, nullable=False, server_default=sa_false())
|
|
# Which voice reads a question aloud. A setting, not a decision to retake at
|
|
# the top of every session — it used to be a dropdown in the player, beside
|
|
# the question, where it was the only control on screen that had nothing to
|
|
# do with answering. Null means whichever voice an administrator marked
|
|
# default, so a learner who never opens Settings still gets a working one.
|
|
tts_voice = Column(String, nullable=True)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
|
|
# passive_deletes leaves the child rows to the database, which already
|
|
# knows what to do with every one of them — the foreign keys are CASCADE or
|
|
# SET NULL. Without it SQLAlchemy insists on emptying each relationship
|
|
# itself first, by writing NULL into columns that are NOT NULL, and
|
|
# deleting a user failed with a constraint violation from a table nobody
|
|
# was looking at.
|
|
documents = relationship("PDFDocument", back_populates="user", passive_deletes=True)
|
|
quizzes = relationship("Quiz", back_populates="user", passive_deletes=True)
|
|
attempts = relationship("QuizAttempt", back_populates="user", passive_deletes=True)
|
|
favorites = relationship("Favorite", back_populates="user",
|
|
cascade="all, delete-orphan", passive_deletes=True)
|
|
note = relationship("UserNote", back_populates="user", cascade="all, delete-orphan",
|
|
uselist=False, passive_deletes=True)
|
|
|
|
@property
|
|
def is_admin(self):
|
|
return self.role == "admin"
|
|
|
|
@property
|
|
def is_moderator(self):
|
|
return self.role in ("admin", "moderator")
|