first commit
This commit is contained in:
25
app/models/__init__.py
Normal file
25
app/models/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Database models for IRT Bank Soal system.
|
||||
|
||||
Exports all SQLAlchemy ORM models for use in the application.
|
||||
"""
|
||||
|
||||
from app.database import Base
|
||||
from app.models.item import Item
|
||||
from app.models.session import Session
|
||||
from app.models.tryout import Tryout
|
||||
from app.models.tryout_stats import TryoutStats
|
||||
from app.models.user import User
|
||||
from app.models.user_answer import UserAnswer
|
||||
from app.models.website import Website
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"User",
|
||||
"Website",
|
||||
"Tryout",
|
||||
"Item",
|
||||
"Session",
|
||||
"UserAnswer",
|
||||
"TryoutStats",
|
||||
]
|
||||
222
app/models/item.py
Normal file
222
app/models/item.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Item model for questions with CTT and IRT parameters.
|
||||
|
||||
Represents individual questions with both classical test theory (CTT)
|
||||
and item response theory (IRT) parameters.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Union
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Item(Base):
|
||||
"""
|
||||
Item model representing individual questions.
|
||||
|
||||
Supports both CTT (p, bobot, category) and IRT (b, se) parameters.
|
||||
Tracks AI generation metadata and calibration status.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
tryout_id: Tryout identifier
|
||||
website_id: Website identifier
|
||||
slot: Question position in tryout
|
||||
level: Difficulty level (mudah, sedang, sulit)
|
||||
stem: Question text
|
||||
options: JSON array of answer options
|
||||
correct_answer: Correct option (A, B, C, D)
|
||||
explanation: Answer explanation
|
||||
ctt_p: CTT difficulty (proportion correct)
|
||||
ctt_bobot: CTT weight (1 - p)
|
||||
ctt_category: CTT difficulty category
|
||||
irt_b: IRT difficulty parameter [-3, +3]
|
||||
irt_se: IRT standard error
|
||||
calibrated: Calibration status
|
||||
calibration_sample_size: Sample size for calibration
|
||||
generated_by: Generation source (manual, ai)
|
||||
ai_model: AI model used (if generated by AI)
|
||||
basis_item_id: Original item ID (for AI variants)
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
tryout: Tryout relationship
|
||||
user_answers: User responses to this item
|
||||
"""
|
||||
|
||||
__tablename__ = "items"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Foreign keys
|
||||
tryout_id: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, index=True, comment="Tryout identifier"
|
||||
)
|
||||
website_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Website identifier",
|
||||
)
|
||||
|
||||
# Position and difficulty
|
||||
slot: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, comment="Question position in tryout"
|
||||
)
|
||||
level: Mapped[Literal["mudah", "sedang", "sulit"]] = mapped_column(
|
||||
String(50), nullable=False, comment="Difficulty level"
|
||||
)
|
||||
|
||||
# Question content
|
||||
stem: Mapped[str] = mapped_column(Text, nullable=False, comment="Question text")
|
||||
options: Mapped[dict] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
comment="JSON object with options (e.g., {\"A\": \"option1\", \"B\": \"option2\"})",
|
||||
)
|
||||
correct_answer: Mapped[str] = mapped_column(
|
||||
String(10), nullable=False, comment="Correct option (A, B, C, D)"
|
||||
)
|
||||
explanation: Mapped[Union[str, None]] = mapped_column(
|
||||
Text, nullable=True, comment="Answer explanation"
|
||||
)
|
||||
|
||||
# CTT parameters
|
||||
ctt_p: Mapped[Union[float, None]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
comment="CTT difficulty (proportion correct)",
|
||||
)
|
||||
ctt_bobot: Mapped[Union[float, None]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
comment="CTT weight (1 - p)",
|
||||
)
|
||||
ctt_category: Mapped[Union[Literal["mudah", "sedang", "sulit"], None]] = mapped_column(
|
||||
String(50),
|
||||
nullable=True,
|
||||
comment="CTT difficulty category",
|
||||
)
|
||||
|
||||
# IRT parameters (1PL Rasch model)
|
||||
irt_b: Mapped[Union[float, None]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
comment="IRT difficulty parameter [-3, +3]",
|
||||
)
|
||||
irt_se: Mapped[Union[float, None]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
comment="IRT standard error",
|
||||
)
|
||||
|
||||
# Calibration status
|
||||
calibrated: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, comment="Calibration status"
|
||||
)
|
||||
calibration_sample_size: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="Sample size for calibration",
|
||||
)
|
||||
|
||||
# AI generation metadata
|
||||
generated_by: Mapped[Literal["manual", "ai"]] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="manual",
|
||||
comment="Generation source",
|
||||
)
|
||||
ai_model: Mapped[Union[str, None]] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
comment="AI model used (if generated by AI)",
|
||||
)
|
||||
basis_item_id: Mapped[Union[int, None]] = mapped_column(
|
||||
ForeignKey("items.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Original item ID (for AI variants)",
|
||||
)
|
||||
|
||||
# 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
|
||||
tryout: Mapped["Tryout"] = relationship(
|
||||
"Tryout", back_populates="items", lazy="selectin"
|
||||
)
|
||||
user_answers: Mapped[list["UserAnswer"]] = relationship(
|
||||
"UserAnswer", back_populates="item", lazy="selectin", cascade="all, delete-orphan"
|
||||
)
|
||||
basis_item: Mapped[Union["Item", None]] = relationship(
|
||||
"Item",
|
||||
remote_side=[id],
|
||||
back_populates="variants",
|
||||
lazy="selectin",
|
||||
single_parent=True,
|
||||
)
|
||||
variants: Mapped[list["Item"]] = relationship(
|
||||
"Item",
|
||||
back_populates="basis_item",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# Constraints and indexes
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_items_tryout_id_website_id_slot",
|
||||
"tryout_id",
|
||||
"website_id",
|
||||
"slot",
|
||||
"level",
|
||||
unique=True,
|
||||
),
|
||||
Index("ix_items_calibrated", "calibrated"),
|
||||
Index("ix_items_basis_item_id", "basis_item_id"),
|
||||
# IRT b parameter constraint [-3, +3]
|
||||
CheckConstraint(
|
||||
"irt_b IS NULL OR (irt_b >= -3 AND irt_b <= 3)",
|
||||
"ck_irt_b_range",
|
||||
),
|
||||
# CTT p constraint [0, 1]
|
||||
CheckConstraint(
|
||||
"ctt_p IS NULL OR (ctt_p >= 0 AND ctt_p <= 1)",
|
||||
"ck_ctt_p_range",
|
||||
),
|
||||
# CTT bobot constraint [0, 1]
|
||||
CheckConstraint(
|
||||
"ctt_bobot IS NULL OR (ctt_bobot >= 0 AND ctt_bobot <= 1)",
|
||||
"ck_ctt_bobot_range",
|
||||
),
|
||||
# Slot must be positive
|
||||
CheckConstraint("slot > 0", "ck_slot_positive"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Item(id={self.id}, slot={self.slot}, level={self.level})>"
|
||||
193
app/models/session.py
Normal file
193
app/models/session.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
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, index=True, comment="WordPress user ID"
|
||||
)
|
||||
website_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Website identifier",
|
||||
)
|
||||
tryout_id: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, index=True, 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(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Nilai Mentah (raw score) [0, 1000]",
|
||||
)
|
||||
NN: Mapped[Union[int, None]] = mapped_column(
|
||||
Integer,
|
||||
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]
|
||||
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"<Session(session_id={self.session_id}, tryout_id={self.tryout_id})>"
|
||||
184
app/models/tryout.py
Normal file
184
app/models/tryout.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
Tryout model with configuration for assessment sessions.
|
||||
|
||||
Represents tryout exams with configurable scoring, selection, and normalization modes.
|
||||
"""
|
||||
|
||||
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 Tryout(Base):
|
||||
"""
|
||||
Tryout model with configuration for assessment sessions.
|
||||
|
||||
Supports multiple scoring modes (CTT, IRT, hybrid), selection strategies
|
||||
(fixed, adaptive, hybrid), and normalization modes (static, dynamic, hybrid).
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier (unique per website)
|
||||
name: Tryout name
|
||||
description: Tryout description
|
||||
scoring_mode: Scoring algorithm (ctt, irt, hybrid)
|
||||
selection_mode: Item selection strategy (fixed, adaptive, hybrid)
|
||||
normalization_mode: Normalization method (static, dynamic, hybrid)
|
||||
min_sample_for_dynamic: Minimum sample size for dynamic normalization
|
||||
static_rataan: Static mean value for manual normalization
|
||||
static_sb: Static standard deviation for manual normalization
|
||||
AI_generation_enabled: Enable/disable AI question generation
|
||||
hybrid_transition_slot: Slot number to transition from fixed to adaptive
|
||||
min_calibration_sample: Minimum responses needed for IRT calibration
|
||||
theta_estimation_method: Method for estimating theta (mle, map, eap)
|
||||
fallback_to_ctt_on_error: Fallback to CTT if IRT fails
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
website: Website relationship
|
||||
items: Items in this tryout
|
||||
sessions: Sessions for this tryout
|
||||
stats: Tryout statistics
|
||||
"""
|
||||
|
||||
__tablename__ = "tryouts"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Foreign keys
|
||||
website_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Website identifier",
|
||||
)
|
||||
|
||||
# Tryout identifier (unique per website)
|
||||
tryout_id: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Tryout identifier (unique per website)",
|
||||
)
|
||||
|
||||
# Basic information
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, comment="Tryout name"
|
||||
)
|
||||
description: Mapped[Union[str, None]] = mapped_column(
|
||||
String(1000), nullable=True, comment="Tryout description"
|
||||
)
|
||||
|
||||
# Scoring mode: ctt (Classical Test Theory), irt (Item Response Theory), hybrid
|
||||
scoring_mode: Mapped[Literal["ctt", "irt", "hybrid"]] = mapped_column(
|
||||
String(50), nullable=False, default="ctt", comment="Scoring mode"
|
||||
)
|
||||
|
||||
# Selection mode: fixed (slot order), adaptive (CAT), hybrid (mixed)
|
||||
selection_mode: Mapped[Literal["fixed", "adaptive", "hybrid"]] = mapped_column(
|
||||
String(50), nullable=False, default="fixed", comment="Item selection mode"
|
||||
)
|
||||
|
||||
# Normalization mode: static (hardcoded), dynamic (real-time), hybrid
|
||||
normalization_mode: Mapped[Literal["static", "dynamic", "hybrid"]] = mapped_column(
|
||||
String(50), nullable=False, default="static", comment="Normalization mode"
|
||||
)
|
||||
|
||||
# Normalization settings
|
||||
min_sample_for_dynamic: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=100,
|
||||
comment="Minimum sample size for dynamic normalization",
|
||||
)
|
||||
static_rataan: Mapped[float] = mapped_column(
|
||||
Float,
|
||||
nullable=False,
|
||||
default=500.0,
|
||||
comment="Static mean value for manual normalization",
|
||||
)
|
||||
static_sb: Mapped[float] = mapped_column(
|
||||
Float,
|
||||
nullable=False,
|
||||
default=100.0,
|
||||
comment="Static standard deviation for manual normalization",
|
||||
)
|
||||
|
||||
# AI generation settings
|
||||
ai_generation_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment="Enable/disable AI question generation",
|
||||
)
|
||||
|
||||
# Hybrid mode settings
|
||||
hybrid_transition_slot: Mapped[Union[int, None]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Slot number to transition from fixed to adaptive (hybrid mode)",
|
||||
)
|
||||
|
||||
# IRT settings
|
||||
min_calibration_sample: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=100,
|
||||
comment="Minimum responses needed for IRT calibration",
|
||||
)
|
||||
theta_estimation_method: Mapped[Literal["mle", "map", "eap"]] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="mle",
|
||||
comment="Method for estimating theta",
|
||||
)
|
||||
fallback_to_ctt_on_error: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
comment="Fallback to CTT if IRT fails",
|
||||
)
|
||||
|
||||
# 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
|
||||
website: Mapped["Website"] = relationship(
|
||||
"Website", back_populates="tryouts", lazy="selectin"
|
||||
)
|
||||
items: Mapped[list["Item"]] = relationship(
|
||||
"Item", back_populates="tryout", lazy="selectin", cascade="all, delete-orphan"
|
||||
)
|
||||
sessions: Mapped[list["Session"]] = relationship(
|
||||
"Session", back_populates="tryout", lazy="selectin", cascade="all, delete-orphan"
|
||||
)
|
||||
stats: Mapped["TryoutStats"] = relationship(
|
||||
"TryoutStats", back_populates="tryout", lazy="selectin", uselist=False
|
||||
)
|
||||
|
||||
# Constraints and indexes
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_tryouts_website_id_tryout_id", "website_id", "tryout_id", unique=True
|
||||
),
|
||||
CheckConstraint("min_sample_for_dynamic > 0", "ck_min_sample_positive"),
|
||||
CheckConstraint("static_rataan > 0", "ck_static_rataan_positive"),
|
||||
CheckConstraint("static_sb > 0", "ck_static_sb_positive"),
|
||||
CheckConstraint("min_calibration_sample > 0", "ck_min_calibration_positive"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Tryout(id={self.id}, tryout_id={self.tryout_id}, website_id={self.website_id})>"
|
||||
151
app/models/tryout_stats.py
Normal file
151
app/models/tryout_stats.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
TryoutStats model for tracking tryout-level statistics.
|
||||
|
||||
Maintains running statistics for dynamic normalization and reporting.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Union
|
||||
|
||||
from sqlalchemy import CheckConstraint, DateTime, Float, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class TryoutStats(Base):
|
||||
"""
|
||||
TryoutStats model for maintaining tryout-level statistics.
|
||||
|
||||
Tracks participant counts, score distributions, and calculated
|
||||
normalization parameters (rataan, sb) for dynamic normalization.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
participant_count: Number of completed sessions
|
||||
total_nm_sum: Running sum of NM scores
|
||||
total_nm_sq_sum: Running sum of squared NM scores (for variance calc)
|
||||
rataan: Calculated mean of NM scores
|
||||
sb: Calculated standard deviation of NM scores
|
||||
min_nm: Minimum NM score observed
|
||||
max_nm: Maximum NM score observed
|
||||
last_calculated: Timestamp of last statistics update
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
tryout: Tryout relationship
|
||||
"""
|
||||
|
||||
__tablename__ = "tryout_stats"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Foreign keys
|
||||
website_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Website identifier",
|
||||
)
|
||||
tryout_id: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Tryout identifier",
|
||||
)
|
||||
|
||||
# Running statistics
|
||||
participant_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="Number of completed sessions",
|
||||
)
|
||||
total_nm_sum: Mapped[float] = mapped_column(
|
||||
Float,
|
||||
nullable=False,
|
||||
default=0.0,
|
||||
comment="Running sum of NM scores",
|
||||
)
|
||||
total_nm_sq_sum: Mapped[float] = mapped_column(
|
||||
Float,
|
||||
nullable=False,
|
||||
default=0.0,
|
||||
comment="Running sum of squared NM scores",
|
||||
)
|
||||
|
||||
# Calculated statistics
|
||||
rataan: Mapped[Union[float, None]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
comment="Calculated mean of NM scores",
|
||||
)
|
||||
sb: Mapped[Union[float, None]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
comment="Calculated standard deviation of NM scores",
|
||||
)
|
||||
|
||||
# Score range
|
||||
min_nm: Mapped[Union[int, None]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Minimum NM score observed",
|
||||
)
|
||||
max_nm: Mapped[Union[int, None]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Maximum NM score observed",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
last_calculated: Mapped[Union[datetime, None]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Timestamp of last statistics update",
|
||||
)
|
||||
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
|
||||
tryout: Mapped["Tryout"] = relationship(
|
||||
"Tryout", back_populates="stats", lazy="selectin"
|
||||
)
|
||||
|
||||
# Constraints and indexes
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_tryout_stats_website_id_tryout_id",
|
||||
"website_id",
|
||||
"tryout_id",
|
||||
unique=True,
|
||||
),
|
||||
# Participant count must be non-negative
|
||||
CheckConstraint("participant_count >= 0", "ck_participant_count_non_negative"),
|
||||
# Min and max NM must be within valid range [0, 1000]
|
||||
CheckConstraint(
|
||||
"min_nm IS NULL OR (min_nm >= 0 AND min_nm <= 1000)",
|
||||
"ck_min_nm_range",
|
||||
),
|
||||
CheckConstraint(
|
||||
"max_nm IS NULL OR (max_nm >= 0 AND max_nm <= 1000)",
|
||||
"ck_max_nm_range",
|
||||
),
|
||||
# Min must be less than or equal to max
|
||||
CheckConstraint(
|
||||
"min_nm IS NULL OR max_nm IS NULL OR min_nm <= max_nm",
|
||||
"ck_min_max_nm_order",
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TryoutStats(tryout_id={self.tryout_id}, participant_count={self.participant_count})>"
|
||||
72
app/models/user.py
Normal file
72
app/models/user.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
User model for WordPress user integration.
|
||||
|
||||
Represents users from WordPress that can take tryouts.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""
|
||||
User model representing WordPress users.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
wp_user_id: WordPress user ID (unique per site)
|
||||
website_id: Website identifier (for multi-site support)
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
sessions: User's tryout sessions
|
||||
"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# WordPress user ID (unique within website context)
|
||||
wp_user_id: Mapped[int] = mapped_column(
|
||||
String(255), nullable=False, index=True, comment="WordPress user ID"
|
||||
)
|
||||
|
||||
# Website identifier (for multi-site support)
|
||||
website_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Website identifier",
|
||||
)
|
||||
|
||||
# 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
|
||||
website: Mapped["Website"] = relationship(
|
||||
"Website", back_populates="users", lazy="selectin"
|
||||
)
|
||||
sessions: Mapped[list["Session"]] = relationship(
|
||||
"Session", back_populates="user", lazy="selectin", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index("ix_users_wp_user_id_website_id", "wp_user_id", "website_id", unique=True),
|
||||
Index("ix_users_website_id", "website_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User(wp_user_id={self.wp_user_id}, website_id={self.website_id})>"
|
||||
137
app/models/user_answer.py
Normal file
137
app/models/user_answer.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
UserAnswer model for tracking individual question responses.
|
||||
|
||||
Represents a student's response to a single question with scoring metadata.
|
||||
"""
|
||||
|
||||
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 UserAnswer(Base):
|
||||
"""
|
||||
UserAnswer model representing a student's response to a question.
|
||||
|
||||
Tracks response, correctness, scoring, and timing information.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
session_id: Session identifier
|
||||
wp_user_id: WordPress user ID
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
item_id: Item identifier
|
||||
response: User's answer (A, B, C, D)
|
||||
is_correct: Whether answer is correct
|
||||
time_spent: Time spent on this question (seconds)
|
||||
scoring_mode_used: Scoring mode used
|
||||
bobot_earned: Weight earned for this answer
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
session: Session relationship
|
||||
item: Item relationship
|
||||
"""
|
||||
|
||||
__tablename__ = "user_answers"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Foreign keys
|
||||
session_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("sessions.session_id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Session identifier",
|
||||
)
|
||||
wp_user_id: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, index=True, comment="WordPress user ID"
|
||||
)
|
||||
website_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Website identifier",
|
||||
)
|
||||
tryout_id: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, index=True, comment="Tryout identifier"
|
||||
)
|
||||
item_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("items.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Item identifier",
|
||||
)
|
||||
|
||||
# Response information
|
||||
response: Mapped[str] = mapped_column(
|
||||
String(10), nullable=False, comment="User's answer (A, B, C, D)"
|
||||
)
|
||||
is_correct: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, comment="Whether answer is correct"
|
||||
)
|
||||
time_spent: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="Time spent on this question (seconds)",
|
||||
)
|
||||
|
||||
# Scoring metadata
|
||||
scoring_mode_used: Mapped[Literal["ctt", "irt", "hybrid"]] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
comment="Scoring mode used",
|
||||
)
|
||||
bobot_earned: Mapped[float] = mapped_column(
|
||||
Float,
|
||||
nullable=False,
|
||||
default=0.0,
|
||||
comment="Weight earned for this answer",
|
||||
)
|
||||
|
||||
# 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
|
||||
session: Mapped["Session"] = relationship(
|
||||
"Session", back_populates="user_answers", lazy="selectin"
|
||||
)
|
||||
item: Mapped["Item"] = relationship(
|
||||
"Item", back_populates="user_answers", lazy="selectin"
|
||||
)
|
||||
|
||||
# Constraints and indexes
|
||||
__table_args__ = (
|
||||
Index("ix_user_answers_session_id", "session_id"),
|
||||
Index("ix_user_answers_wp_user_id", "wp_user_id"),
|
||||
Index("ix_user_answers_website_id", "website_id"),
|
||||
Index("ix_user_answers_tryout_id", "tryout_id"),
|
||||
Index("ix_user_answers_item_id", "item_id"),
|
||||
Index(
|
||||
"ix_user_answers_session_id_item_id",
|
||||
"session_id",
|
||||
"item_id",
|
||||
unique=True,
|
||||
),
|
||||
# Time spent must be non-negative
|
||||
CheckConstraint("time_spent >= 0", "ck_time_spent_non_negative"),
|
||||
# Bobot earned must be non-negative
|
||||
CheckConstraint("bobot_earned >= 0", "ck_bobot_earned_non_negative"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<UserAnswer(id={self.id}, session_id={self.session_id}, item_id={self.item_id})>"
|
||||
69
app/models/website.py
Normal file
69
app/models/website.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Website model for multi-site support.
|
||||
|
||||
Represents WordPress websites that use the IRT Bank Soal system.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Website(Base):
|
||||
"""
|
||||
Website model representing WordPress sites.
|
||||
|
||||
Enables multi-site support where a single backend serves multiple
|
||||
WordPress-powered educational sites.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
site_url: WordPress site URL
|
||||
site_name: Human-readable site name
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
users: Users belonging to this website
|
||||
tryouts: Tryouts available on this website
|
||||
"""
|
||||
|
||||
__tablename__ = "websites"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Site information
|
||||
site_url: Mapped[str] = mapped_column(
|
||||
String(512),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
index=True,
|
||||
comment="WordPress site URL",
|
||||
)
|
||||
site_name: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, comment="Human-readable site name"
|
||||
)
|
||||
|
||||
# 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
|
||||
users: Mapped[list["User"]] = relationship(
|
||||
"User", back_populates="website", lazy="selectin", cascade="all, delete-orphan"
|
||||
)
|
||||
tryouts: Mapped[list["Tryout"]] = relationship(
|
||||
"Tryout", back_populates="website", lazy="selectin", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Website(id={self.id}, site_url={self.site_url})>"
|
||||
Reference in New Issue
Block a user