from __future__ import annotations from datetime import datetime, timezone from typing import Any from sqlalchemy import ( JSON, Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text, UniqueConstraint, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base def utcnow() -> datetime: return datetime.now(timezone.utc) AVATAR_ANIMALS = ( "fox", "owl", "bear", "otter", "koala", "tiger", "frog", "penguin", ) AVATAR_COLORS = ( "avatar-1", "avatar-2", "avatar-3", "avatar-4", "avatar-5", "avatar-6", "avatar-7", "avatar-8", ) GENDER_OPTIONS = ( "woman", "man", "non_binary", "agender", "genderqueer", "two_spirit", "other", "prefer_not_to_say", ) class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) auth_sub: Mapped[str] = mapped_column(String(190), unique=True, nullable=False, index=True) email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) nickname: Mapped[str] = mapped_column(String(48), unique=True, nullable=False, index=True) first_name: Mapped[str] = mapped_column(String(80), nullable=False, default="") last_name: Mapped[str] = mapped_column(String(80), nullable=False, default="") age: Mapped[int | None] = mapped_column(Integer, nullable=True) gender: Mapped[str] = mapped_column(String(40), nullable=False, default="prefer_not_to_say") gender_custom: Mapped[str | None] = mapped_column(String(80), nullable=True) avatar_animal: Mapped[str] = mapped_column(String(32), nullable=False, default="fox") avatar_color: Mapped[str] = mapped_column(String(32), nullable=False, default="avatar-1") public_website_consent: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) consent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) onboarded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_banned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) ban_reason: Mapped[str | None] = mapped_column(String(255), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) api_keys: Mapped[list[ApiKey]] = relationship( "ApiKey", back_populates="user", cascade="all, delete-orphan" ) quizzes: Mapped[list[Quiz]] = relationship( "Quiz", back_populates="author", cascade="all, delete-orphan" ) attempts: Mapped[list[QuizAttempt]] = relationship( "QuizAttempt", back_populates="user", cascade="all, delete-orphan" ) votes: Mapped[list[QuizVote]] = relationship( "QuizVote", back_populates="user", cascade="all, delete-orphan" ) favourites: Mapped[list[Favourite]] = relationship( "Favourite", back_populates="user", cascade="all, delete-orphan", order_by="Favourite.position", ) class ApiKey(Base): __tablename__ = "api_keys" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) user_id: Mapped[int] = mapped_column( Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) name: Mapped[str] = mapped_column(String(80), nullable=False) key_prefix: Mapped[str] = mapped_column(String(24), nullable=False) key_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) user: Mapped[User] = relationship("User", back_populates="api_keys") class Quiz(Base): __tablename__ = "quizzes" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) share_slug: Mapped[str] = mapped_column(String(40), unique=True, nullable=False, index=True) author_id: Mapped[int] = mapped_column( Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) title: Mapped[str] = mapped_column(String(140), nullable=False) description: Mapped[str] = mapped_column(Text, nullable=False, default="") is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) is_removed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow ) author: Mapped[User] = relationship("User", back_populates="quizzes") questions: Mapped[list[Question]] = relationship( "Question", back_populates="quiz", cascade="all, delete-orphan", order_by="Question.position", ) attempts: Mapped[list[QuizAttempt]] = relationship( "QuizAttempt", back_populates="quiz", cascade="all, delete-orphan" ) votes: Mapped[list[QuizVote]] = relationship( "QuizVote", back_populates="quiz", cascade="all, delete-orphan" ) favourited_by: Mapped[list[Favourite]] = relationship( "Favourite", back_populates="quiz", cascade="all, delete-orphan" ) class Question(Base): __tablename__ = "questions" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) quiz_id: Mapped[int] = mapped_column( Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False, index=True ) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) question_type: Mapped[str] = mapped_column(String(24), nullable=False) prompt: Mapped[str] = mapped_column(Text, nullable=False) options: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False, default=list) correct_answer: Mapped[str] = mapped_column(String(500), nullable=False) accepted_answers: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) quiz: Mapped[Quiz] = relationship("Quiz", back_populates="questions") class QuizAttempt(Base): __tablename__ = "quiz_attempts" __table_args__ = ( Index("ix_quiz_attempts_quiz_score", "quiz_id", "score_percent", "completed_at"), Index("ix_quiz_attempts_user_quiz", "user_id", "quiz_id", "completed_at"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) quiz_id: Mapped[int] = mapped_column( Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False ) user_id: Mapped[int] = mapped_column( Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) score_count: Mapped[int] = mapped_column(Integer, nullable=False) total_questions: Mapped[int] = mapped_column(Integer, nullable=False) score_percent: Mapped[float] = mapped_column(Float, nullable=False) answers_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict) completed_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) quiz: Mapped[Quiz] = relationship("Quiz", back_populates="attempts") user: Mapped[User] = relationship("User", back_populates="attempts") class QuizVote(Base): __tablename__ = "quiz_votes" __table_args__ = (UniqueConstraint("quiz_id", "user_id", name="uq_quiz_vote_user"),) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) quiz_id: Mapped[int] = mapped_column( Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False, index=True ) user_id: Mapped[int] = mapped_column( Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) value: Mapped[int] = mapped_column(Integer, nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow ) quiz: Mapped[Quiz] = relationship("Quiz", back_populates="votes") user: Mapped[User] = relationship("User", back_populates="votes") class Favourite(Base): __tablename__ = "favourites" __table_args__ = (UniqueConstraint("user_id", "quiz_id", name="uq_favourite_user_quiz"),) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) user_id: Mapped[int] = mapped_column( Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) quiz_id: Mapped[int] = mapped_column( Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False, index=True ) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) user: Mapped[User] = relationship("User", back_populates="favourites") quiz: Mapped[Quiz] = relationship("Quiz", back_populates="favourited_by")