Checkpoint React frontend migration
This commit is contained in:
33
backend/app/models/__init__.py
Normal file
33
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
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.ai_generation_run import AIGenerationRun
|
||||
from app.models.item import Item
|
||||
from app.models.report_schedule import ReportScheduleModel
|
||||
from app.models.session import Session
|
||||
from app.models.tryout import Tryout
|
||||
from app.models.tryout_import_snapshot import TryoutImportSnapshot
|
||||
from app.models.tryout_snapshot_question import TryoutSnapshotQuestion
|
||||
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",
|
||||
"AIGenerationRun",
|
||||
"User",
|
||||
"Website",
|
||||
"Tryout",
|
||||
"TryoutImportSnapshot",
|
||||
"TryoutSnapshotQuestion",
|
||||
"Item",
|
||||
"ReportScheduleModel",
|
||||
"Session",
|
||||
"UserAnswer",
|
||||
"TryoutStats",
|
||||
]
|
||||
74
backend/app/models/ai_generation_run.py
Normal file
74
backend/app/models/ai_generation_run.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
AI generation run model.
|
||||
|
||||
Represents one admin generation request that can produce one or many variants.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AIGenerationRun(Base):
|
||||
__tablename__ = "ai_generation_runs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
basis_item_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("items.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Basis item ID",
|
||||
)
|
||||
source_snapshot_question_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("tryout_snapshot_questions.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Source snapshot question ID",
|
||||
)
|
||||
target_level: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
comment="Target level (mudah/sulit)",
|
||||
)
|
||||
requested_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=1,
|
||||
comment="Requested output count",
|
||||
)
|
||||
model: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
comment="Model identifier",
|
||||
)
|
||||
prompt_version: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="v1",
|
||||
comment="Prompt template version",
|
||||
)
|
||||
operator_notes: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="Optional admin notes",
|
||||
)
|
||||
created_by: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
comment="Admin username",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
generated_items: Mapped[list["Item"]] = relationship(
|
||||
"Item",
|
||||
back_populates="generation_run",
|
||||
primaryjoin="AIGenerationRun.id == Item.generation_run_id",
|
||||
foreign_keys="Item.generation_run_id",
|
||||
lazy="selectin",
|
||||
)
|
||||
270
backend/app/models/item.py
Normal file
270
backend/app/models/item.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
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,
|
||||
ForeignKeyConstraint,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
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,
|
||||
comment="Original item ID (for AI variants)",
|
||||
)
|
||||
generation_run_id: Mapped[Union[int, None]] = mapped_column(
|
||||
ForeignKey("ai_generation_runs.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="AI generation run ID",
|
||||
)
|
||||
source_snapshot_question_id: Mapped[Union[int, None]] = mapped_column(
|
||||
ForeignKey("tryout_snapshot_questions.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Source snapshot question ID",
|
||||
)
|
||||
variant_status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="active",
|
||||
comment="Lifecycle status (active/draft/approved/rejected/archived/stale)",
|
||||
)
|
||||
reviewed_by: Mapped[Union[str, None]] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
comment="Reviewer username",
|
||||
)
|
||||
reviewed_at: Mapped[Union[datetime, None]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Review timestamp",
|
||||
)
|
||||
review_notes: Mapped[Union[str, None]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="Review notes",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.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",
|
||||
)
|
||||
generation_run: Mapped[Union["AIGenerationRun", None]] = relationship(
|
||||
"AIGenerationRun",
|
||||
back_populates="generated_items",
|
||||
foreign_keys=[generation_run_id],
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
# Constraints and indexes
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(
|
||||
["website_id", "tryout_id"],
|
||||
["tryouts.website_id", "tryouts.tryout_id"],
|
||||
name="fk_items_tryout",
|
||||
ondelete="CASCADE",
|
||||
onupdate="CASCADE",
|
||||
),
|
||||
Index(
|
||||
"ix_items_tryout_id_website_id_slot",
|
||||
"tryout_id",
|
||||
"website_id",
|
||||
"slot",
|
||||
"level",
|
||||
unique=False,
|
||||
),
|
||||
Index("ix_items_calibrated", "calibrated"),
|
||||
Index("ix_items_basis_item_id", "basis_item_id"),
|
||||
Index("ix_items_variant_status", "variant_status"),
|
||||
# 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})>"
|
||||
46
backend/app/models/report_schedule.py
Normal file
46
backend/app/models/report_schedule.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Persistent report schedule model.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, JSON, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ReportScheduleModel(Base):
|
||||
"""Database-backed report schedule configuration."""
|
||||
|
||||
__tablename__ = "report_schedules"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
schedule_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
index=True,
|
||||
comment="Public schedule identifier",
|
||||
)
|
||||
report_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
schedule: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
tryout_ids: Mapped[list[str]] = mapped_column(JSON, nullable=False)
|
||||
website_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
recipients: Mapped[list[str]] = mapped_column(JSON, nullable=False)
|
||||
format: Mapped[str] = mapped_column(String(10), nullable=False, default="xlsx")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
last_run: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
next_run: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_report_schedules_website_active", "website_id", "is_active"),
|
||||
)
|
||||
219
backend/app/models/session.py
Normal file
219
backend/app/models/session.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
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,
|
||||
ForeignKeyConstraint,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
func,
|
||||
)
|
||||
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=func.now()
|
||||
)
|
||||
end_time: Mapped[Union[datetime, None]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="Session end timestamp"
|
||||
)
|
||||
expires_at: Mapped[Union[datetime, None]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="Session expiration 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=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="sessions",
|
||||
lazy="selectin",
|
||||
overlaps="tryout,sessions",
|
||||
)
|
||||
tryout: Mapped["Tryout"] = relationship(
|
||||
"Tryout",
|
||||
back_populates="sessions",
|
||||
lazy="selectin",
|
||||
overlaps="user",
|
||||
)
|
||||
user_answers: Mapped[list["UserAnswer"]] = relationship(
|
||||
"UserAnswer", back_populates="session", lazy="selectin", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# Constraints and indexes
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(
|
||||
["website_id", "tryout_id"],
|
||||
["tryouts.website_id", "tryouts.tryout_id"],
|
||||
name="fk_sessions_tryout",
|
||||
ondelete="CASCADE",
|
||||
onupdate="CASCADE",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["wp_user_id", "website_id"],
|
||||
["users.wp_user_id", "users.website_id"],
|
||||
name="fk_sessions_user",
|
||||
ondelete="CASCADE",
|
||||
onupdate="CASCADE",
|
||||
),
|
||||
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"<Session(session_id={self.session_id}, tryout_id={self.tryout_id})>"
|
||||
200
backend/app/models/tryout.py
Normal file
200
backend/app/models/tryout.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
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,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
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=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.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",
|
||||
overlaps="user",
|
||||
)
|
||||
stats: Mapped["TryoutStats"] = relationship(
|
||||
"TryoutStats", back_populates="tryout", lazy="selectin", uselist=False
|
||||
)
|
||||
|
||||
# Constraints and indexes
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"website_id",
|
||||
"tryout_id",
|
||||
name="uq_tryouts_website_id_tryout_id",
|
||||
),
|
||||
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})>"
|
||||
103
backend/app/models/tryout_import_snapshot.py
Normal file
103
backend/app/models/tryout_import_snapshot.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Snapshot archive for imported external tryout payloads.
|
||||
|
||||
Stores each imported JSON export so the backend can trace source changes
|
||||
without treating the source file itself as the system of record.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class TryoutImportSnapshot(Base):
|
||||
__tablename__ = "tryout_import_snapshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
website_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Website identifier",
|
||||
)
|
||||
source_tryout_id: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="External source tryout identifier",
|
||||
)
|
||||
source_key: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
comment="External tryout object key in source payload",
|
||||
)
|
||||
title: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
comment="Imported tryout title",
|
||||
)
|
||||
source_permalink: Mapped[Optional[str]] = mapped_column(
|
||||
String(1024),
|
||||
nullable=True,
|
||||
comment="Imported source permalink",
|
||||
)
|
||||
source_status: Mapped[Optional[str]] = mapped_column(
|
||||
String(50),
|
||||
nullable=True,
|
||||
comment="Imported source status",
|
||||
)
|
||||
exported_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Timestamp from source export metadata",
|
||||
)
|
||||
source_created_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Source tryout created timestamp",
|
||||
)
|
||||
source_modified_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Source tryout modified timestamp",
|
||||
)
|
||||
exported_by: Mapped[Optional[str]] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
comment="Source exporter identity",
|
||||
)
|
||||
question_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="Number of questions in imported payload",
|
||||
)
|
||||
result_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="Number of result rows in imported payload",
|
||||
)
|
||||
payload_checksum: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
comment="Checksum for the imported payload",
|
||||
)
|
||||
raw_payload: Mapped[dict] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
comment="Original imported payload",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
139
backend/app/models/tryout_snapshot_question.py
Normal file
139
backend/app/models/tryout_snapshot_question.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Read-only normalized reference rows for imported tryout questions.
|
||||
|
||||
These rows reflect the latest imported source version of each question and are
|
||||
kept separate from operational items and AI-generated variants.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class TryoutSnapshotQuestion(Base):
|
||||
__tablename__ = "tryout_snapshot_questions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
website_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Website identifier",
|
||||
)
|
||||
source_tryout_id: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="External source tryout identifier",
|
||||
)
|
||||
source_question_id: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
comment="External source question identifier",
|
||||
)
|
||||
latest_snapshot_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("tryout_import_snapshots.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Latest snapshot containing this question",
|
||||
)
|
||||
question_title: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
nullable=False,
|
||||
comment="Imported title or short label",
|
||||
)
|
||||
question_html: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
nullable=False,
|
||||
comment="Imported question body HTML",
|
||||
)
|
||||
explanation_html: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="Imported explanation HTML",
|
||||
)
|
||||
raw_options: Mapped[list] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
comment="Raw source options payload",
|
||||
)
|
||||
correct_answer: Mapped[str] = mapped_column(
|
||||
String(10),
|
||||
nullable=False,
|
||||
comment="Imported correct answer key",
|
||||
)
|
||||
category_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Imported category id",
|
||||
)
|
||||
category_name: Mapped[Optional[str]] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
comment="Imported category name",
|
||||
)
|
||||
category_code: Mapped[Optional[str]] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
comment="Imported category code",
|
||||
)
|
||||
option_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="Count of source options",
|
||||
)
|
||||
has_option_labels: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment="Whether source options include visible labels",
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
comment="Whether question is still present in latest source import",
|
||||
)
|
||||
content_checksum: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
comment="Checksum of normalized question content",
|
||||
)
|
||||
raw_payload: Mapped[dict] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
comment="Original source question payload",
|
||||
)
|
||||
first_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"website_id",
|
||||
"source_tryout_id",
|
||||
"source_question_id",
|
||||
name="uq_snapshot_questions_website_tryout_question",
|
||||
),
|
||||
)
|
||||
168
backend/app/models/tryout_stats.py
Normal file
168
backend/app/models/tryout_stats.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
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,
|
||||
ForeignKeyConstraint,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
func,
|
||||
)
|
||||
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=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tryout: Mapped["Tryout"] = relationship(
|
||||
"Tryout", back_populates="stats", lazy="selectin"
|
||||
)
|
||||
|
||||
# Constraints and indexes
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(
|
||||
["website_id", "tryout_id"],
|
||||
["tryouts.website_id", "tryouts.tryout_id"],
|
||||
name="fk_tryout_stats_tryout",
|
||||
ondelete="CASCADE",
|
||||
onupdate="CASCADE",
|
||||
),
|
||||
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})>"
|
||||
79
backend/app/models/user.py
Normal file
79
backend/app/models/user.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
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, UniqueConstraint, func
|
||||
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[str] = 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,
|
||||
comment="Website identifier",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.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",
|
||||
overlaps="sessions,tryout",
|
||||
)
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"wp_user_id",
|
||||
"website_id",
|
||||
name="uq_users_wp_user_id_website_id",
|
||||
),
|
||||
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})>"
|
||||
134
backend/app/models/user_answer.py
Normal file
134
backend/app/models/user_answer.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
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, func
|
||||
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,
|
||||
comment="Session identifier",
|
||||
)
|
||||
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"
|
||||
)
|
||||
item_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("items.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
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=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.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
backend/app/models/website.py
Normal file
69
backend/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, func
|
||||
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=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.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