""" Session model for tryout attempt tracking. Represents a student's attempt at a tryout with scoring information. """ from datetime import datetime from typing import Literal, Union from sqlalchemy import ( Boolean, CheckConstraint, DateTime, Float, ForeignKey, Index, Integer, String, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base class Session(Base): """ Session model representing a student's tryout attempt. Tracks session metadata, scoring results, and IRT estimates. Attributes: id: Primary key session_id: Unique session identifier wp_user_id: WordPress user ID website_id: Website identifier tryout_id: Tryout identifier start_time: Session start timestamp end_time: Session end timestamp is_completed: Completion status scoring_mode_used: Scoring mode used for this session total_benar: Total correct answers total_bobot_earned: Total weight earned NM: Nilai Mentah (raw score) [0, 1000] NN: Nilai Nasional (normalized score) [0, 1000] theta: IRT ability estimate [-3, +3] theta_se: IRT standard error rataan_used: Mean value used for normalization sb_used: Standard deviation used for normalization created_at: Record creation timestamp updated_at: Record update timestamp user: User relationship tryout: Tryout relationship user_answers: User's responses in this session """ __tablename__ = "sessions" # Primary key id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) # Session identifier (globally unique) session_id: Mapped[str] = mapped_column( String(255), nullable=False, unique=True, index=True, comment="Unique session identifier", ) # Foreign keys wp_user_id: Mapped[str] = mapped_column( String(255), nullable=False, comment="WordPress user ID" ) website_id: Mapped[int] = mapped_column( ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False, comment="Website identifier", ) tryout_id: Mapped[str] = mapped_column( String(255), nullable=False, comment="Tryout identifier" ) # Timestamps start_time: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default="NOW()" ) end_time: Mapped[Union[datetime, None]] = mapped_column( DateTime(timezone=True), nullable=True, comment="Session end timestamp" ) is_completed: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, comment="Completion status" ) # Scoring metadata scoring_mode_used: Mapped[Literal["ctt", "irt", "hybrid"]] = mapped_column( String(50), nullable=False, comment="Scoring mode used for this session", ) # CTT scoring results total_benar: Mapped[int] = mapped_column( Integer, nullable=False, default=0, comment="Total correct answers" ) total_bobot_earned: Mapped[float] = mapped_column( Float, nullable=False, default=0.0, comment="Total weight earned" ) NM: Mapped[Union[int, None]] = mapped_column( name='NM', quote=True, nullable=True, comment="Nilai Mentah (raw score) [0, 1000]", ) NN: Mapped[Union[int, None]] = mapped_column( name='NN', quote=True, nullable=True, comment="Nilai Nasional (normalized score) [0, 1000]", ) # IRT scoring results theta: Mapped[Union[float, None]] = mapped_column( Float, nullable=True, comment="IRT ability estimate [-3, +3]", ) theta_se: Mapped[Union[float, None]] = mapped_column( Float, nullable=True, comment="IRT standard error", ) # Normalization metadata rataan_used: Mapped[Union[float, None]] = mapped_column( Float, nullable=True, comment="Mean value used for normalization", ) sb_used: Mapped[Union[float, None]] = mapped_column( Float, nullable=True, comment="Standard deviation used for normalization", ) # Timestamps created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default="NOW()" ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default="NOW()", onupdate="NOW()", ) # Relationships user: Mapped["User"] = relationship( "User", back_populates="sessions", lazy="selectin" ) tryout: Mapped["Tryout"] = relationship( "Tryout", back_populates="sessions", lazy="selectin" ) user_answers: Mapped[list["UserAnswer"]] = relationship( "UserAnswer", back_populates="session", lazy="selectin", cascade="all, delete-orphan" ) # Constraints and indexes __table_args__ = ( Index("ix_sessions_wp_user_id", "wp_user_id"), Index("ix_sessions_website_id", "website_id"), Index("ix_sessions_tryout_id", "tryout_id"), Index("ix_sessions_is_completed", "is_completed"), # Score constraints [0, 1000] - quote column names to match quoted identifiers CheckConstraint( '"NM" IS NULL OR ("NM" >= 0 AND "NM" <= 1000)', "ck_nm_range", ), CheckConstraint( '"NN" IS NULL OR ("NN" >= 0 AND "NN" <= 1000)', "ck_nn_range", ), # IRT theta constraint [-3, +3] CheckConstraint( "theta IS NULL OR (theta >= -3 AND theta <= 3)", "ck_theta_range", ), # Total correct must be non-negative CheckConstraint("total_benar >= 0", "ck_total_benar_non_negative"), # Total bobot must be non-negative CheckConstraint("total_bobot_earned >= 0", "ck_total_bobot_non_negative"), ) def __repr__(self) -> str: return f""