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