Checkpoint React frontend migration
This commit is contained in:
36
backend/.env.example
Normal file
36
backend/.env.example
Normal file
@@ -0,0 +1,36 @@
|
||||
# Database
|
||||
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/irt_bank_soal
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=irt_bank_soal
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=your_password_here
|
||||
|
||||
# FastAPI
|
||||
SECRET_KEY=your-secret-key-here-change-in-production
|
||||
API_V1_STR=/api/v1
|
||||
PROJECT_NAME=IRT Bank Soal
|
||||
ENVIRONMENT=development
|
||||
ENABLE_ADMIN=false
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me
|
||||
ADMIN_SESSION_EXPIRE_SECONDS=3600
|
||||
|
||||
# OpenRouter (AI Generation)
|
||||
OPENROUTER_API_KEY=your-openrouter-api-key-here
|
||||
OPENROUTER_MODEL_QWEN=qwen/qwen2.5-32b-instruct
|
||||
OPENROUTER_MODEL_CHEAP=mistralai/mistral-small-2603
|
||||
OPENROUTER_MODEL_LLAMA=meta-llama/llama-3.3-70b-instruct
|
||||
OPENROUTER_TIMEOUT=30
|
||||
|
||||
# WordPress Integration
|
||||
WORDPRESS_API_URL=https://your-wordpress-site.com/wp-json
|
||||
WORDPRESS_AUTH_TOKEN=your-wordpress-jwt-token
|
||||
|
||||
# Redis (Celery)
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
CELERY_BROKER_URL=redis://localhost:6379/0
|
||||
CELERY_RESULT_BACKEND=redis://localhost:6379/0
|
||||
|
||||
# CORS
|
||||
ALLOWED_ORIGINS=https://site1.com,https://site2.com,https://site3.com
|
||||
18
backend/Dockerfile
Normal file
18
backend/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Run migrations and start the app
|
||||
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
||||
147
backend/alembic.ini
Normal file
147
backend/alembic.ini
Normal file
@@ -0,0 +1,147 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
1
backend/alembic/README
Normal file
1
backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
99
backend/alembic/env.py
Normal file
99
backend/alembic/env.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Alembic environment configuration for async PostgreSQL migrations.
|
||||
|
||||
Configures Alembic to work with SQLAlchemy async engine and models.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Import models and Base
|
||||
sys.path.insert(0, ".")
|
||||
from app.database import Base
|
||||
from app.models import * # noqa: F401, F403
|
||||
|
||||
# Import settings for database URL
|
||||
from app.core.config import get_settings
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Get settings and set database URL
|
||||
settings = get_settings()
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
"""
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
28
backend/alembic/script.py.mako
Normal file
28
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
280
backend/alembic/versions/20260331_000001_initial_schema.py
Normal file
280
backend/alembic/versions/20260331_000001_initial_schema.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 20260331_000001
|
||||
Revises:
|
||||
Create Date: 2026-03-31 12:30:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import context, op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "20260331_000001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _table_exists(name: str) -> bool:
|
||||
if context.is_offline_mode():
|
||||
return False
|
||||
return sa.inspect(op.get_bind()).has_table(name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _table_exists("websites"):
|
||||
op.create_table(
|
||||
"websites",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("site_url", sa.String(length=512), nullable=False),
|
||||
sa.Column("site_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_websites_site_url", "websites", ["site_url"], unique=True)
|
||||
|
||||
if not _table_exists("tryouts"):
|
||||
op.create_table(
|
||||
"tryouts",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("website_id", sa.Integer(), nullable=False),
|
||||
sa.Column("tryout_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.String(length=1000), nullable=True),
|
||||
sa.Column("scoring_mode", sa.String(length=50), nullable=False),
|
||||
sa.Column("selection_mode", sa.String(length=50), nullable=False),
|
||||
sa.Column("normalization_mode", sa.String(length=50), nullable=False),
|
||||
sa.Column("min_sample_for_dynamic", sa.Integer(), nullable=False),
|
||||
sa.Column("static_rataan", sa.Float(), nullable=False),
|
||||
sa.Column("static_sb", sa.Float(), nullable=False),
|
||||
sa.Column("ai_generation_enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("hybrid_transition_slot", sa.Integer(), nullable=True),
|
||||
sa.Column("min_calibration_sample", sa.Integer(), nullable=False),
|
||||
sa.Column("theta_estimation_method", sa.String(length=50), nullable=False),
|
||||
sa.Column("fallback_to_ctt_on_error", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["website_id"], ["websites.id"], ondelete="CASCADE", onupdate="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("website_id", "tryout_id", name="uq_tryouts_website_id_tryout_id"),
|
||||
sa.CheckConstraint("min_sample_for_dynamic > 0", name="ck_min_sample_positive"),
|
||||
sa.CheckConstraint("static_rataan > 0", name="ck_static_rataan_positive"),
|
||||
sa.CheckConstraint("static_sb > 0", name="ck_static_sb_positive"),
|
||||
sa.CheckConstraint("min_calibration_sample > 0", name="ck_min_calibration_positive"),
|
||||
)
|
||||
op.create_index("ix_tryouts_website_id", "tryouts", ["website_id"], unique=False)
|
||||
op.create_index("ix_tryouts_tryout_id", "tryouts", ["tryout_id"], unique=False)
|
||||
|
||||
if not _table_exists("users"):
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("wp_user_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("website_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["website_id"], ["websites.id"], ondelete="CASCADE", onupdate="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("wp_user_id", "website_id", name="uq_users_wp_user_id_website_id"),
|
||||
)
|
||||
op.create_index("ix_users_wp_user_id", "users", ["wp_user_id"], unique=False)
|
||||
op.create_index("ix_users_website_id", "users", ["website_id"], unique=False)
|
||||
|
||||
if not _table_exists("items"):
|
||||
op.create_table(
|
||||
"items",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("tryout_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("website_id", sa.Integer(), nullable=False),
|
||||
sa.Column("slot", sa.Integer(), nullable=False),
|
||||
sa.Column("level", sa.String(length=50), nullable=False),
|
||||
sa.Column("stem", sa.Text(), nullable=False),
|
||||
sa.Column("options", sa.JSON(), nullable=False),
|
||||
sa.Column("correct_answer", sa.String(length=10), nullable=False),
|
||||
sa.Column("explanation", sa.Text(), nullable=True),
|
||||
sa.Column("ctt_p", sa.Float(), nullable=True),
|
||||
sa.Column("ctt_bobot", sa.Float(), nullable=True),
|
||||
sa.Column("ctt_category", sa.String(length=50), nullable=True),
|
||||
sa.Column("irt_b", sa.Float(), nullable=True),
|
||||
sa.Column("irt_se", sa.Float(), nullable=True),
|
||||
sa.Column("calibrated", sa.Boolean(), nullable=False),
|
||||
sa.Column("calibration_sample_size", sa.Integer(), nullable=False),
|
||||
sa.Column("generated_by", sa.String(length=50), nullable=False),
|
||||
sa.Column("ai_model", sa.String(length=255), nullable=True),
|
||||
sa.Column("basis_item_id", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["website_id"], ["websites.id"], ondelete="CASCADE", onupdate="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["basis_item_id"], ["items.id"], ondelete="SET NULL", onupdate="CASCADE"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["website_id", "tryout_id"],
|
||||
["tryouts.website_id", "tryouts.tryout_id"],
|
||||
name="fk_items_tryout",
|
||||
ondelete="CASCADE",
|
||||
onupdate="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.CheckConstraint("irt_b IS NULL OR (irt_b >= -3 AND irt_b <= 3)", name="ck_irt_b_range"),
|
||||
sa.CheckConstraint("ctt_p IS NULL OR (ctt_p >= 0 AND ctt_p <= 1)", name="ck_ctt_p_range"),
|
||||
sa.CheckConstraint("ctt_bobot IS NULL OR (ctt_bobot >= 0 AND ctt_bobot <= 1)", name="ck_ctt_bobot_range"),
|
||||
sa.CheckConstraint("slot > 0", name="ck_slot_positive"),
|
||||
)
|
||||
op.create_index("ix_items_tryout_id", "items", ["tryout_id"], unique=False)
|
||||
op.create_index("ix_items_website_id", "items", ["website_id"], unique=False)
|
||||
op.create_index("ix_items_basis_item_id", "items", ["basis_item_id"], unique=False)
|
||||
op.create_index("ix_items_calibrated", "items", ["calibrated"], unique=False)
|
||||
op.create_index(
|
||||
"ix_items_tryout_id_website_id_slot",
|
||||
"items",
|
||||
["tryout_id", "website_id", "slot", "level"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
if not _table_exists("sessions"):
|
||||
op.create_table(
|
||||
"sessions",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("session_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("wp_user_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("website_id", sa.Integer(), nullable=False),
|
||||
sa.Column("tryout_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("start_time", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("end_time", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("is_completed", sa.Boolean(), nullable=False),
|
||||
sa.Column("scoring_mode_used", sa.String(length=50), nullable=False),
|
||||
sa.Column("total_benar", sa.Integer(), nullable=False),
|
||||
sa.Column("total_bobot_earned", sa.Float(), nullable=False),
|
||||
sa.Column("NM", sa.Integer(), quote=True, nullable=True),
|
||||
sa.Column("NN", sa.Integer(), quote=True, nullable=True),
|
||||
sa.Column("theta", sa.Float(), nullable=True),
|
||||
sa.Column("theta_se", sa.Float(), nullable=True),
|
||||
sa.Column("rataan_used", sa.Float(), nullable=True),
|
||||
sa.Column("sb_used", sa.Float(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["website_id"], ["websites.id"], ondelete="CASCADE", onupdate="CASCADE"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["website_id", "tryout_id"],
|
||||
["tryouts.website_id", "tryouts.tryout_id"],
|
||||
name="fk_sessions_tryout",
|
||||
ondelete="CASCADE",
|
||||
onupdate="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["wp_user_id", "website_id"],
|
||||
["users.wp_user_id", "users.website_id"],
|
||||
name="fk_sessions_user",
|
||||
ondelete="CASCADE",
|
||||
onupdate="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("session_id"),
|
||||
sa.CheckConstraint('"NM" IS NULL OR ("NM" >= 0 AND "NM" <= 1000)', name="ck_nm_range"),
|
||||
sa.CheckConstraint('"NN" IS NULL OR ("NN" >= 0 AND "NN" <= 1000)', name="ck_nn_range"),
|
||||
sa.CheckConstraint("theta IS NULL OR (theta >= -3 AND theta <= 3)", name="ck_theta_range"),
|
||||
sa.CheckConstraint("total_benar >= 0", name="ck_total_benar_non_negative"),
|
||||
sa.CheckConstraint("total_bobot_earned >= 0", name="ck_total_bobot_non_negative"),
|
||||
)
|
||||
op.create_index("ix_sessions_session_id", "sessions", ["session_id"], unique=True)
|
||||
op.create_index("ix_sessions_wp_user_id", "sessions", ["wp_user_id"], unique=False)
|
||||
op.create_index("ix_sessions_website_id", "sessions", ["website_id"], unique=False)
|
||||
op.create_index("ix_sessions_tryout_id", "sessions", ["tryout_id"], unique=False)
|
||||
op.create_index("ix_sessions_is_completed", "sessions", ["is_completed"], unique=False)
|
||||
|
||||
if not _table_exists("tryout_stats"):
|
||||
op.create_table(
|
||||
"tryout_stats",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("website_id", sa.Integer(), nullable=False),
|
||||
sa.Column("tryout_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("participant_count", sa.Integer(), nullable=False),
|
||||
sa.Column("total_nm_sum", sa.Float(), nullable=False),
|
||||
sa.Column("total_nm_sq_sum", sa.Float(), nullable=False),
|
||||
sa.Column("rataan", sa.Float(), nullable=True),
|
||||
sa.Column("sb", sa.Float(), nullable=True),
|
||||
sa.Column("min_nm", sa.Integer(), nullable=True),
|
||||
sa.Column("max_nm", sa.Integer(), nullable=True),
|
||||
sa.Column("last_calculated", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["website_id"], ["websites.id"], ondelete="CASCADE", onupdate="CASCADE"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["website_id", "tryout_id"],
|
||||
["tryouts.website_id", "tryouts.tryout_id"],
|
||||
name="fk_tryout_stats_tryout",
|
||||
ondelete="CASCADE",
|
||||
onupdate="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.CheckConstraint("participant_count >= 0", name="ck_participant_count_non_negative"),
|
||||
sa.CheckConstraint("min_nm IS NULL OR (min_nm >= 0 AND min_nm <= 1000)", name="ck_min_nm_range"),
|
||||
sa.CheckConstraint("max_nm IS NULL OR (max_nm >= 0 AND max_nm <= 1000)", name="ck_max_nm_range"),
|
||||
sa.CheckConstraint(
|
||||
"min_nm IS NULL OR max_nm IS NULL OR min_nm <= max_nm",
|
||||
name="ck_min_max_nm_order",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_tryout_stats_website_id", "tryout_stats", ["website_id"], unique=False)
|
||||
op.create_index("ix_tryout_stats_tryout_id", "tryout_stats", ["tryout_id"], unique=False)
|
||||
op.create_index(
|
||||
"ix_tryout_stats_website_id_tryout_id",
|
||||
"tryout_stats",
|
||||
["website_id", "tryout_id"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
if not _table_exists("user_answers"):
|
||||
op.create_table(
|
||||
"user_answers",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("session_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("wp_user_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("website_id", sa.Integer(), nullable=False),
|
||||
sa.Column("tryout_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("item_id", sa.Integer(), nullable=False),
|
||||
sa.Column("response", sa.String(length=10), nullable=False),
|
||||
sa.Column("is_correct", sa.Boolean(), nullable=False),
|
||||
sa.Column("time_spent", sa.Integer(), nullable=False),
|
||||
sa.Column("scoring_mode_used", sa.String(length=50), nullable=False),
|
||||
sa.Column("bobot_earned", sa.Float(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["session_id"], ["sessions.session_id"], ondelete="CASCADE", onupdate="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["website_id"], ["websites.id"], ondelete="CASCADE", onupdate="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["item_id"], ["items.id"], ondelete="CASCADE", onupdate="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.CheckConstraint("time_spent >= 0", name="ck_time_spent_non_negative"),
|
||||
sa.CheckConstraint("bobot_earned >= 0", name="ck_bobot_earned_non_negative"),
|
||||
)
|
||||
op.create_index("ix_user_answers_session_id", "user_answers", ["session_id"], unique=False)
|
||||
op.create_index("ix_user_answers_wp_user_id", "user_answers", ["wp_user_id"], unique=False)
|
||||
op.create_index("ix_user_answers_website_id", "user_answers", ["website_id"], unique=False)
|
||||
op.create_index("ix_user_answers_tryout_id", "user_answers", ["tryout_id"], unique=False)
|
||||
op.create_index("ix_user_answers_item_id", "user_answers", ["item_id"], unique=False)
|
||||
op.create_index(
|
||||
"ix_user_answers_session_id_item_id",
|
||||
"user_answers",
|
||||
["session_id", "item_id"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _table_exists("user_answers"):
|
||||
op.drop_table("user_answers")
|
||||
if _table_exists("tryout_stats"):
|
||||
op.drop_table("tryout_stats")
|
||||
if _table_exists("sessions"):
|
||||
op.drop_table("sessions")
|
||||
if _table_exists("items"):
|
||||
op.drop_table("items")
|
||||
if _table_exists("users"):
|
||||
op.drop_table("users")
|
||||
if _table_exists("tryouts"):
|
||||
op.drop_table("tryouts")
|
||||
if _table_exists("websites"):
|
||||
op.drop_table("websites")
|
||||
@@ -0,0 +1,118 @@
|
||||
"""add tryout JSON snapshot tables
|
||||
|
||||
Revision ID: 20260402_000002
|
||||
Revises: 20260331_000001
|
||||
Create Date: 2026-04-02 11:30:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260402_000002"
|
||||
down_revision: Union[str, None] = "20260331_000001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"tryout_import_snapshots",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("website_id", sa.Integer(), nullable=False),
|
||||
sa.Column("source_tryout_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("source_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("source_permalink", sa.String(length=1024), nullable=True),
|
||||
sa.Column("source_status", sa.String(length=50), nullable=True),
|
||||
sa.Column("exported_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("source_created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("source_modified_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("exported_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("question_count", sa.Integer(), nullable=False),
|
||||
sa.Column("result_count", sa.Integer(), nullable=False),
|
||||
sa.Column("payload_checksum", sa.String(length=64), nullable=False),
|
||||
sa.Column("raw_payload", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["website_id"], ["websites.id"], ondelete="CASCADE", onupdate="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_tryout_import_snapshots_website_id",
|
||||
"tryout_import_snapshots",
|
||||
["website_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_tryout_import_snapshots_source_tryout_id",
|
||||
"tryout_import_snapshots",
|
||||
["source_tryout_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"tryout_snapshot_questions",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("website_id", sa.Integer(), nullable=False),
|
||||
sa.Column("source_tryout_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("source_question_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("latest_snapshot_id", sa.Integer(), nullable=True),
|
||||
sa.Column("question_title", sa.Text(), nullable=False),
|
||||
sa.Column("question_html", sa.Text(), nullable=False),
|
||||
sa.Column("explanation_html", sa.Text(), nullable=True),
|
||||
sa.Column("raw_options", sa.JSON(), nullable=False),
|
||||
sa.Column("correct_answer", sa.String(length=10), nullable=False),
|
||||
sa.Column("category_id", sa.Integer(), nullable=True),
|
||||
sa.Column("category_name", sa.String(length=255), nullable=True),
|
||||
sa.Column("category_code", sa.String(length=255), nullable=True),
|
||||
sa.Column("option_count", sa.Integer(), nullable=False),
|
||||
sa.Column("has_option_labels", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("content_checksum", sa.String(length=64), nullable=False),
|
||||
sa.Column("raw_payload", sa.JSON(), nullable=False),
|
||||
sa.Column("first_seen_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("last_seen_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["website_id"], ["websites.id"], ondelete="CASCADE", onupdate="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["latest_snapshot_id"], ["tryout_import_snapshots.id"], ondelete="SET NULL", onupdate="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"website_id",
|
||||
"source_tryout_id",
|
||||
"source_question_id",
|
||||
name="uq_snapshot_questions_website_tryout_question",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_tryout_snapshot_questions_website_id",
|
||||
"tryout_snapshot_questions",
|
||||
["website_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_tryout_snapshot_questions_source_tryout_id",
|
||||
"tryout_snapshot_questions",
|
||||
["source_tryout_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_tryout_snapshot_questions_latest_snapshot_id",
|
||||
"tryout_snapshot_questions",
|
||||
["latest_snapshot_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_tryout_snapshot_questions_latest_snapshot_id", table_name="tryout_snapshot_questions")
|
||||
op.drop_index("ix_tryout_snapshot_questions_source_tryout_id", table_name="tryout_snapshot_questions")
|
||||
op.drop_index("ix_tryout_snapshot_questions_website_id", table_name="tryout_snapshot_questions")
|
||||
op.drop_table("tryout_snapshot_questions")
|
||||
|
||||
op.drop_index("ix_tryout_import_snapshots_source_tryout_id", table_name="tryout_import_snapshots")
|
||||
op.drop_index("ix_tryout_import_snapshots_website_id", table_name="tryout_import_snapshots")
|
||||
op.drop_table("tryout_import_snapshots")
|
||||
@@ -0,0 +1,118 @@
|
||||
"""add ai generation runs and item variant lifecycle fields
|
||||
|
||||
Revision ID: 20260404_000003
|
||||
Revises: 20260402_000002
|
||||
Create Date: 2026-04-04 10:10:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260404_000003"
|
||||
down_revision: Union[str, None] = "20260402_000002"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"ai_generation_runs",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("basis_item_id", sa.Integer(), nullable=False),
|
||||
sa.Column("source_snapshot_question_id", sa.Integer(), nullable=True),
|
||||
sa.Column("target_level", sa.String(length=50), nullable=False),
|
||||
sa.Column("requested_count", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("model", sa.String(length=255), nullable=False),
|
||||
sa.Column("prompt_version", sa.String(length=50), nullable=False, server_default="v1"),
|
||||
sa.Column("operator_notes", sa.Text(), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["basis_item_id"], ["items.id"], ondelete="CASCADE", onupdate="CASCADE"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["source_snapshot_question_id"],
|
||||
["tryout_snapshot_questions.id"],
|
||||
ondelete="SET NULL",
|
||||
onupdate="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_ai_generation_runs_basis_item_id", "ai_generation_runs", ["basis_item_id"], unique=False)
|
||||
op.create_index(
|
||||
"ix_ai_generation_runs_source_snapshot_question_id",
|
||||
"ai_generation_runs",
|
||||
["source_snapshot_question_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.add_column("items", sa.Column("generation_run_id", sa.Integer(), nullable=True))
|
||||
op.add_column("items", sa.Column("source_snapshot_question_id", sa.Integer(), nullable=True))
|
||||
op.add_column("items", sa.Column("variant_status", sa.String(length=50), nullable=False, server_default="active"))
|
||||
op.add_column("items", sa.Column("reviewed_by", sa.String(length=255), nullable=True))
|
||||
op.add_column("items", sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("items", sa.Column("review_notes", sa.Text(), nullable=True))
|
||||
|
||||
op.create_foreign_key(
|
||||
"fk_items_generation_run_id",
|
||||
"items",
|
||||
"ai_generation_runs",
|
||||
["generation_run_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
onupdate="CASCADE",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_items_source_snapshot_question_id",
|
||||
"items",
|
||||
"tryout_snapshot_questions",
|
||||
["source_snapshot_question_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
onupdate="CASCADE",
|
||||
)
|
||||
op.create_index("ix_items_generation_run_id", "items", ["generation_run_id"], unique=False)
|
||||
op.create_index(
|
||||
"ix_items_source_snapshot_question_id",
|
||||
"items",
|
||||
["source_snapshot_question_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index("ix_items_variant_status", "items", ["variant_status"], unique=False)
|
||||
|
||||
op.drop_index("ix_items_tryout_id_website_id_slot", table_name="items")
|
||||
op.create_index(
|
||||
"ix_items_tryout_id_website_id_slot",
|
||||
"items",
|
||||
["tryout_id", "website_id", "slot", "level"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.alter_column("items", "variant_status", server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_items_tryout_id_website_id_slot", table_name="items")
|
||||
op.create_index(
|
||||
"ix_items_tryout_id_website_id_slot",
|
||||
"items",
|
||||
["tryout_id", "website_id", "slot", "level"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
op.drop_index("ix_items_variant_status", table_name="items")
|
||||
op.drop_index("ix_items_source_snapshot_question_id", table_name="items")
|
||||
op.drop_index("ix_items_generation_run_id", table_name="items")
|
||||
op.drop_constraint("fk_items_source_snapshot_question_id", "items", type_="foreignkey")
|
||||
op.drop_constraint("fk_items_generation_run_id", "items", type_="foreignkey")
|
||||
op.drop_column("items", "review_notes")
|
||||
op.drop_column("items", "reviewed_at")
|
||||
op.drop_column("items", "reviewed_by")
|
||||
op.drop_column("items", "variant_status")
|
||||
op.drop_column("items", "source_snapshot_question_id")
|
||||
op.drop_column("items", "generation_run_id")
|
||||
|
||||
op.drop_index("ix_ai_generation_runs_source_snapshot_question_id", table_name="ai_generation_runs")
|
||||
op.drop_index("ix_ai_generation_runs_basis_item_id", table_name="ai_generation_runs")
|
||||
op.drop_table("ai_generation_runs")
|
||||
53
backend/alembic/versions/20260405_000004_report_schedules.py
Normal file
53
backend/alembic/versions/20260405_000004_report_schedules.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""add persistent report schedules
|
||||
|
||||
Revision ID: 20260405_000004
|
||||
Revises: 20260404_000003
|
||||
Create Date: 2026-04-05 09:00:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260405_000004"
|
||||
down_revision: Union[str, None] = "20260404_000003"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"report_schedules",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("schedule_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("report_type", sa.String(length=50), nullable=False),
|
||||
sa.Column("schedule", sa.String(length=20), nullable=False),
|
||||
sa.Column("tryout_ids", sa.JSON(), nullable=False),
|
||||
sa.Column("website_id", sa.Integer(), nullable=False),
|
||||
sa.Column("recipients", sa.JSON(), nullable=False),
|
||||
sa.Column("format", sa.String(length=10), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("last_run", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("next_run", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["website_id"], ["websites.id"], ondelete="CASCADE", onupdate="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("schedule_id"),
|
||||
)
|
||||
op.create_index("ix_report_schedules_schedule_id", "report_schedules", ["schedule_id"], unique=True)
|
||||
op.create_index("ix_report_schedules_website_id", "report_schedules", ["website_id"], unique=False)
|
||||
op.create_index(
|
||||
"ix_report_schedules_website_active",
|
||||
"report_schedules",
|
||||
["website_id", "is_active"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_report_schedules_website_active", table_name="report_schedules")
|
||||
op.drop_index("ix_report_schedules_website_id", table_name="report_schedules")
|
||||
op.drop_index("ix_report_schedules_schedule_id", table_name="report_schedules")
|
||||
op.drop_table("report_schedules")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""add session expires at
|
||||
|
||||
Revision ID: 20260617_000005
|
||||
Revises: 20260405_000004
|
||||
Create Date: 2026-06-17 15:00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260617_000005"
|
||||
down_revision: Union[str, None] = "20260405_000004"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("sessions", sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("sessions", "expires_at")
|
||||
7
backend/app/__init__.py
Normal file
7
backend/app/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
IRT Bank Soal - Adaptive Question Bank System
|
||||
|
||||
Main application package.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
1016
backend/app/admin.py
Normal file
1016
backend/app/admin.py
Normal file
File diff suppressed because it is too large
Load Diff
6456
backend/app/admin_web.py
Normal file
6456
backend/app/admin_web.py
Normal file
File diff suppressed because it is too large
Load Diff
110
backend/app/admin_web_icons.py
Normal file
110
backend/app/admin_web_icons.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Icon constants using inline SVG (Heroicons style).
|
||||
These replace emoji usage in the admin UI for consistent, professional icons.
|
||||
"""
|
||||
|
||||
# Navigation icons
|
||||
ICON_DASHBOARD = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" /></svg>"""
|
||||
|
||||
ICON_QUESTIONS = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" /></svg>"""
|
||||
|
||||
ICON_IMPORT = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" /></svg>"""
|
||||
|
||||
ICON_AI = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" /></svg>"""
|
||||
|
||||
ICON_EXAMS = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></svg>"""
|
||||
|
||||
ICON_REPORTS = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /></svg>"""
|
||||
|
||||
ICON_SETTINGS = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>"""
|
||||
|
||||
ICON_LOGOUT = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15M12 9l-3 3m0 0 3 3m-3-3h12.75" /></svg>"""
|
||||
|
||||
# Page icons
|
||||
ICON_TARGET = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 0 0 1.5-.189m-1.5.189a6.01 6.01 0 0 1-1.5-.189m3.75 7.478a12.06 12.06 0 0 1-4.5 0m3.75 2.383a14.406 14.406 0 0 1-3 0M14.25 18v-.192c0-.983.658-1.727 1.5-1.727s1.5.744 1.5 1.727V18m-4.5 0h.008v.008H14.25v-.008Z" /></svg>"""
|
||||
|
||||
ICON_USERS = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" /></svg>"""
|
||||
|
||||
ICON_CALIBRATION = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15ZM21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h6" /></svg>"""
|
||||
|
||||
ICON_STUDENTS = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" /></svg>"""
|
||||
|
||||
ICON_DOWNLOAD = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>"""
|
||||
|
||||
ICON_UPLOAD = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" /></svg>"""
|
||||
|
||||
ICON_SEARCH = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>"""
|
||||
|
||||
ICON_CHECK = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /></svg>"""
|
||||
|
||||
ICON_WARNING = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" /></svg>"""
|
||||
|
||||
ICON_INFO = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" /></svg>"""
|
||||
|
||||
ICON_LIGHTBULB = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 0 0 1.5-.189m-1.5.189a6.01 6.01 0 0 1-1.5-.189m3.75 7.478a12.06 12.06 0 0 1-4.5 0m3.75 2.383a14.406 14.406 0 0 1-3 0M14.25 18v-.192c0-.983.658-1.727 1.5-1.727s1.5.744 1.5 1.727V18m-4.5 0h.008v.008H14.25v-.008Z" /></svg>"""
|
||||
|
||||
ICON_TREND_UP = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941" /></svg>"""
|
||||
|
||||
ICON_TREND_DOWN = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6 9 12.75l4.286-4.286a11.948 11.948 0 0 1 4.306 6.43l.776 2.898m0 0 3.182-5.511m-3.182 5.51-5.511-3.181" /></svg>"""
|
||||
|
||||
# Huge icons for replacing emojis (24x24 with larger visual weight)
|
||||
ICON_HUGE_TARGET = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a8.01 8.01 0 0 0 1.5-.189m-1.5.189a8.01 8.01 0 0 1-1.5-.189m3.75 7.478a10.56 10.56 0 0 1-4.5 0m3.75 2.383a13.406 13.406 0 0 1-3 0M14.25 18v-.192c0-.983.658-1.727 1.5-1.727s1.5.744 1.5 1.727V18m-4.5 0h.008v.008H14.25v-.008Z" /></svg>"""
|
||||
|
||||
ICON_HUGE_USER = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" /></svg>"""
|
||||
|
||||
ICON_HUGE_CHECK = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /></svg>"""
|
||||
|
||||
ICON_HUGE_CLOCK = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>"""
|
||||
|
||||
ICON_HUGE_ROCKET = """<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none" /><g fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linejoin="round" d="m11.801 6.49l1.486-1.486c1.673-1.673 3.862-2.367 6.18-2.48c.902-.044 1.352-.066 1.714.295c.361.362.34.812.295 1.714c-.113 2.318-.807 4.507-2.48 6.18L17.511 12.2c-1.224 1.223-1.572 1.571-1.315 2.898c.254 1.014.499 1.995-.238 2.732c-.894.895-1.71.895-2.604 0l-7.183-7.183c-.895-.894-.895-1.71 0-2.604c.737-.737 1.718-.492 2.732-.238c1.327.257 1.675-.091 2.898-1.315Z" /><path stroke-linecap="round" d="m2.5 21.5l5-5m1 5l2-2m-8-4l2-2" /><path stroke-linecap="round" stroke-linejoin="round" d="M17.125 7H17m.25 0a.25.25 0 1 1-.5 0a.25.25 0 0 1 .5 0" /></g></svg>"""
|
||||
|
||||
ICON_HUGE_CHART = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /></svg>"""
|
||||
|
||||
# Emoji to SVG icon mapping for replacement
|
||||
EMOJI_TO_ICON = {
|
||||
# Navigation & main icons
|
||||
"🏠": ICON_DASHBOARD,
|
||||
"📝": ICON_QUESTIONS,
|
||||
"📥": ICON_IMPORT,
|
||||
"🤖": ICON_AI,
|
||||
"📋": ICON_EXAMS,
|
||||
"📊": ICON_REPORTS,
|
||||
"⚙️": ICON_SETTINGS,
|
||||
"🚪": ICON_LOGOUT,
|
||||
"🎯": ICON_HUGE_TARGET,
|
||||
"👤": ICON_HUGE_USER,
|
||||
"👥": ICON_USERS,
|
||||
"⚠️": ICON_WARNING,
|
||||
"ℹ️": ICON_INFO,
|
||||
"🚀": ICON_HUGE_ROCKET,
|
||||
"✅": ICON_HUGE_CHECK,
|
||||
"❌": """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>""",
|
||||
"⏳": ICON_HUGE_CLOCK,
|
||||
"📈": ICON_TREND_UP,
|
||||
"📉": ICON_TREND_DOWN,
|
||||
"💡": ICON_LIGHTBULB,
|
||||
"👋": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="display:inline;width:28px;height:28px;margin-bottom:-4px;"><path stroke-linecap="round" stroke-linejoin="round" d="M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672ZM12 2.25V4.5m5.834.166-1.591 1.591M20.25 10.5H18M7.757 14.743l-1.59 1.59M6 10.5H3.75m4.007-4.243-1.59-1.59" /></svg>',
|
||||
"📊": ICON_REPORTS,
|
||||
"🚀": ICON_HUGE_ROCKET,
|
||||
"📈": ICON_TREND_UP,
|
||||
# Additional icons from UI
|
||||
"🌐": """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /></svg>""",
|
||||
"🔍": ICON_SEARCH,
|
||||
"📁": """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" /></svg>""",
|
||||
"🔐": """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>""",
|
||||
"⚡": """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z" /></svg>""",
|
||||
"💾": """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0v3.75C20.25 20.653 16.556 22.5 12 22.5s-8.25-1.847-8.25-4.125v-3.75m-16.5 0v3.75" /></svg>""",
|
||||
"🔄": """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" /></svg>""",
|
||||
"🔘": """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" /></svg>""",
|
||||
"📍": """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z" /></svg>""",
|
||||
}
|
||||
|
||||
# Navigation icon mapping
|
||||
NAV_ICONS_SVG = {
|
||||
"Dashboard": ICON_DASHBOARD,
|
||||
"Import": ICON_IMPORT,
|
||||
"Exams": ICON_EXAMS,
|
||||
"Reports": ICON_REPORTS,
|
||||
"Settings": ICON_SETTINGS,
|
||||
"Logout": ICON_LOGOUT,
|
||||
}
|
||||
5
backend/app/api/__init__.py
Normal file
5
backend/app/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
API module for IRT Bank Soal.
|
||||
|
||||
Contains FastAPI routers and endpoint definitions.
|
||||
"""
|
||||
25
backend/app/api/v1/__init__.py
Normal file
25
backend/app/api/v1/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
API v1 Router configuration.
|
||||
|
||||
Defines all API v1 endpoints and their prefixes.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import session
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# Include session endpoints
|
||||
api_router.include_router(
|
||||
session.router,
|
||||
prefix="/session",
|
||||
tags=["session"]
|
||||
)
|
||||
|
||||
# Include admin endpoints
|
||||
api_router.include_router(
|
||||
session.admin_router,
|
||||
prefix="/admin",
|
||||
tags=["admin"]
|
||||
)
|
||||
448
backend/app/api/v1/session.py
Normal file
448
backend/app/api/v1/session.py
Normal file
@@ -0,0 +1,448 @@
|
||||
"""
|
||||
Session API endpoints for CAT item selection.
|
||||
|
||||
Provides endpoints for:
|
||||
- GET /api/v1/session/{session_id}/next_item - Get next question
|
||||
- POST /api/v1/admin/cat/test - Admin playground for testing CAT
|
||||
"""
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.auth import (
|
||||
AuthContext,
|
||||
ensure_website_scope_matches,
|
||||
get_auth_context,
|
||||
require_website_auth,
|
||||
)
|
||||
from app.models import Item, Session, Tryout, UserAnswer
|
||||
from app.services.cat_selection import (
|
||||
CATSelectionError,
|
||||
get_next_item,
|
||||
should_terminate,
|
||||
simulate_cat_selection,
|
||||
update_theta,
|
||||
)
|
||||
|
||||
# Default SE threshold for termination
|
||||
DEFAULT_SE_THRESHOLD = 0.5
|
||||
|
||||
# Session router for student-facing endpoints
|
||||
router = APIRouter()
|
||||
|
||||
# Admin router for admin-only endpoints
|
||||
admin_router = APIRouter()
|
||||
|
||||
|
||||
# ============== Request/Response Models ==============
|
||||
|
||||
class NextItemResponse(BaseModel):
|
||||
"""Response for next item endpoint."""
|
||||
status: Literal["item", "completed"] = "item"
|
||||
item_id: Optional[int] = None
|
||||
stem: Optional[str] = None
|
||||
options: Optional[dict] = None
|
||||
slot: Optional[int] = None
|
||||
level: Optional[str] = None
|
||||
display_level: Optional[str] = None
|
||||
generated_by: Optional[str] = None
|
||||
source_snapshot_question_id: Optional[int] = None
|
||||
selection_method: Optional[str] = None
|
||||
reason: Optional[str] = None
|
||||
current_theta: Optional[float] = None
|
||||
current_se: Optional[float] = None
|
||||
items_answered: Optional[int] = None
|
||||
|
||||
|
||||
class SubmitAnswerRequest(BaseModel):
|
||||
"""Request for submitting an answer."""
|
||||
item_id: int = Field(..., description="Item ID being answered")
|
||||
response: str = Field(..., description="User's answer (A, B, C, D)")
|
||||
time_spent: int = Field(default=0, ge=0, description="Time spent on question (seconds)")
|
||||
|
||||
|
||||
class SubmitAnswerResponse(BaseModel):
|
||||
"""Response for submitting an answer."""
|
||||
theta: Optional[float] = None
|
||||
theta_se: Optional[float] = None
|
||||
|
||||
|
||||
class CATTestRequest(BaseModel):
|
||||
"""Request for admin CAT test endpoint."""
|
||||
tryout_id: str = Field(..., description="Tryout identifier")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
initial_theta: float = Field(default=0.0, ge=-3.0, le=3.0, description="Initial theta value")
|
||||
selection_mode: Literal["fixed", "adaptive", "hybrid"] = Field(
|
||||
default="adaptive", description="Selection mode"
|
||||
)
|
||||
max_items: int = Field(default=15, ge=1, le=100, description="Maximum items to simulate")
|
||||
se_threshold: float = Field(
|
||||
default=0.5, ge=0.1, le=3.0, description="SE threshold for termination"
|
||||
)
|
||||
hybrid_transition_slot: int = Field(
|
||||
default=10, ge=1, description="Slot to transition in hybrid mode"
|
||||
)
|
||||
|
||||
|
||||
class CATTestResponse(BaseModel):
|
||||
"""Response for admin CAT test endpoint."""
|
||||
tryout_id: str
|
||||
website_id: int
|
||||
initial_theta: float
|
||||
selection_mode: str
|
||||
total_items: int
|
||||
final_theta: float
|
||||
final_se: float
|
||||
se_threshold_met: bool
|
||||
items: list
|
||||
|
||||
|
||||
# ============== Session Endpoints ==============
|
||||
|
||||
@router.get(
|
||||
"/{session_id}/next_item",
|
||||
response_model=NextItemResponse,
|
||||
summary="Get next item for session",
|
||||
description="Returns the next question for a session based on the tryout's selection mode."
|
||||
)
|
||||
async def get_next_item_endpoint(
|
||||
session_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> NextItemResponse:
|
||||
"""
|
||||
Get the next item for a session.
|
||||
|
||||
Validates session exists and is not completed.
|
||||
Gets Tryout config (scoring_mode, selection_mode, max_items).
|
||||
Calls appropriate selection function based on selection_mode.
|
||||
Returns item or completion status.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||
|
||||
# Get session
|
||||
session_query = select(Session).where(
|
||||
Session.session_id == session_id,
|
||||
Session.website_id == website_id,
|
||||
)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Session {session_id} not found"
|
||||
)
|
||||
if auth.role == "student" and session.wp_user_id != auth.wp_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Session does not belong to this authenticated user",
|
||||
)
|
||||
|
||||
if session.is_completed:
|
||||
return NextItemResponse(
|
||||
status="completed",
|
||||
reason="Session already completed"
|
||||
)
|
||||
|
||||
# Get tryout config
|
||||
tryout_query = select(Tryout).where(
|
||||
Tryout.tryout_id == session.tryout_id,
|
||||
Tryout.website_id == session.website_id
|
||||
)
|
||||
tryout_result = await db.execute(tryout_query)
|
||||
tryout = tryout_result.scalar_one_or_none()
|
||||
|
||||
if not tryout:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tryout {session.tryout_id} not found"
|
||||
)
|
||||
|
||||
# Check termination conditions
|
||||
termination = await should_terminate(
|
||||
db,
|
||||
session_id,
|
||||
max_items=None, # Will be set from tryout config if needed
|
||||
se_threshold=DEFAULT_SE_THRESHOLD
|
||||
)
|
||||
|
||||
if termination.should_terminate:
|
||||
return NextItemResponse(
|
||||
status="completed",
|
||||
reason=termination.reason,
|
||||
current_theta=session.theta,
|
||||
current_se=session.theta_se,
|
||||
items_answered=termination.items_answered
|
||||
)
|
||||
|
||||
# Get next item based on selection mode
|
||||
try:
|
||||
result = await get_next_item(
|
||||
db,
|
||||
session_id,
|
||||
selection_mode=tryout.selection_mode,
|
||||
hybrid_transition_slot=tryout.hybrid_transition_slot or 10,
|
||||
ai_generation_enabled=tryout.ai_generation_enabled
|
||||
)
|
||||
except CATSelectionError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
if result.item is None:
|
||||
return NextItemResponse(
|
||||
status="completed",
|
||||
reason=result.reason,
|
||||
current_theta=session.theta,
|
||||
current_se=session.theta_se,
|
||||
items_answered=termination.items_answered
|
||||
)
|
||||
|
||||
item = result.item
|
||||
|
||||
return NextItemResponse(
|
||||
status="item",
|
||||
item_id=item.id,
|
||||
stem=item.stem,
|
||||
options=item.options,
|
||||
slot=item.slot,
|
||||
level=item.level,
|
||||
display_level="Original"
|
||||
if item.generated_by != "ai" and item.source_snapshot_question_id is not None
|
||||
else item.level,
|
||||
generated_by=item.generated_by,
|
||||
source_snapshot_question_id=item.source_snapshot_question_id,
|
||||
selection_method=result.selection_method,
|
||||
reason=result.reason,
|
||||
current_theta=session.theta,
|
||||
current_se=session.theta_se,
|
||||
items_answered=termination.items_answered
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{session_id}/submit_answer",
|
||||
response_model=SubmitAnswerResponse,
|
||||
summary="Submit answer for item",
|
||||
description="Submit an answer for an item and update theta estimate."
|
||||
)
|
||||
async def submit_answer_endpoint(
|
||||
session_id: str,
|
||||
request: SubmitAnswerRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> SubmitAnswerResponse:
|
||||
"""
|
||||
Submit an answer for an item.
|
||||
|
||||
Validates session and item.
|
||||
Checks correctness.
|
||||
Updates theta estimate.
|
||||
Records response time.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||
|
||||
# Get session
|
||||
session_query = select(Session).where(
|
||||
Session.session_id == session_id,
|
||||
Session.website_id == website_id,
|
||||
)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Session {session_id} not found"
|
||||
)
|
||||
if auth.role == "student" and session.wp_user_id != auth.wp_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Session does not belong to this authenticated user",
|
||||
)
|
||||
|
||||
if session.is_completed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Session already completed"
|
||||
)
|
||||
|
||||
# Get item
|
||||
item_query = select(Item).where(
|
||||
Item.id == request.item_id,
|
||||
Item.website_id == session.website_id,
|
||||
Item.tryout_id == session.tryout_id,
|
||||
)
|
||||
item_result = await db.execute(item_query)
|
||||
item = item_result.scalar_one_or_none()
|
||||
|
||||
if not item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Item {request.item_id} not found"
|
||||
)
|
||||
|
||||
existing_answer_result = await db.execute(
|
||||
select(UserAnswer.id).where(
|
||||
UserAnswer.session_id == session_id,
|
||||
UserAnswer.item_id == request.item_id,
|
||||
)
|
||||
)
|
||||
if existing_answer_result.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Item was already answered for this session",
|
||||
)
|
||||
|
||||
# Check correctness
|
||||
is_correct = request.response.upper() == item.correct_answer.upper()
|
||||
|
||||
# Update theta
|
||||
theta, theta_se = await update_theta(db, session_id, request.item_id, is_correct)
|
||||
|
||||
user_answer = UserAnswer(
|
||||
session_id=session_id,
|
||||
wp_user_id=session.wp_user_id,
|
||||
website_id=session.website_id,
|
||||
tryout_id=session.tryout_id,
|
||||
item_id=request.item_id,
|
||||
response=request.response.upper(),
|
||||
is_correct=is_correct,
|
||||
time_spent=request.time_spent,
|
||||
scoring_mode_used=session.scoring_mode_used,
|
||||
bobot_earned=item.ctt_bobot if is_correct and item.ctt_bobot else 0.0
|
||||
)
|
||||
|
||||
db.add(user_answer)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Item was already answered for this session",
|
||||
) from exc
|
||||
|
||||
return SubmitAnswerResponse(
|
||||
theta=theta,
|
||||
theta_se=theta_se
|
||||
)
|
||||
|
||||
|
||||
# ============== Admin Endpoints ==============
|
||||
|
||||
@admin_router.post(
|
||||
"/cat/test",
|
||||
response_model=CATTestResponse,
|
||||
summary="Test CAT selection algorithm",
|
||||
description="Admin playground for testing adaptive selection behavior."
|
||||
)
|
||||
async def test_cat_endpoint(
|
||||
request: CATTestRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> CATTestResponse:
|
||||
"""
|
||||
Test CAT selection algorithm.
|
||||
|
||||
Simulates CAT selection for a tryout and returns
|
||||
the sequence of selected items with theta progression.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
ensure_website_scope_matches(website_id, request.website_id)
|
||||
|
||||
# Verify tryout exists
|
||||
tryout_query = select(Tryout).where(
|
||||
Tryout.tryout_id == request.tryout_id,
|
||||
Tryout.website_id == website_id
|
||||
)
|
||||
tryout_result = await db.execute(tryout_query)
|
||||
tryout = tryout_result.scalar_one_or_none()
|
||||
|
||||
if not tryout:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tryout {request.tryout_id} not found for website {website_id}"
|
||||
)
|
||||
|
||||
# Run simulation
|
||||
result = await simulate_cat_selection(
|
||||
db,
|
||||
tryout_id=request.tryout_id,
|
||||
website_id=website_id,
|
||||
initial_theta=request.initial_theta,
|
||||
selection_mode=request.selection_mode,
|
||||
max_items=request.max_items,
|
||||
se_threshold=request.se_threshold,
|
||||
hybrid_transition_slot=request.hybrid_transition_slot
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result["error"]
|
||||
)
|
||||
|
||||
return CATTestResponse(**result)
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/session/{session_id}/status",
|
||||
summary="Get session status",
|
||||
description="Get detailed session status including theta and SE."
|
||||
)
|
||||
async def get_session_status_endpoint(
|
||||
session_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> dict:
|
||||
"""
|
||||
Get session status for admin monitoring.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
|
||||
# Get session
|
||||
session_query = select(Session).where(
|
||||
Session.session_id == session_id,
|
||||
Session.website_id == website_id,
|
||||
)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Session {session_id} not found"
|
||||
)
|
||||
|
||||
# Count answers
|
||||
from sqlalchemy import func
|
||||
from app.models import UserAnswer
|
||||
|
||||
count_query = select(func.count(UserAnswer.id)).where(
|
||||
UserAnswer.session_id == session_id
|
||||
)
|
||||
count_result = await db.execute(count_query)
|
||||
items_answered = count_result.scalar() or 0
|
||||
|
||||
return {
|
||||
"session_id": session.session_id,
|
||||
"wp_user_id": session.wp_user_id,
|
||||
"tryout_id": session.tryout_id,
|
||||
"is_completed": session.is_completed,
|
||||
"theta": session.theta,
|
||||
"theta_se": session.theta_se,
|
||||
"items_answered": items_answered,
|
||||
"scoring_mode_used": session.scoring_mode_used,
|
||||
"NM": session.NM,
|
||||
"NN": session.NN,
|
||||
"start_time": session.start_time.isoformat() if session.start_time else None,
|
||||
"end_time": session.end_time.isoformat() if session.end_time else None
|
||||
}
|
||||
3
backend/app/core/__init__.py
Normal file
3
backend/app/core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Core configuration and database utilities.
|
||||
"""
|
||||
170
backend/app/core/auth.py
Normal file
170
backend/app/core/auth.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Token-based authentication helpers for website-scoped access control.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Header, HTTPException, status
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthContext:
|
||||
website_id: Optional[int]
|
||||
role: str
|
||||
wp_user_id: Optional[str] = None
|
||||
|
||||
|
||||
def _b64url_encode(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(raw: str) -> bytes:
|
||||
padding = "=" * (-len(raw) % 4)
|
||||
return base64.urlsafe_b64decode((raw + padding).encode("ascii"))
|
||||
|
||||
|
||||
def issue_access_token(
|
||||
website_id: int | None,
|
||||
role: str = "student",
|
||||
wp_user_id: str | None = None,
|
||||
expires_in_seconds: int = 3600,
|
||||
) -> str:
|
||||
payload = {
|
||||
"website_id": int(website_id) if website_id is not None else None,
|
||||
"role": role,
|
||||
"wp_user_id": wp_user_id,
|
||||
"exp": int(time.time()) + int(expires_in_seconds),
|
||||
}
|
||||
payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
payload_b64 = _b64url_encode(payload_bytes)
|
||||
sig = hmac.new(settings.SECRET_KEY.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||
return f"{payload_b64}.{_b64url_encode(sig)}"
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> AuthContext:
|
||||
try:
|
||||
payload_b64, sig_b64 = token.split(".", 1)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid access token format",
|
||||
) from exc
|
||||
|
||||
expected_sig = hmac.new(
|
||||
settings.SECRET_KEY.encode("utf-8"),
|
||||
payload_b64.encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
provided_sig = _b64url_decode(sig_b64)
|
||||
if not hmac.compare_digest(provided_sig, expected_sig):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid access token signature",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = json.loads(_b64url_decode(payload_b64).decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid access token payload",
|
||||
) from exc
|
||||
|
||||
exp = int(payload.get("exp", 0))
|
||||
if exp <= int(time.time()):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Access token has expired",
|
||||
)
|
||||
|
||||
website_id = payload.get("website_id")
|
||||
role = payload.get("role")
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Access token missing required claims",
|
||||
)
|
||||
if website_id is None and role != "system_admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Access token missing website scope",
|
||||
)
|
||||
|
||||
return AuthContext(
|
||||
website_id=int(website_id) if website_id is not None else None,
|
||||
role=str(role),
|
||||
wp_user_id=payload.get("wp_user_id"),
|
||||
)
|
||||
|
||||
|
||||
def get_auth_context(
|
||||
authorization: str | None = Header(None, alias="Authorization"),
|
||||
x_website_id: str | None = Header(None, alias="X-Website-ID"),
|
||||
) -> AuthContext:
|
||||
if authorization is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authorization header is required",
|
||||
)
|
||||
parts = authorization.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Authorization header format. Use: Bearer {token}",
|
||||
)
|
||||
|
||||
context = decode_access_token(parts[1])
|
||||
|
||||
# If system_admin explicitly sets a website context via header, use it
|
||||
if context.role == "system_admin" and x_website_id and x_website_id.isdigit():
|
||||
context.website_id = int(x_website_id)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def require_website_auth(
|
||||
auth: AuthContext,
|
||||
allowed_roles: set[str] | None = None,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Check if the authenticated user has required roles.
|
||||
Returns the website_id if scoped to a specific website.
|
||||
Returns None if the user is a system_admin with global access and no specific website context.
|
||||
"""
|
||||
if allowed_roles is not None and auth.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions for this endpoint",
|
||||
)
|
||||
|
||||
if auth.role == "system_admin":
|
||||
if auth.website_id is not None:
|
||||
return auth.website_id
|
||||
return None
|
||||
|
||||
return auth.website_id
|
||||
|
||||
|
||||
def ensure_website_scope_matches(
|
||||
auth_website_id: int | None,
|
||||
payload_website_id: int,
|
||||
) -> None:
|
||||
if auth_website_id is None:
|
||||
return
|
||||
if int(auth_website_id) != int(payload_website_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="website_id in payload must match authenticated website scope",
|
||||
)
|
||||
151
backend/app/core/config.py
Normal file
151
backend/app/core/config.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
Application configuration using Pydantic Settings.
|
||||
|
||||
Loads configuration from environment variables with validation.
|
||||
"""
|
||||
|
||||
from typing import Annotated, Literal, List, Union
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = Field(
|
||||
default="postgresql+asyncpg://postgres:postgres@localhost:5432/irt_bank_soal",
|
||||
description="PostgreSQL database URL with asyncpg driver",
|
||||
)
|
||||
|
||||
# FastAPI
|
||||
SECRET_KEY: str = Field(
|
||||
default="dev-secret-key-change-in-production",
|
||||
description="Secret key for JWT token signing",
|
||||
)
|
||||
API_V1_STR: str = Field(default="/api/v1", description="API v1 prefix")
|
||||
PROJECT_NAME: str = Field(default="IRT Bank Soal", description="Project name")
|
||||
ENVIRONMENT: Literal["development", "staging", "production"] = Field(
|
||||
default="development", description="Environment name"
|
||||
)
|
||||
ENABLE_ADMIN: bool = Field(
|
||||
default=False,
|
||||
description="Enable admin UI and admin-only API routes",
|
||||
)
|
||||
ADMIN_USERNAME: str = Field(
|
||||
default="",
|
||||
description="Admin panel username",
|
||||
)
|
||||
ADMIN_PASSWORD: str = Field(
|
||||
default="",
|
||||
description="Admin panel password (plain env value)",
|
||||
)
|
||||
ADMIN_SESSION_EXPIRE_SECONDS: int = Field(
|
||||
default=3600,
|
||||
description="Admin session lifetime in seconds",
|
||||
)
|
||||
|
||||
# OpenRouter (AI Generation)
|
||||
OPENROUTER_API_KEY: str = Field(
|
||||
default="", description="OpenRouter API key for AI generation"
|
||||
)
|
||||
OPENROUTER_MODEL_QWEN: str = Field(
|
||||
default="qwen/qwen2.5-32b-instruct",
|
||||
description="Balanced Qwen model identifier",
|
||||
)
|
||||
OPENROUTER_MODEL_CHEAP: str = Field(
|
||||
default="mistralai/mistral-small-2603",
|
||||
description="Low-cost model identifier",
|
||||
)
|
||||
OPENROUTER_MODEL_LLAMA: str = Field(
|
||||
default="meta-llama/llama-3.3-70b-instruct",
|
||||
description="Premium Llama model identifier",
|
||||
)
|
||||
OPENROUTER_TIMEOUT: int = Field(default=30, description="OpenRouter API timeout in seconds")
|
||||
OPENROUTER_PROVIDER_ORDER: List[str] = Field(
|
||||
default=["NovitaAI", "AkashML", "Inception"],
|
||||
description="Preferred OpenRouter providers in priority order",
|
||||
)
|
||||
OPENROUTER_ALLOW_PROVIDER_FALLBACKS: bool = Field(
|
||||
default=True,
|
||||
description="Allow OpenRouter to fallback outside preferred providers",
|
||||
)
|
||||
|
||||
# WordPress Integration
|
||||
WORDPRESS_API_URL: str = Field(
|
||||
default="", description="WordPress REST API base URL"
|
||||
)
|
||||
WORDPRESS_AUTH_TOKEN: str = Field(
|
||||
default="", description="WordPress JWT authentication token"
|
||||
)
|
||||
|
||||
# Redis (Celery)
|
||||
REDIS_URL: str = Field(
|
||||
default="redis://localhost:6379/0", description="Redis connection URL"
|
||||
)
|
||||
CELERY_BROKER_URL: str = Field(
|
||||
default="redis://localhost:6379/0", description="Celery broker URL"
|
||||
)
|
||||
CELERY_RESULT_BACKEND: str = Field(
|
||||
default="redis://localhost:6379/0", description="Celery result backend URL"
|
||||
)
|
||||
|
||||
# CORS - stored as list, accepts comma-separated string from env
|
||||
ALLOWED_ORIGINS: Annotated[List[str], NoDecode] = Field(
|
||||
default=["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:5173"],
|
||||
description="List of allowed CORS origins",
|
||||
)
|
||||
|
||||
@field_validator("ALLOWED_ORIGINS", mode="before")
|
||||
@classmethod
|
||||
def parse_allowed_origins(cls, v: Union[str, List[str]]) -> List[str]:
|
||||
"""Parse comma-separated origins into list."""
|
||||
if isinstance(v, str):
|
||||
return [origin.strip() for origin in v.split(",") if origin.strip()]
|
||||
return v
|
||||
|
||||
@field_validator("OPENROUTER_PROVIDER_ORDER", mode="before")
|
||||
@classmethod
|
||||
def parse_provider_order(cls, v: Union[str, List[str]]) -> List[str]:
|
||||
"""Parse comma-separated OpenRouter provider list into array."""
|
||||
if isinstance(v, str):
|
||||
return [provider.strip() for provider in v.split(",") if provider.strip()]
|
||||
return v
|
||||
|
||||
|
||||
# Global settings instance
|
||||
_settings: Union[Settings, None] = None
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""
|
||||
Get application settings instance.
|
||||
|
||||
Returns:
|
||||
Settings: Application settings
|
||||
|
||||
Raises:
|
||||
ValueError: If settings not initialized
|
||||
"""
|
||||
global _settings
|
||||
if _settings is None:
|
||||
_settings = Settings()
|
||||
return _settings
|
||||
|
||||
|
||||
def init_settings(settings: Settings) -> None:
|
||||
"""
|
||||
Initialize settings with custom instance (useful for testing).
|
||||
|
||||
Args:
|
||||
settings: Settings instance to use
|
||||
"""
|
||||
global _settings
|
||||
_settings = settings
|
||||
121
backend/app/core/rate_limit.py
Normal file
121
backend/app/core/rate_limit.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Lightweight in-process rate limiting helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
_lock = threading.Lock()
|
||||
_hits: dict[str, deque[float]] = defaultdict(deque)
|
||||
_redis_client: Redis | None = None
|
||||
_redis_unavailable = False
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
if request.client and request.client.host:
|
||||
return request.client.host
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _get_redis_client() -> Redis | None:
|
||||
global _redis_client
|
||||
if _redis_unavailable:
|
||||
return None
|
||||
if _redis_client is None:
|
||||
settings = get_settings()
|
||||
if not settings.REDIS_URL:
|
||||
return None
|
||||
_redis_client = Redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
return _redis_client
|
||||
|
||||
|
||||
def _enforce_in_memory_rate_limit(
|
||||
*,
|
||||
key: str,
|
||||
scope: str,
|
||||
max_requests: int,
|
||||
window_seconds: int,
|
||||
) -> None:
|
||||
now = time.time()
|
||||
cutoff = now - window_seconds
|
||||
|
||||
with _lock:
|
||||
dq = _hits[key]
|
||||
while dq and dq[0] <= cutoff:
|
||||
dq.popleft()
|
||||
if len(dq) >= max_requests:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Too many requests for {scope}. Please try again later.",
|
||||
)
|
||||
dq.append(now)
|
||||
|
||||
|
||||
async def enforce_rate_limit(
|
||||
request: Request,
|
||||
*,
|
||||
scope: str,
|
||||
max_requests: int,
|
||||
window_seconds: int,
|
||||
) -> None:
|
||||
global _redis_unavailable
|
||||
|
||||
ip = _client_ip(request)
|
||||
key = f"{scope}:{ip}"
|
||||
|
||||
redis = _get_redis_client()
|
||||
if redis is not None:
|
||||
try:
|
||||
current = await redis.incr(key)
|
||||
if current == 1:
|
||||
await redis.expire(key, window_seconds)
|
||||
if current > max_requests:
|
||||
ttl = await redis.ttl(key)
|
||||
retry_after = ttl if ttl and ttl > 0 else window_seconds
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Too many requests for {scope}. Please try again later.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
return
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_redis_unavailable = True
|
||||
logger.warning("Redis rate limiter unavailable; falling back to memory: %s", exc)
|
||||
|
||||
_enforce_in_memory_rate_limit(
|
||||
key=key,
|
||||
scope=scope,
|
||||
max_requests=max_requests,
|
||||
window_seconds=window_seconds,
|
||||
)
|
||||
|
||||
|
||||
async def close_rate_limit() -> None:
|
||||
global _redis_client
|
||||
if _redis_client is None:
|
||||
return
|
||||
try:
|
||||
await _redis_client.aclose()
|
||||
finally:
|
||||
_redis_client = None
|
||||
|
||||
|
||||
def reset_rate_limit_state() -> None:
|
||||
"""Reset local limiter state for tests."""
|
||||
global _redis_client, _redis_unavailable
|
||||
_redis_client = None
|
||||
_redis_unavailable = False
|
||||
with _lock:
|
||||
_hits.clear()
|
||||
88
backend/app/database.py
Normal file
88
backend/app/database.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Database configuration and session management for async PostgreSQL.
|
||||
|
||||
Uses SQLAlchemy 2.0 async ORM with asyncpg driver.
|
||||
"""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Create async engine with connection pooling
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.ENVIRONMENT == "development", # Log SQL in development
|
||||
pool_pre_ping=True, # Verify connections before using
|
||||
pool_size=10, # Number of connections to maintain
|
||||
max_overflow=20, # Max additional connections beyond pool_size
|
||||
pool_recycle=3600, # Recycle connections after 1 hour
|
||||
)
|
||||
|
||||
# Create async session factory
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False, # Prevent attributes from being expired after commit
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all database models."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
Dependency for getting async database session.
|
||||
|
||||
Yields:
|
||||
AsyncSession: Database session
|
||||
|
||||
Example:
|
||||
```python
|
||||
@app.get("/items/")
|
||||
async def get_items(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Item))
|
||||
return result.scalars().all()
|
||||
```
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""
|
||||
Initialize database - create all tables.
|
||||
|
||||
Note: In production, use Alembic migrations instead.
|
||||
This is useful for development and testing.
|
||||
"""
|
||||
if settings.ENVIRONMENT == "production":
|
||||
return
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
"""Close database connections."""
|
||||
await engine.dispose()
|
||||
265
backend/app/main.py
Normal file
265
backend/app/main.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
IRT Bank Soal - Adaptive Question Bank System
|
||||
|
||||
Main FastAPI application entry point.
|
||||
|
||||
Features:
|
||||
- CTT (Classical Test Theory) scoring with exact Excel formulas
|
||||
- IRT (Item Response Theory) support for adaptive testing
|
||||
- Multi-website support for WordPress integration
|
||||
- AI-powered question generation
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.v1.session import (
|
||||
admin_router as adaptive_admin_router,
|
||||
router as adaptive_session_router,
|
||||
)
|
||||
from app.core.rate_limit import close_rate_limit
|
||||
from app.admin_web import (
|
||||
configure_admin_web,
|
||||
router as admin_web_router,
|
||||
shutdown_admin_web,
|
||||
)
|
||||
from app.core.config import get_settings
|
||||
from app.database import close_db, init_db
|
||||
from app.routers import (
|
||||
admin_router,
|
||||
ai_router,
|
||||
auth_router,
|
||||
import_export_router,
|
||||
reports_router,
|
||||
sessions_router,
|
||||
tryouts_router,
|
||||
wordpress_router,
|
||||
websites_router,
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def validate_security_config() -> None:
|
||||
"""
|
||||
Enforce minimum security requirements for production deployments.
|
||||
"""
|
||||
if settings.ENVIRONMENT != "production":
|
||||
return
|
||||
|
||||
insecure_secret_values = {
|
||||
"",
|
||||
"dev-secret-key-change-in-production",
|
||||
"your-secret-key-here-change-in-production",
|
||||
}
|
||||
if settings.SECRET_KEY in insecure_secret_values:
|
||||
raise RuntimeError(
|
||||
"In production, SECRET_KEY must be set to a strong non-default value."
|
||||
)
|
||||
|
||||
if settings.ENABLE_ADMIN and (
|
||||
not settings.ADMIN_USERNAME
|
||||
or not settings.ADMIN_PASSWORD
|
||||
or settings.ADMIN_PASSWORD == "change-me"
|
||||
):
|
||||
raise RuntimeError(
|
||||
"In production with ENABLE_ADMIN=true, ADMIN_USERNAME and ADMIN_PASSWORD must be configured securely."
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""
|
||||
Application lifespan manager.
|
||||
|
||||
Handles startup and shutdown events.
|
||||
"""
|
||||
validate_security_config()
|
||||
|
||||
# Startup: Initialize database
|
||||
await init_db()
|
||||
if settings.ENABLE_ADMIN:
|
||||
await configure_admin_web()
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown: Close database connections
|
||||
if settings.ENABLE_ADMIN:
|
||||
await shutdown_admin_web()
|
||||
await close_rate_limit()
|
||||
await close_db()
|
||||
|
||||
|
||||
# Initialize FastAPI application
|
||||
app = FastAPI(
|
||||
title="IRT Bank Soal",
|
||||
description="""
|
||||
## Adaptive Question Bank System with IRT/CTT Scoring
|
||||
|
||||
This API provides a comprehensive backend for adaptive assessment systems.
|
||||
|
||||
### Features
|
||||
- **CTT Scoring**: Classical Test Theory with exact Excel formula compatibility
|
||||
- **IRT Support**: Item Response Theory for adaptive testing (1PL Rasch model)
|
||||
- **Multi-Site**: Single backend serving multiple WordPress sites
|
||||
- **AI Generation**: Automatic question variant generation
|
||||
|
||||
### Scoring Formulas (PRD Section 13.1)
|
||||
- **CTT p-value**: `p = Σ Benar / Total Peserta`
|
||||
- **CTT Bobot**: `Bobot = 1 - p`
|
||||
- **CTT NM**: `NM = (Total_Bobot_Siswa / Total_Bobot_Max) × 1000`
|
||||
- **CTT NN**: `NN = 500 + 100 × ((NM - Rataan) / SB)`
|
||||
|
||||
### Authentication
|
||||
Most endpoints require `X-Website-ID` header for multi-site isolation.
|
||||
""",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Configure CORS middleware
|
||||
# Parse ALLOWED_ORIGINS from settings (comma-separated string)
|
||||
allowed_origins = settings.ALLOWED_ORIGINS
|
||||
if isinstance(allowed_origins, str):
|
||||
allowed_origins = [origin.strip() for origin in allowed_origins.split(",") if origin.strip()]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=allowed_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@app.get(
|
||||
"/",
|
||||
summary="Health check",
|
||||
description="Returns API status and version information.",
|
||||
tags=["health"],
|
||||
)
|
||||
async def root():
|
||||
"""
|
||||
Health check endpoint.
|
||||
|
||||
Returns basic API information for monitoring and load balancer checks.
|
||||
"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "IRT Bank Soal",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
}
|
||||
|
||||
|
||||
@app.get(
|
||||
"/health",
|
||||
summary="Detailed health check",
|
||||
description="Returns detailed health status including database connectivity.",
|
||||
tags=["health"],
|
||||
)
|
||||
async def health_check():
|
||||
"""
|
||||
Detailed health check endpoint.
|
||||
|
||||
Includes database connectivity verification.
|
||||
"""
|
||||
from app.database import engine
|
||||
from sqlalchemy import text
|
||||
|
||||
db_status = "unknown"
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
db_status = "connected"
|
||||
except Exception as e:
|
||||
db_status = f"error: {str(e)}"
|
||||
|
||||
return {
|
||||
"status": "healthy" if db_status == "connected" else "degraded",
|
||||
"service": "IRT Bank Soal",
|
||||
"version": "1.0.0",
|
||||
"database": db_status,
|
||||
"environment": settings.ENVIRONMENT,
|
||||
}
|
||||
|
||||
|
||||
# Include API routers with version prefix
|
||||
app.include_router(
|
||||
auth_router,
|
||||
prefix=f"{settings.API_V1_STR}",
|
||||
)
|
||||
app.include_router(
|
||||
import_export_router,
|
||||
)
|
||||
app.include_router(
|
||||
sessions_router,
|
||||
prefix=f"{settings.API_V1_STR}",
|
||||
)
|
||||
app.include_router(
|
||||
adaptive_session_router,
|
||||
prefix=f"{settings.API_V1_STR}/session",
|
||||
)
|
||||
app.include_router(
|
||||
tryouts_router,
|
||||
prefix=f"{settings.API_V1_STR}",
|
||||
)
|
||||
app.include_router(
|
||||
wordpress_router,
|
||||
prefix=f"{settings.API_V1_STR}",
|
||||
)
|
||||
app.include_router(
|
||||
reports_router,
|
||||
prefix=f"{settings.API_V1_STR}",
|
||||
)
|
||||
app.include_router(
|
||||
websites_router,
|
||||
prefix=f"{settings.API_V1_STR}",
|
||||
)
|
||||
|
||||
if settings.ENABLE_ADMIN:
|
||||
app.include_router(
|
||||
ai_router,
|
||||
prefix=f"{settings.API_V1_STR}",
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
adaptive_admin_router,
|
||||
prefix=f"{settings.API_V1_STR}/admin",
|
||||
)
|
||||
|
||||
app.include_router(admin_web_router)
|
||||
|
||||
# Include admin API router for custom actions
|
||||
app.include_router(
|
||||
admin_router,
|
||||
prefix=f"{settings.API_V1_STR}",
|
||||
)
|
||||
|
||||
|
||||
# Placeholder routers for future implementation
|
||||
# These will be implemented in subsequent phases
|
||||
|
||||
# app.include_router(
|
||||
# items_router,
|
||||
# prefix=f"{settings.API_V1_STR}",
|
||||
# tags=["items"],
|
||||
# )
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=settings.ENVIRONMENT == "development",
|
||||
)
|
||||
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})>"
|
||||
25
backend/app/routers/__init__.py
Normal file
25
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
API routers package.
|
||||
"""
|
||||
|
||||
from app.routers.admin import router as admin_router
|
||||
from app.routers.ai import router as ai_router
|
||||
from app.routers.auth import router as auth_router
|
||||
from app.routers.import_export import router as import_export_router
|
||||
from app.routers.reports import router as reports_router
|
||||
from app.routers.sessions import router as sessions_router
|
||||
from app.routers.tryouts import router as tryouts_router
|
||||
from app.routers.wordpress import router as wordpress_router
|
||||
from app.routers.websites import router as websites_router
|
||||
|
||||
__all__ = [
|
||||
"admin_router",
|
||||
"ai_router",
|
||||
"auth_router",
|
||||
"import_export_router",
|
||||
"reports_router",
|
||||
"sessions_router",
|
||||
"tryouts_router",
|
||||
"wordpress_router",
|
||||
"websites_router",
|
||||
]
|
||||
1077
backend/app/routers/admin.py
Normal file
1077
backend/app/routers/admin.py
Normal file
File diff suppressed because it is too large
Load Diff
530
backend/app/routers/ai.py
Normal file
530
backend/app/routers/ai.py
Normal file
@@ -0,0 +1,530 @@
|
||||
"""
|
||||
AI Generation Router.
|
||||
|
||||
Admin endpoints for AI question generation playground.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.auth import (
|
||||
AuthContext,
|
||||
ensure_website_scope_matches,
|
||||
get_auth_context,
|
||||
require_website_auth,
|
||||
)
|
||||
from app.core.rate_limit import enforce_rate_limit
|
||||
from app.database import get_db
|
||||
from app.models.item import Item
|
||||
from app.schemas.ai import (
|
||||
AIBatchGeneratedItem,
|
||||
AIGenerateBatchRequest,
|
||||
AIGenerateBatchResponse,
|
||||
AIGeneratePreviewRequest,
|
||||
AIGeneratePreviewResponse,
|
||||
AISaveRequest,
|
||||
AISaveResponse,
|
||||
AIStatsResponse,
|
||||
)
|
||||
from app.services.ai_generation import (
|
||||
SUPPORTED_MODELS,
|
||||
combine_usage,
|
||||
create_generation_run,
|
||||
generate_question,
|
||||
generate_questions_batch,
|
||||
generated_matches_basis_options,
|
||||
get_ai_stats,
|
||||
get_model_pricing,
|
||||
save_ai_question,
|
||||
validate_ai_model,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
router = APIRouter(prefix="/admin/ai", tags=["admin", "ai-generation"])
|
||||
|
||||
|
||||
def _validate_original_basis_item(basis_item: Item) -> None:
|
||||
if basis_item.level != "sedang":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Basis item must be 'sedang' level, got: {basis_item.level}",
|
||||
)
|
||||
if basis_item.generated_by == "ai":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Basis item must be an original question, not an AI-generated variant.",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate-preview",
|
||||
response_model=AIGeneratePreviewResponse,
|
||||
summary="Preview AI-generated question",
|
||||
description="""
|
||||
Generate a question preview using AI without saving to database.
|
||||
|
||||
This is an admin playground endpoint for testing AI generation quality.
|
||||
Admins can retry unlimited times until satisfied with the result.
|
||||
|
||||
Requirements:
|
||||
- basis_item_id must reference an existing item at 'sedang' level
|
||||
- target_level must be 'mudah' or 'sulit'
|
||||
- ai_model must be a supported OpenRouter model
|
||||
""",
|
||||
responses={
|
||||
200: {"description": "Question generated successfully (preview mode)"},
|
||||
400: {"description": "Invalid request (wrong level, unsupported model)"},
|
||||
404: {"description": "Basis item not found"},
|
||||
500: {"description": "AI generation failed"},
|
||||
},
|
||||
)
|
||||
async def generate_preview(
|
||||
request_http: Request,
|
||||
request: AIGeneratePreviewRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> AIGeneratePreviewResponse:
|
||||
"""
|
||||
Generate AI question preview (no database save).
|
||||
|
||||
- **basis_item_id**: ID of the sedang-level question to base generation on
|
||||
- **target_level**: Target difficulty (mudah/sulit)
|
||||
- **ai_model**: OpenRouter model to use (default: qwen/qwen2.5-32b-instruct)
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
await enforce_rate_limit(
|
||||
request_http,
|
||||
scope="ai.generate_preview",
|
||||
max_requests=40,
|
||||
window_seconds=300,
|
||||
)
|
||||
|
||||
# Validate AI model
|
||||
if not validate_ai_model(request.ai_model):
|
||||
supported = ", ".join(SUPPORTED_MODELS.keys())
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported AI model: {request.ai_model}. "
|
||||
f"Supported models: {supported}",
|
||||
)
|
||||
|
||||
# Fetch basis item
|
||||
result = await db.execute(
|
||||
select(Item).where(Item.id == request.basis_item_id)
|
||||
)
|
||||
basis_item = result.scalar_one_or_none()
|
||||
|
||||
if not basis_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Basis item not found: {request.basis_item_id}",
|
||||
)
|
||||
ensure_website_scope_matches(website_id, basis_item.website_id)
|
||||
|
||||
_validate_original_basis_item(basis_item)
|
||||
|
||||
# Generate question
|
||||
try:
|
||||
generated = await generate_question(
|
||||
basis_item=basis_item,
|
||||
target_level=request.target_level,
|
||||
ai_model=request.ai_model,
|
||||
)
|
||||
|
||||
if not generated:
|
||||
return AIGeneratePreviewResponse(
|
||||
success=False,
|
||||
error="AI generation failed. Please check logs or try again.",
|
||||
ai_model=request.ai_model,
|
||||
basis_item_id=request.basis_item_id,
|
||||
target_level=request.target_level,
|
||||
)
|
||||
|
||||
return AIGeneratePreviewResponse(
|
||||
success=True,
|
||||
stem=generated.stem,
|
||||
options=generated.options,
|
||||
correct=generated.correct,
|
||||
explanation=generated.explanation,
|
||||
usage=generated.usage,
|
||||
ai_model=request.ai_model,
|
||||
basis_item_id=request.basis_item_id,
|
||||
target_level=request.target_level,
|
||||
cached=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AI preview generation failed: {e}")
|
||||
return AIGeneratePreviewResponse(
|
||||
success=False,
|
||||
error=f"AI generation error: {str(e)}",
|
||||
ai_model=request.ai_model,
|
||||
basis_item_id=request.basis_item_id,
|
||||
target_level=request.target_level,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate-save",
|
||||
response_model=AISaveResponse,
|
||||
summary="Save AI-generated question",
|
||||
description="""
|
||||
Save an AI-generated question to the database.
|
||||
|
||||
This endpoint creates a new Item record with:
|
||||
- generated_by='ai'
|
||||
- ai_model from request
|
||||
- basis_item_id linking to original question
|
||||
- calibrated=False (will be calculated later)
|
||||
""",
|
||||
responses={
|
||||
200: {"description": "Question saved successfully"},
|
||||
400: {"description": "Invalid request data"},
|
||||
404: {"description": "Basis item or tryout not found"},
|
||||
500: {"description": "Database save failed"},
|
||||
},
|
||||
)
|
||||
async def generate_save(
|
||||
request_http: Request,
|
||||
request: AISaveRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> AISaveResponse:
|
||||
"""
|
||||
Save AI-generated question to database.
|
||||
|
||||
- **stem**: Question text
|
||||
- **options**: Dict with the same option labels as the basis item
|
||||
- **correct**: Correct answer label from the generated options
|
||||
- **explanation**: Answer explanation (optional)
|
||||
- **tryout_id**: Tryout identifier
|
||||
- **website_id**: Website identifier
|
||||
- **basis_item_id**: Original item ID this was generated from
|
||||
- **slot**: Question slot position
|
||||
- **level**: Difficulty level
|
||||
- **ai_model**: AI model used for generation
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
await enforce_rate_limit(
|
||||
request_http,
|
||||
scope="ai.generate_save",
|
||||
max_requests=40,
|
||||
window_seconds=300,
|
||||
)
|
||||
ensure_website_scope_matches(website_id, request.website_id)
|
||||
|
||||
# Verify basis item exists
|
||||
basis_result = await db.execute(
|
||||
select(Item).where(Item.id == request.basis_item_id)
|
||||
)
|
||||
basis_item = basis_result.scalar_one_or_none()
|
||||
|
||||
if not basis_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Basis item not found: {request.basis_item_id}",
|
||||
)
|
||||
ensure_website_scope_matches(website_id, basis_item.website_id)
|
||||
_validate_original_basis_item(basis_item)
|
||||
|
||||
# Create GeneratedQuestion from request
|
||||
from app.schemas.ai import GeneratedQuestion
|
||||
|
||||
generated_data = GeneratedQuestion(
|
||||
stem=request.stem,
|
||||
options=request.options,
|
||||
correct=request.correct,
|
||||
explanation=request.explanation,
|
||||
)
|
||||
if not generated_matches_basis_options(generated_data, basis_item):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Generated options must match the basis question option labels exactly.",
|
||||
)
|
||||
|
||||
run_id = await create_generation_run(
|
||||
basis_item_id=basis_item.id,
|
||||
source_snapshot_question_id=basis_item.source_snapshot_question_id,
|
||||
target_level=request.level,
|
||||
requested_count=1,
|
||||
model=request.ai_model,
|
||||
created_by=auth.wp_user_id or auth.role,
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Save to database
|
||||
item_id = await save_ai_question(
|
||||
generated_data=generated_data,
|
||||
tryout_id=request.tryout_id,
|
||||
website_id=request.website_id,
|
||||
basis_item_id=request.basis_item_id,
|
||||
slot=request.slot,
|
||||
level=request.level,
|
||||
ai_model=request.ai_model,
|
||||
generation_run_id=run_id,
|
||||
source_snapshot_question_id=basis_item.source_snapshot_question_id,
|
||||
variant_status=request.variant_status,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if not item_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to save AI-generated question",
|
||||
)
|
||||
|
||||
return AISaveResponse(
|
||||
success=True,
|
||||
item_id=item_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate-batch",
|
||||
response_model=AIGenerateBatchResponse,
|
||||
summary="Generate and save AI question batch",
|
||||
description="Generate multiple trusted active variants from one medium-level basis question and track the run.",
|
||||
)
|
||||
async def generate_batch(
|
||||
request_http: Request,
|
||||
request: AIGenerateBatchRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> AIGenerateBatchResponse:
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
await enforce_rate_limit(
|
||||
request_http,
|
||||
scope="ai.generate_batch",
|
||||
max_requests=10,
|
||||
window_seconds=300,
|
||||
)
|
||||
|
||||
if not validate_ai_model(request.ai_model):
|
||||
supported = ", ".join(SUPPORTED_MODELS.keys())
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported AI model: {request.ai_model}. Supported models: {supported}",
|
||||
)
|
||||
|
||||
result = await db.execute(select(Item).where(Item.id == request.basis_item_id))
|
||||
basis_item = result.scalar_one_or_none()
|
||||
if not basis_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Basis item not found: {request.basis_item_id}",
|
||||
)
|
||||
ensure_website_scope_matches(website_id, basis_item.website_id)
|
||||
_validate_original_basis_item(basis_item)
|
||||
|
||||
run_id = await create_generation_run(
|
||||
basis_item_id=basis_item.id,
|
||||
source_snapshot_question_id=basis_item.source_snapshot_question_id,
|
||||
target_level=request.target_level,
|
||||
requested_count=request.count,
|
||||
model=request.ai_model,
|
||||
created_by=auth.wp_user_id or auth.role,
|
||||
operator_notes=request.operator_notes,
|
||||
db=db,
|
||||
)
|
||||
|
||||
generated_questions = await generate_questions_batch(
|
||||
basis_item=basis_item,
|
||||
target_level=request.target_level,
|
||||
ai_model=request.ai_model,
|
||||
count=request.count,
|
||||
operator_notes=request.operator_notes,
|
||||
)
|
||||
item_ids: list[int] = []
|
||||
response_items: list[AIBatchGeneratedItem] = []
|
||||
for generated in generated_questions:
|
||||
item_id = await save_ai_question(
|
||||
generated_data=generated,
|
||||
tryout_id=basis_item.tryout_id,
|
||||
website_id=basis_item.website_id,
|
||||
basis_item_id=basis_item.id,
|
||||
slot=basis_item.slot,
|
||||
level=request.target_level,
|
||||
ai_model=request.ai_model,
|
||||
db=db,
|
||||
generation_run_id=run_id,
|
||||
source_snapshot_question_id=basis_item.source_snapshot_question_id,
|
||||
variant_status="active",
|
||||
)
|
||||
if item_id is not None:
|
||||
item_ids.append(item_id)
|
||||
response_items.append(
|
||||
AIBatchGeneratedItem(
|
||||
item_id=item_id,
|
||||
stem=generated.stem,
|
||||
options=generated.options,
|
||||
correct=generated.correct,
|
||||
explanation=generated.explanation,
|
||||
level=request.target_level,
|
||||
variant_status="active",
|
||||
usage=generated.usage,
|
||||
)
|
||||
)
|
||||
|
||||
if not item_ids:
|
||||
return AIGenerateBatchResponse(
|
||||
success=False,
|
||||
run_id=run_id,
|
||||
generated_count=0,
|
||||
error="AI generation failed. No variants were saved.",
|
||||
)
|
||||
|
||||
return AIGenerateBatchResponse(
|
||||
success=True,
|
||||
run_id=run_id,
|
||||
item_ids=item_ids,
|
||||
items=response_items,
|
||||
generated_count=len(item_ids),
|
||||
usage=combine_usage([item.usage for item in response_items]),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=AIStatsResponse,
|
||||
summary="Get AI generation statistics",
|
||||
description="""
|
||||
Get statistics about AI-generated questions.
|
||||
|
||||
Returns:
|
||||
- Total AI-generated items count
|
||||
- Items count by model
|
||||
- Cache hit rate (placeholder)
|
||||
""",
|
||||
)
|
||||
async def get_stats(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> AIStatsResponse:
|
||||
"""
|
||||
Get AI generation statistics.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
stats = await get_ai_stats(db, website_id=website_id)
|
||||
|
||||
return AIStatsResponse(
|
||||
total_ai_items=stats["total_ai_items"],
|
||||
items_by_model=stats["items_by_model"],
|
||||
cache_hit_rate=stats["cache_hit_rate"],
|
||||
total_cache_hits=stats["total_cache_hits"],
|
||||
total_requests=stats["total_requests"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/models",
|
||||
summary="List supported AI models",
|
||||
description="Returns list of supported AI models for question generation.",
|
||||
)
|
||||
async def list_models(auth: AuthContext = Depends(get_auth_context)) -> dict:
|
||||
"""
|
||||
List supported AI models.
|
||||
"""
|
||||
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
configured_models = [
|
||||
{
|
||||
"id": settings.OPENROUTER_MODEL_CHEAP,
|
||||
"name": "Mistral Small 4",
|
||||
"description": "Cheap and fast option for routine variant generation",
|
||||
},
|
||||
{
|
||||
"id": settings.OPENROUTER_MODEL_QWEN,
|
||||
"name": "Qwen 2.5 32B Instruct",
|
||||
"description": "Balanced default for structured soal generation",
|
||||
},
|
||||
{
|
||||
"id": settings.OPENROUTER_MODEL_LLAMA,
|
||||
"name": "Llama 3.3 70B",
|
||||
"description": "Premium fallback when you want better quality over cost",
|
||||
},
|
||||
]
|
||||
|
||||
models = []
|
||||
for model in configured_models:
|
||||
pricing = await get_model_pricing(model["id"])
|
||||
models.append({**model, "pricing": pricing})
|
||||
return {"models": models}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/pending-reviews",
|
||||
summary="Get pending AI generated questions",
|
||||
description="Retrieve all AI generated questions that are pending review (variant_status='draft').",
|
||||
)
|
||||
async def admin_get_pending_reviews(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> dict:
|
||||
"""Retrieve pending reviews."""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
|
||||
query = (
|
||||
select(Item)
|
||||
.where(Item.generated_by == "ai", Item.variant_status == "draft")
|
||||
.order_by(Item.created_at.desc())
|
||||
.limit(200)
|
||||
)
|
||||
if website_id is not None:
|
||||
query = query.where(Item.website_id == website_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": i.id,
|
||||
"tryout_id": i.tryout_id,
|
||||
"level": i.level,
|
||||
"stem_text": i.stem_text if hasattr(i, 'stem_text') else i.stem[:100],
|
||||
"ai_model": i.ai_model,
|
||||
"basis_item_id": i.basis_item_id,
|
||||
"created_at": i.created_at,
|
||||
"status": i.variant_status,
|
||||
}
|
||||
for i in items
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/review/{item_id}",
|
||||
summary="Approve or reject AI generated question",
|
||||
description="Update the variant_status of an AI generated question.",
|
||||
)
|
||||
async def admin_review_ai_question(
|
||||
item_id: int,
|
||||
status: str, # "active", "rejected"
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> dict:
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
|
||||
result = await db.execute(select(Item).where(Item.id == item_id))
|
||||
item = result.scalar_one_or_none()
|
||||
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
if website_id is not None and item.website_id != website_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized for this website")
|
||||
|
||||
if status not in ["active", "rejected"]:
|
||||
raise HTTPException(status_code=400, detail="Status must be active or rejected")
|
||||
|
||||
item.variant_status = status
|
||||
await db.commit()
|
||||
|
||||
return {"success": True, "item_id": item_id, "status": status}
|
||||
60
backend/app/routers/auth.py
Normal file
60
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Authentication endpoints.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.auth import issue_access_token
|
||||
from app.core.config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.post(
|
||||
"/admin-login",
|
||||
summary="Admin Login",
|
||||
description="Login for standalone app administration.",
|
||||
)
|
||||
async def admin_login(request: LoginRequest) -> Dict[str, Any]:
|
||||
"""Authenticate an app admin and issue a JWT token."""
|
||||
if not settings.ENABLE_ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin functionality is disabled.",
|
||||
)
|
||||
|
||||
if not settings.ADMIN_USERNAME or not settings.ADMIN_PASSWORD:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Admin credentials not configured.",
|
||||
)
|
||||
|
||||
if (
|
||||
request.username != settings.ADMIN_USERNAME
|
||||
or request.password != settings.ADMIN_PASSWORD
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
|
||||
token = issue_access_token(
|
||||
website_id=None,
|
||||
role="system_admin",
|
||||
expires_in_seconds=86400 * 7, # 7 days
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"role": "system_admin",
|
||||
}
|
||||
424
backend/app/routers/import_export.py
Normal file
424
backend/app/routers/import_export.py
Normal file
@@ -0,0 +1,424 @@
|
||||
"""
|
||||
Import/Export API router for migration and snapshot ingestion.
|
||||
|
||||
Endpoints:
|
||||
- POST /api/v1/import/preview: Preview Excel import without saving
|
||||
- POST /api/v1/import/questions: Import questions from Excel to database
|
||||
- GET /api/v1/export/questions: Export questions to Excel file
|
||||
- POST /api/v1/import-export/tryout-json/preview: Preview Sejoli tryout JSON import
|
||||
- POST /api/v1/import-export/tryout-json: Import Sejoli tryout JSON as read-only snapshot
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.auth import AuthContext, get_auth_context, require_website_auth
|
||||
from app.core.rate_limit import enforce_rate_limit
|
||||
from app.database import get_db
|
||||
from app.models import Website
|
||||
from app.services.excel_import import (
|
||||
bulk_insert_items,
|
||||
export_questions_to_excel,
|
||||
parse_excel_import,
|
||||
validate_excel_structure,
|
||||
)
|
||||
from app.services.tryout_json_import import (
|
||||
TryoutImportError,
|
||||
import_tryout_json_snapshot,
|
||||
preview_tryout_json_import,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/import-export", tags=["import-export"])
|
||||
|
||||
|
||||
async def ensure_website_exists(
|
||||
website_id: int,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
website = await db.get(Website, website_id)
|
||||
if website is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=(
|
||||
f"Website {website_id} not found. Website registration is stored in the database, "
|
||||
"not in .env."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/preview",
|
||||
summary="Preview Excel import",
|
||||
description="Parse Excel file and return preview without saving to database.",
|
||||
)
|
||||
async def preview_import(
|
||||
request: Request,
|
||||
file: UploadFile = File(..., description="Excel file (.xlsx)"),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> dict:
|
||||
"""
|
||||
Preview Excel import without saving to database.
|
||||
|
||||
Args:
|
||||
file: Excel file upload (.xlsx format)
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- items_count: Number of items parsed
|
||||
- preview: List of item previews
|
||||
- validation_errors: List of validation errors if any
|
||||
|
||||
Raises:
|
||||
HTTPException: If file format is invalid or parsing fails
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
await enforce_rate_limit(
|
||||
request,
|
||||
scope="import.preview",
|
||||
max_requests=30,
|
||||
window_seconds=300,
|
||||
)
|
||||
|
||||
# Validate file format
|
||||
if not file.filename or not file.filename.lower().endswith('.xlsx'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File must be .xlsx format",
|
||||
)
|
||||
|
||||
# Save uploaded file to temporary location
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as temp_file:
|
||||
content = await file.read()
|
||||
temp_file.write(content)
|
||||
temp_file_path = temp_file.name
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to save uploaded file: {str(e)}",
|
||||
)
|
||||
|
||||
try:
|
||||
# Validate Excel structure
|
||||
validation = validate_excel_structure(temp_file_path)
|
||||
if not validation["valid"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"error": "Invalid Excel structure",
|
||||
"validation_errors": validation["errors"],
|
||||
},
|
||||
)
|
||||
|
||||
# Parse Excel (tryout_id is optional for preview)
|
||||
tryout_id = "preview" # Use dummy tryout_id for preview
|
||||
result = parse_excel_import(
|
||||
temp_file_path,
|
||||
website_id=website_id,
|
||||
tryout_id=tryout_id
|
||||
)
|
||||
|
||||
if result["validation_errors"]:
|
||||
return {
|
||||
"items_count": result["items_count"],
|
||||
"preview": result["items"],
|
||||
"validation_errors": result["validation_errors"],
|
||||
"has_errors": True,
|
||||
}
|
||||
|
||||
# Return limited preview (first 5 items)
|
||||
preview_items = result["items"][:5]
|
||||
|
||||
return {
|
||||
"items_count": result["items_count"],
|
||||
"preview": preview_items,
|
||||
"validation_errors": [],
|
||||
"has_errors": False,
|
||||
}
|
||||
|
||||
finally:
|
||||
# Clean up temporary file
|
||||
if os.path.exists(temp_file_path):
|
||||
os.unlink(temp_file_path)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/questions",
|
||||
summary="Import questions from Excel",
|
||||
description="Parse Excel file and import questions to database with 100% data integrity.",
|
||||
)
|
||||
async def import_questions(
|
||||
request: Request,
|
||||
file: UploadFile = File(..., description="Excel file (.xlsx)"),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
tryout_id: str = Form(..., description="Tryout identifier"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
Import questions from Excel to database.
|
||||
|
||||
Validates file format, parses Excel content, checks for duplicates,
|
||||
and performs bulk insert with rollback on error.
|
||||
|
||||
Args:
|
||||
file: Excel file upload (.xlsx format)
|
||||
website_id: Website ID from header
|
||||
tryout_id: Tryout identifier
|
||||
db: Async database session
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- imported: Number of items successfully imported
|
||||
- duplicates: Number of duplicate items skipped
|
||||
- errors: List of errors if any
|
||||
|
||||
Raises:
|
||||
HTTPException: If file format is invalid, validation fails, or import fails
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
await enforce_rate_limit(
|
||||
request,
|
||||
scope="import.questions",
|
||||
max_requests=20,
|
||||
window_seconds=300,
|
||||
)
|
||||
|
||||
# Validate file format
|
||||
if not file.filename or not file.filename.lower().endswith('.xlsx'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File must be .xlsx format",
|
||||
)
|
||||
|
||||
# Save uploaded file to temporary location
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as temp_file:
|
||||
content = await file.read()
|
||||
temp_file.write(content)
|
||||
temp_file_path = temp_file.name
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to save uploaded file: {str(e)}",
|
||||
)
|
||||
|
||||
try:
|
||||
# Validate Excel structure
|
||||
validation = validate_excel_structure(temp_file_path)
|
||||
if not validation["valid"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"error": "Invalid Excel structure",
|
||||
"validation_errors": validation["errors"],
|
||||
},
|
||||
)
|
||||
|
||||
# Parse Excel
|
||||
result = parse_excel_import(
|
||||
temp_file_path,
|
||||
website_id=website_id,
|
||||
tryout_id=tryout_id
|
||||
)
|
||||
|
||||
# Check for validation errors
|
||||
if result["validation_errors"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"error": "Validation failed",
|
||||
"validation_errors": result["validation_errors"],
|
||||
},
|
||||
)
|
||||
|
||||
# Check if items were parsed
|
||||
if result["items_count"] == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No items found in Excel file",
|
||||
)
|
||||
|
||||
# Bulk insert items
|
||||
insert_result = await bulk_insert_items(result["items"], db)
|
||||
|
||||
# Check for insertion errors
|
||||
if insert_result["errors"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"error": "Import failed",
|
||||
"errors": insert_result["errors"],
|
||||
},
|
||||
)
|
||||
|
||||
# Check for conflicts (duplicates)
|
||||
if insert_result["duplicate_count"] > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"message": f"Import completed with {insert_result['duplicate_count']} duplicate(s) skipped",
|
||||
"imported": insert_result["inserted_count"],
|
||||
"duplicates": insert_result["duplicate_count"],
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Import successful",
|
||||
"imported": insert_result["inserted_count"],
|
||||
"duplicates": insert_result["duplicate_count"],
|
||||
}
|
||||
|
||||
finally:
|
||||
# Clean up temporary file
|
||||
if os.path.exists(temp_file_path):
|
||||
os.unlink(temp_file_path)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/export/questions",
|
||||
summary="Export questions to Excel",
|
||||
description="Export questions for a tryout to Excel file in standardized format.",
|
||||
)
|
||||
async def export_questions(
|
||||
tryout_id: str,
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> FileResponse:
|
||||
"""
|
||||
Export questions to Excel file.
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
website_id: Website ID from header
|
||||
db: Async database session
|
||||
|
||||
Returns:
|
||||
FileResponse with Excel file
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout has no questions or export fails
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
|
||||
try:
|
||||
# Export questions to Excel
|
||||
output_path = await export_questions_to_excel(
|
||||
tryout_id=tryout_id,
|
||||
website_id=website_id,
|
||||
db=db
|
||||
)
|
||||
|
||||
# Return file for download
|
||||
filename = f"tryout_{tryout_id}_questions.xlsx"
|
||||
return FileResponse(
|
||||
path=output_path,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Export failed: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tryout-json/preview",
|
||||
summary="Preview Sejoli tryout JSON import",
|
||||
description="Parse a Sejoli tryout export JSON file and show snapshot diff without writing to database.",
|
||||
)
|
||||
async def preview_tryout_json(
|
||||
request: Request,
|
||||
file: UploadFile = File(..., description="Sejoli tryout export JSON"),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
await enforce_rate_limit(
|
||||
request,
|
||||
scope="import.tryout_json_preview",
|
||||
max_requests=30,
|
||||
window_seconds=300,
|
||||
)
|
||||
if not file.filename or not file.filename.lower().endswith(".json"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File must be .json format",
|
||||
)
|
||||
|
||||
await ensure_website_exists(website_id, db)
|
||||
|
||||
try:
|
||||
payload = json.loads((await file.read()).decode("utf-8"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid JSON file: {str(e)}",
|
||||
)
|
||||
|
||||
try:
|
||||
return await preview_tryout_json_import(payload, website_id, db)
|
||||
except TryoutImportError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tryout-json",
|
||||
summary="Import Sejoli tryout JSON snapshot",
|
||||
description="Store Sejoli tryout export JSON as read-only snapshot data and upsert normalized reference questions.",
|
||||
)
|
||||
async def import_tryout_json(
|
||||
request: Request,
|
||||
file: UploadFile = File(..., description="Sejoli tryout export JSON"),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
if website_id is None:
|
||||
x_website_id = request.headers.get("x-website-id")
|
||||
if not x_website_id or not x_website_id.isdigit():
|
||||
raise HTTPException(status_code=400, detail="X-Website-ID header is required for system_admin")
|
||||
website_id = int(x_website_id)
|
||||
await enforce_rate_limit(
|
||||
request,
|
||||
scope="import.tryout_json",
|
||||
max_requests=20,
|
||||
window_seconds=300,
|
||||
)
|
||||
if not file.filename or not file.filename.lower().endswith(".json"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File must be .json format",
|
||||
)
|
||||
|
||||
await ensure_website_exists(website_id, db)
|
||||
|
||||
try:
|
||||
payload = json.loads((await file.read()).decode("utf-8"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid JSON file: {str(e)}",
|
||||
)
|
||||
|
||||
try:
|
||||
return await import_tryout_json_snapshot(payload, website_id, db)
|
||||
except TryoutImportError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
279
backend/app/routers/normalization.py
Normal file
279
backend/app/routers/normalization.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Normalization API router for dynamic normalization management.
|
||||
|
||||
Endpoints:
|
||||
- GET /tryout/{tryout_id}/normalization: Get normalization configuration
|
||||
- PUT /tryout/{tryout_id}/normalization: Update normalization settings
|
||||
- POST /tryout/{tryout_id}/normalization/reset: Reset normalization stats
|
||||
- GET /tryout/{tryout_id}/normalization/validate: Validate dynamic normalization
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.services.config_management import (
|
||||
get_normalization_config,
|
||||
reset_normalization_stats,
|
||||
toggle_normalization_mode,
|
||||
update_config,
|
||||
)
|
||||
from app.services.normalization import (
|
||||
validate_dynamic_normalization,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/tryout", tags=["normalization"])
|
||||
|
||||
|
||||
def get_website_id_from_header(
|
||||
x_website_id: Optional[str] = Header(None, alias="X-Website-ID"),
|
||||
) -> int:
|
||||
"""
|
||||
Extract and validate website_id from request header.
|
||||
|
||||
Args:
|
||||
x_website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
Validated website ID as integer
|
||||
|
||||
Raises:
|
||||
HTTPException: If header is missing or invalid
|
||||
"""
|
||||
if x_website_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="X-Website-ID header is required",
|
||||
)
|
||||
try:
|
||||
return int(x_website_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="X-Website-ID must be a valid integer",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{tryout_id}/normalization",
|
||||
summary="Get normalization configuration",
|
||||
description="Retrieve current normalization configuration including mode, static values, dynamic values, and threshold status.",
|
||||
)
|
||||
async def get_normalization_endpoint(
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
website_id: int = Depends(get_website_id_from_header),
|
||||
):
|
||||
"""
|
||||
Get normalization configuration for a tryout.
|
||||
|
||||
Returns:
|
||||
Normalization configuration with:
|
||||
- mode (static/dynamic/hybrid)
|
||||
- current rataan, sb (from TryoutStats)
|
||||
- static_rataan, static_sb (from Tryout config)
|
||||
- participant_count
|
||||
- threshold_status (ready for dynamic or not)
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout not found
|
||||
"""
|
||||
try:
|
||||
config = await get_normalization_config(db, website_id, tryout_id)
|
||||
return config
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{tryout_id}/normalization",
|
||||
summary="Update normalization settings",
|
||||
description="Update normalization mode and static values for a tryout.",
|
||||
)
|
||||
async def update_normalization_endpoint(
|
||||
tryout_id: str,
|
||||
normalization_mode: Optional[str] = None,
|
||||
static_rataan: Optional[float] = None,
|
||||
static_sb: Optional[float] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
website_id: int = Depends(get_website_id_from_header),
|
||||
):
|
||||
"""
|
||||
Update normalization settings for a tryout.
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
normalization_mode: New normalization mode (static/dynamic/hybrid)
|
||||
static_rataan: New static mean value
|
||||
static_sb: New static standard deviation
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
Updated normalization configuration
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout not found or validation fails
|
||||
"""
|
||||
# Build updates dictionary
|
||||
updates = {}
|
||||
|
||||
if normalization_mode is not None:
|
||||
if normalization_mode not in ["static", "dynamic", "hybrid"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid normalization_mode: {normalization_mode}. Must be 'static', 'dynamic', or 'hybrid'",
|
||||
)
|
||||
updates["normalization_mode"] = normalization_mode
|
||||
|
||||
if static_rataan is not None:
|
||||
if static_rataan <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="static_rataan must be greater than 0",
|
||||
)
|
||||
updates["static_rataan"] = static_rataan
|
||||
|
||||
if static_sb is not None:
|
||||
if static_sb <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="static_sb must be greater than 0",
|
||||
)
|
||||
updates["static_sb"] = static_sb
|
||||
|
||||
if not updates:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No updates provided",
|
||||
)
|
||||
|
||||
try:
|
||||
# Update configuration
|
||||
await update_config(db, website_id, tryout_id, updates)
|
||||
|
||||
# Get updated configuration
|
||||
config = await get_normalization_config(db, website_id, tryout_id)
|
||||
|
||||
return config
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{tryout_id}/normalization/reset",
|
||||
summary="Reset normalization stats",
|
||||
description="Reset TryoutStats to initial values and switch to static normalization mode.",
|
||||
)
|
||||
async def reset_normalization_endpoint(
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
website_id: int = Depends(get_website_id_from_header),
|
||||
):
|
||||
"""
|
||||
Reset normalization stats for a tryout.
|
||||
|
||||
Resets TryoutStats to initial values (participant_count=0, sums cleared)
|
||||
and temporarily switches normalization_mode to "static".
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
Success message with updated configuration
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout not found
|
||||
"""
|
||||
try:
|
||||
stats = await reset_normalization_stats(db, website_id, tryout_id)
|
||||
config = await get_normalization_config(db, website_id, tryout_id)
|
||||
|
||||
return {
|
||||
"message": "Normalization stats reset successfully",
|
||||
"tryout_id": tryout_id,
|
||||
"participant_count": stats.participant_count,
|
||||
"normalization_mode": config["normalization_mode"],
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{tryout_id}/normalization/validate",
|
||||
summary="Validate dynamic normalization",
|
||||
description="Validate that dynamic normalization produces expected distribution (mean≈500±5, SD≈100±5).",
|
||||
)
|
||||
async def validate_normalization_endpoint(
|
||||
tryout_id: str,
|
||||
target_mean: float = 500.0,
|
||||
target_sd: float = 100.0,
|
||||
mean_tolerance: float = 5.0,
|
||||
sd_tolerance: float = 5.0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
website_id: int = Depends(get_website_id_from_header),
|
||||
):
|
||||
"""
|
||||
Validate dynamic normalization for a tryout.
|
||||
|
||||
Checks if calculated rataan and sb are close to target values.
|
||||
Returns validation status, deviations, warnings, and suggestions.
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
target_mean: Target mean (default: 500)
|
||||
target_sd: Target standard deviation (default: 100)
|
||||
mean_tolerance: Allowed deviation from target mean (default: 5)
|
||||
sd_tolerance: Allowed deviation from target SD (default: 5)
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
Validation result with:
|
||||
- is_valid: True if within tolerance
|
||||
- details: Full validation details
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout not found
|
||||
"""
|
||||
try:
|
||||
is_valid, details = await validate_dynamic_normalization(
|
||||
db=db,
|
||||
website_id=website_id,
|
||||
tryout_id=tryout_id,
|
||||
target_mean=target_mean,
|
||||
target_sd=target_sd,
|
||||
mean_tolerance=mean_tolerance,
|
||||
sd_tolerance=sd_tolerance,
|
||||
)
|
||||
|
||||
return {
|
||||
"tryout_id": tryout_id,
|
||||
"is_valid": is_valid,
|
||||
"target_mean": target_mean,
|
||||
"target_sd": target_sd,
|
||||
"mean_tolerance": mean_tolerance,
|
||||
"sd_tolerance": sd_tolerance,
|
||||
"details": details,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
803
backend/app/routers/reports.py
Normal file
803
backend/app/routers/reports.py
Normal file
@@ -0,0 +1,803 @@
|
||||
"""
|
||||
Reports API router for comprehensive reporting.
|
||||
|
||||
Endpoints:
|
||||
- GET /reports/student/performance: Get student performance report
|
||||
- GET /reports/items/analysis: Get item analysis report
|
||||
- GET /reports/calibration/status: Get calibration status report
|
||||
- GET /reports/tryout/comparison: Get tryout comparison report
|
||||
- POST /reports/schedule: Schedule a report
|
||||
- GET /reports/export/{schedule_id}/{format}: Export scheduled report
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.auth import (
|
||||
AuthContext,
|
||||
ensure_website_scope_matches,
|
||||
get_auth_context,
|
||||
require_website_auth,
|
||||
)
|
||||
from app.schemas.report import (
|
||||
StudentPerformanceReportOutput,
|
||||
AggregatePerformanceStatsOutput,
|
||||
StudentPerformanceRecordOutput,
|
||||
ItemAnalysisReportOutput,
|
||||
ItemAnalysisRecordOutput,
|
||||
CalibrationStatusReportOutput,
|
||||
CalibrationItemStatusOutput,
|
||||
TryoutComparisonReportOutput,
|
||||
TryoutComparisonRecordOutput,
|
||||
ReportScheduleRequest,
|
||||
ReportScheduleOutput,
|
||||
ReportScheduleResponse,
|
||||
ExportResponse,
|
||||
)
|
||||
from app.services.reporting import (
|
||||
generate_student_performance_report,
|
||||
generate_item_analysis_report,
|
||||
generate_calibration_status_report,
|
||||
generate_tryout_comparison_report,
|
||||
export_report_to_csv,
|
||||
export_report_to_excel,
|
||||
export_report_to_pdf,
|
||||
schedule_report,
|
||||
get_scheduled_report,
|
||||
list_scheduled_reports,
|
||||
cancel_scheduled_report,
|
||||
StudentPerformanceReport,
|
||||
ItemAnalysisReport,
|
||||
CalibrationStatusReport,
|
||||
TryoutComparisonReport,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/reports", tags=["reports"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Student Performance Report Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@router.get(
|
||||
"/student/performance",
|
||||
response_model=StudentPerformanceReportOutput,
|
||||
summary="Get student performance report",
|
||||
description="Generate student performance report with individual and aggregate statistics.",
|
||||
)
|
||||
async def get_student_performance_report(
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
date_start: Optional[datetime] = None,
|
||||
date_end: Optional[datetime] = None,
|
||||
format_type: Literal["individual", "aggregate", "both"] = "both",
|
||||
) -> StudentPerformanceReportOutput:
|
||||
"""
|
||||
Get student performance report.
|
||||
|
||||
Returns individual student records and/or aggregate statistics.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||
scoped_wp_user_id = None
|
||||
if auth.role == "student":
|
||||
if not auth.wp_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Student reports require an authenticated WordPress user",
|
||||
)
|
||||
scoped_wp_user_id = auth.wp_user_id
|
||||
|
||||
date_range = None
|
||||
if date_start or date_end:
|
||||
date_range = {}
|
||||
if date_start:
|
||||
date_range["start"] = date_start
|
||||
if date_end:
|
||||
date_range["end"] = date_end
|
||||
|
||||
report = await generate_student_performance_report(
|
||||
tryout_id=tryout_id,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
date_range=date_range,
|
||||
format_type=format_type,
|
||||
wp_user_id=scoped_wp_user_id,
|
||||
)
|
||||
|
||||
return _convert_student_performance_report(report)
|
||||
|
||||
|
||||
def _convert_student_performance_report(report: StudentPerformanceReport) -> StudentPerformanceReportOutput:
|
||||
"""Convert dataclass report to Pydantic output."""
|
||||
date_range_str = None
|
||||
if report.date_range:
|
||||
date_range_str = {}
|
||||
if report.date_range.get("start"):
|
||||
date_range_str["start"] = report.date_range["start"].isoformat()
|
||||
if report.date_range.get("end"):
|
||||
date_range_str["end"] = report.date_range["end"].isoformat()
|
||||
|
||||
return StudentPerformanceReportOutput(
|
||||
generated_at=report.generated_at,
|
||||
tryout_id=report.tryout_id,
|
||||
website_id=report.website_id,
|
||||
date_range=date_range_str,
|
||||
aggregate=AggregatePerformanceStatsOutput(
|
||||
tryout_id=report.aggregate.tryout_id,
|
||||
participant_count=report.aggregate.participant_count,
|
||||
avg_nm=report.aggregate.avg_nm,
|
||||
std_nm=report.aggregate.std_nm,
|
||||
min_nm=report.aggregate.min_nm,
|
||||
max_nm=report.aggregate.max_nm,
|
||||
median_nm=report.aggregate.median_nm,
|
||||
avg_nn=report.aggregate.avg_nn,
|
||||
std_nn=report.aggregate.std_nn,
|
||||
avg_theta=report.aggregate.avg_theta,
|
||||
pass_rate=report.aggregate.pass_rate,
|
||||
avg_time_spent=report.aggregate.avg_time_spent,
|
||||
),
|
||||
individual_records=[
|
||||
StudentPerformanceRecordOutput(
|
||||
session_id=r.session_id,
|
||||
wp_user_id=r.wp_user_id,
|
||||
tryout_id=r.tryout_id,
|
||||
NM=r.NM,
|
||||
NN=r.NN,
|
||||
theta=r.theta,
|
||||
theta_se=r.theta_se,
|
||||
total_benar=r.total_benar,
|
||||
time_spent=r.time_spent,
|
||||
start_time=r.start_time,
|
||||
end_time=r.end_time,
|
||||
scoring_mode_used=r.scoring_mode_used,
|
||||
rataan_used=r.rataan_used,
|
||||
sb_used=r.sb_used,
|
||||
)
|
||||
for r in report.individual_records
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Item Analysis Report Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@router.get(
|
||||
"/items/analysis",
|
||||
response_model=ItemAnalysisReportOutput,
|
||||
summary="Get item analysis report",
|
||||
description="Generate item analysis report with difficulty, discrimination, and information functions.",
|
||||
)
|
||||
async def get_item_analysis_report(
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
filter_by: Optional[Literal["difficulty", "calibrated", "discrimination"]] = None,
|
||||
difficulty_level: Optional[Literal["mudah", "sedang", "sulit"]] = None,
|
||||
) -> ItemAnalysisReportOutput:
|
||||
"""
|
||||
Get item analysis report.
|
||||
|
||||
Returns item difficulty, discrimination, and information function data.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
report = await generate_item_analysis_report(
|
||||
tryout_id=tryout_id,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
filter_by=filter_by,
|
||||
difficulty_level=difficulty_level,
|
||||
)
|
||||
|
||||
return ItemAnalysisReportOutput(
|
||||
generated_at=report.generated_at,
|
||||
tryout_id=report.tryout_id,
|
||||
website_id=report.website_id,
|
||||
total_items=report.total_items,
|
||||
items=[
|
||||
ItemAnalysisRecordOutput(
|
||||
item_id=r.item_id,
|
||||
slot=r.slot,
|
||||
level=r.level,
|
||||
ctt_p=r.ctt_p,
|
||||
ctt_bobot=r.ctt_bobot,
|
||||
ctt_category=r.ctt_category,
|
||||
irt_b=r.irt_b,
|
||||
irt_se=r.irt_se,
|
||||
calibrated=r.calibrated,
|
||||
calibration_sample_size=r.calibration_sample_size,
|
||||
correctness_rate=r.correctness_rate,
|
||||
item_total_correlation=r.item_total_correlation,
|
||||
information_values=r.information_values,
|
||||
optimal_theta_range=r.optimal_theta_range,
|
||||
)
|
||||
for r in report.items
|
||||
],
|
||||
summary=report.summary,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Calibration Status Report Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@router.get(
|
||||
"/calibration/status",
|
||||
response_model=CalibrationStatusReportOutput,
|
||||
summary="Get calibration status report",
|
||||
description="Generate calibration status report with progress tracking and readiness metrics.",
|
||||
)
|
||||
async def get_calibration_status_report(
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> CalibrationStatusReportOutput:
|
||||
"""
|
||||
Get calibration status report.
|
||||
|
||||
Returns calibration progress, items awaiting calibration, and IRT readiness status.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
report = await generate_calibration_status_report(
|
||||
tryout_id=tryout_id,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return CalibrationStatusReportOutput(
|
||||
generated_at=report.generated_at,
|
||||
tryout_id=report.tryout_id,
|
||||
website_id=report.website_id,
|
||||
total_items=report.total_items,
|
||||
calibrated_items=report.calibrated_items,
|
||||
calibration_percentage=report.calibration_percentage,
|
||||
items_awaiting_calibration=[
|
||||
CalibrationItemStatusOutput(
|
||||
item_id=r.item_id,
|
||||
slot=r.slot,
|
||||
level=r.level,
|
||||
sample_size=r.sample_size,
|
||||
calibrated=r.calibrated,
|
||||
irt_b=r.irt_b,
|
||||
irt_se=r.irt_se,
|
||||
ctt_p=r.ctt_p,
|
||||
)
|
||||
for r in report.items_awaiting_calibration
|
||||
],
|
||||
avg_calibration_sample_size=report.avg_calibration_sample_size,
|
||||
estimated_time_to_90_percent=report.estimated_time_to_90_percent,
|
||||
ready_for_irt_rollout=report.ready_for_irt_rollout,
|
||||
items=[
|
||||
CalibrationItemStatusOutput(
|
||||
item_id=r.item_id,
|
||||
slot=r.slot,
|
||||
level=r.level,
|
||||
sample_size=r.sample_size,
|
||||
calibrated=r.calibrated,
|
||||
irt_b=r.irt_b,
|
||||
irt_se=r.irt_se,
|
||||
ctt_p=r.ctt_p,
|
||||
)
|
||||
for r in report.items
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tryout Comparison Report Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@router.get(
|
||||
"/tryout/comparison",
|
||||
response_model=TryoutComparisonReportOutput,
|
||||
summary="Get tryout comparison report",
|
||||
description="Generate tryout comparison report across dates or subjects.",
|
||||
)
|
||||
async def get_tryout_comparison_report(
|
||||
tryout_ids: str, # Comma-separated list
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
group_by: Literal["date", "subject"] = "date",
|
||||
) -> TryoutComparisonReportOutput:
|
||||
"""
|
||||
Get tryout comparison report.
|
||||
|
||||
Compares tryouts across dates or subjects.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
tryout_id_list = [tid.strip() for tid in tryout_ids.split(",")]
|
||||
|
||||
if len(tryout_id_list) < 2:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="At least 2 tryout IDs are required for comparison",
|
||||
)
|
||||
|
||||
report = await generate_tryout_comparison_report(
|
||||
tryout_ids=tryout_id_list,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
group_by=group_by,
|
||||
)
|
||||
|
||||
return TryoutComparisonReportOutput(
|
||||
generated_at=report.generated_at,
|
||||
comparison_type=report.comparison_type,
|
||||
tryouts=[
|
||||
TryoutComparisonRecordOutput(
|
||||
tryout_id=r.tryout_id,
|
||||
date=r.date,
|
||||
subject=r.subject,
|
||||
participant_count=r.participant_count,
|
||||
avg_nm=r.avg_nm,
|
||||
avg_nn=r.avg_nn,
|
||||
avg_theta=r.avg_theta,
|
||||
std_nm=r.std_nm,
|
||||
calibration_percentage=r.calibration_percentage,
|
||||
)
|
||||
for r in report.tryouts
|
||||
],
|
||||
trends=report.trends,
|
||||
normalization_impact=report.normalization_impact,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Report Scheduling Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@router.post(
|
||||
"/schedule",
|
||||
response_model=ReportScheduleResponse,
|
||||
summary="Schedule a report",
|
||||
description="Schedule a report for automatic generation on a daily, weekly, or monthly basis.",
|
||||
)
|
||||
async def create_report_schedule(
|
||||
request: ReportScheduleRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> ReportScheduleResponse:
|
||||
"""
|
||||
Schedule a report.
|
||||
|
||||
Creates a scheduled report that will be generated automatically.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
ensure_website_scope_matches(website_id, request.website_id)
|
||||
schedule_id = await schedule_report(
|
||||
db,
|
||||
report_type=request.report_type,
|
||||
schedule=request.schedule,
|
||||
tryout_ids=request.tryout_ids,
|
||||
website_id=request.website_id,
|
||||
recipients=request.recipients,
|
||||
export_format=request.export_format,
|
||||
)
|
||||
|
||||
scheduled = await get_scheduled_report(db, schedule_id)
|
||||
|
||||
return ReportScheduleResponse(
|
||||
schedule_id=schedule_id,
|
||||
message=f"Report scheduled successfully for {request.schedule} generation",
|
||||
next_run=scheduled.next_run if scheduled else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/schedule/{schedule_id}",
|
||||
response_model=ReportScheduleOutput,
|
||||
summary="Get scheduled report details",
|
||||
description="Get details of a scheduled report.",
|
||||
)
|
||||
async def get_scheduled_report_details(
|
||||
schedule_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> ReportScheduleOutput:
|
||||
"""
|
||||
Get scheduled report details.
|
||||
|
||||
Returns the configuration and status of a scheduled report.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
scheduled = await get_scheduled_report(db, schedule_id)
|
||||
|
||||
if not scheduled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Scheduled report {schedule_id} not found",
|
||||
)
|
||||
|
||||
if scheduled.website_id != website_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this scheduled report",
|
||||
)
|
||||
|
||||
return ReportScheduleOutput(
|
||||
schedule_id=scheduled.schedule_id,
|
||||
report_type=scheduled.report_type,
|
||||
schedule=scheduled.schedule,
|
||||
tryout_ids=scheduled.tryout_ids,
|
||||
website_id=scheduled.website_id,
|
||||
recipients=scheduled.recipients,
|
||||
format=scheduled.format,
|
||||
created_at=scheduled.created_at,
|
||||
last_run=scheduled.last_run,
|
||||
next_run=scheduled.next_run,
|
||||
is_active=scheduled.is_active,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/schedule",
|
||||
response_model=List[ReportScheduleOutput],
|
||||
summary="List scheduled reports",
|
||||
description="List all scheduled reports for a website.",
|
||||
)
|
||||
async def list_scheduled_reports_endpoint(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> List[ReportScheduleOutput]:
|
||||
"""
|
||||
List all scheduled reports.
|
||||
|
||||
Returns all scheduled reports for the current website.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
reports = await list_scheduled_reports(db, website_id=website_id)
|
||||
|
||||
return [
|
||||
ReportScheduleOutput(
|
||||
schedule_id=r.schedule_id,
|
||||
report_type=r.report_type,
|
||||
schedule=r.schedule,
|
||||
tryout_ids=r.tryout_ids,
|
||||
website_id=r.website_id,
|
||||
recipients=r.recipients,
|
||||
format=r.format,
|
||||
created_at=r.created_at,
|
||||
last_run=r.last_run,
|
||||
next_run=r.next_run,
|
||||
is_active=r.is_active,
|
||||
)
|
||||
for r in reports
|
||||
]
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/schedule/{schedule_id}",
|
||||
summary="Cancel scheduled report",
|
||||
description="Cancel a scheduled report.",
|
||||
)
|
||||
async def cancel_scheduled_report_endpoint(
|
||||
schedule_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> dict:
|
||||
"""
|
||||
Cancel a scheduled report.
|
||||
|
||||
Removes the scheduled report from the system.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
scheduled = await get_scheduled_report(db, schedule_id)
|
||||
|
||||
if not scheduled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Scheduled report {schedule_id} not found",
|
||||
)
|
||||
|
||||
if scheduled.website_id != website_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this scheduled report",
|
||||
)
|
||||
|
||||
success = await cancel_scheduled_report(db, schedule_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to cancel scheduled report",
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"Scheduled report {schedule_id} cancelled successfully",
|
||||
"schedule_id": schedule_id,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Report Export Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@router.get(
|
||||
"/export/{schedule_id}/{format}",
|
||||
summary="Export scheduled report",
|
||||
description="Generate and export a scheduled report in the specified format.",
|
||||
)
|
||||
async def export_scheduled_report(
|
||||
schedule_id: str,
|
||||
format: Literal["csv", "xlsx", "pdf"],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
"""
|
||||
Export a scheduled report.
|
||||
|
||||
Generates the report and returns it as a file download.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
scheduled = await get_scheduled_report(db, schedule_id)
|
||||
|
||||
if not scheduled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Scheduled report {schedule_id} not found",
|
||||
)
|
||||
|
||||
if scheduled.website_id != website_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this scheduled report",
|
||||
)
|
||||
if not scheduled.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Scheduled report is inactive",
|
||||
)
|
||||
|
||||
# Generate report based on type
|
||||
report = None
|
||||
base_filename = f"report_{scheduled.report_type}_{schedule_id}"
|
||||
|
||||
try:
|
||||
if scheduled.report_type == "student_performance":
|
||||
if len(scheduled.tryout_ids) > 0:
|
||||
report = await generate_student_performance_report(
|
||||
tryout_id=scheduled.tryout_ids[0],
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
)
|
||||
elif scheduled.report_type == "item_analysis":
|
||||
if len(scheduled.tryout_ids) > 0:
|
||||
report = await generate_item_analysis_report(
|
||||
tryout_id=scheduled.tryout_ids[0],
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
)
|
||||
elif scheduled.report_type == "calibration_status":
|
||||
if len(scheduled.tryout_ids) > 0:
|
||||
report = await generate_calibration_status_report(
|
||||
tryout_id=scheduled.tryout_ids[0],
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
)
|
||||
elif scheduled.report_type == "tryout_comparison":
|
||||
report = await generate_tryout_comparison_report(
|
||||
tryout_ids=scheduled.tryout_ids,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if not report:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to generate report",
|
||||
)
|
||||
|
||||
# Export to requested format
|
||||
if format == "csv":
|
||||
file_path = export_report_to_csv(report, base_filename)
|
||||
media_type = "text/csv"
|
||||
elif format == "xlsx":
|
||||
file_path = export_report_to_excel(report, base_filename)
|
||||
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
else: # pdf
|
||||
file_path = export_report_to_pdf(report, base_filename)
|
||||
media_type = "application/pdf"
|
||||
|
||||
# Return file
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
media_type=media_type,
|
||||
filename=os.path.basename(file_path),
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to export report: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Direct Export Endpoints (without scheduling)
|
||||
# =============================================================================
|
||||
|
||||
@router.get(
|
||||
"/student/performance/export/{format}",
|
||||
summary="Export student performance report directly",
|
||||
description="Generate and export student performance report directly without scheduling.",
|
||||
)
|
||||
async def export_student_performance_direct(
|
||||
format: Literal["csv", "xlsx", "pdf"],
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
date_start: Optional[datetime] = None,
|
||||
date_end: Optional[datetime] = None,
|
||||
):
|
||||
"""Export student performance report directly."""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
date_range = None
|
||||
if date_start or date_end:
|
||||
date_range = {}
|
||||
if date_start:
|
||||
date_range["start"] = date_start
|
||||
if date_end:
|
||||
date_range["end"] = date_end
|
||||
|
||||
report = await generate_student_performance_report(
|
||||
tryout_id=tryout_id,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
date_range=date_range,
|
||||
)
|
||||
|
||||
base_filename = f"student_performance_{tryout_id}"
|
||||
|
||||
if format == "csv":
|
||||
file_path = export_report_to_csv(report, base_filename)
|
||||
media_type = "text/csv"
|
||||
elif format == "xlsx":
|
||||
file_path = export_report_to_excel(report, base_filename)
|
||||
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
else:
|
||||
file_path = export_report_to_pdf(report, base_filename)
|
||||
media_type = "application/pdf"
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
media_type=media_type,
|
||||
filename=os.path.basename(file_path),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/items/analysis/export/{format}",
|
||||
summary="Export item analysis report directly",
|
||||
description="Generate and export item analysis report directly without scheduling.",
|
||||
)
|
||||
async def export_item_analysis_direct(
|
||||
format: Literal["csv", "xlsx", "pdf"],
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
filter_by: Optional[Literal["difficulty", "calibrated", "discrimination"]] = None,
|
||||
difficulty_level: Optional[Literal["mudah", "sedang", "sulit"]] = None,
|
||||
):
|
||||
"""Export item analysis report directly."""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
report = await generate_item_analysis_report(
|
||||
tryout_id=tryout_id,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
filter_by=filter_by,
|
||||
difficulty_level=difficulty_level,
|
||||
)
|
||||
|
||||
base_filename = f"item_analysis_{tryout_id}"
|
||||
|
||||
if format == "csv":
|
||||
file_path = export_report_to_csv(report, base_filename)
|
||||
media_type = "text/csv"
|
||||
elif format == "xlsx":
|
||||
file_path = export_report_to_excel(report, base_filename)
|
||||
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
else:
|
||||
file_path = export_report_to_pdf(report, base_filename)
|
||||
media_type = "application/pdf"
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
media_type=media_type,
|
||||
filename=os.path.basename(file_path),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/calibration/status/export/{format}",
|
||||
summary="Export calibration status report directly",
|
||||
description="Generate and export calibration status report directly without scheduling.",
|
||||
)
|
||||
async def export_calibration_status_direct(
|
||||
format: Literal["csv", "xlsx", "pdf"],
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
"""Export calibration status report directly."""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
report = await generate_calibration_status_report(
|
||||
tryout_id=tryout_id,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
base_filename = f"calibration_status_{tryout_id}"
|
||||
|
||||
if format == "csv":
|
||||
file_path = export_report_to_csv(report, base_filename)
|
||||
media_type = "text/csv"
|
||||
elif format == "xlsx":
|
||||
file_path = export_report_to_excel(report, base_filename)
|
||||
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
else:
|
||||
file_path = export_report_to_pdf(report, base_filename)
|
||||
media_type = "application/pdf"
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
media_type=media_type,
|
||||
filename=os.path.basename(file_path),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tryout/comparison/export/{format}",
|
||||
summary="Export tryout comparison report directly",
|
||||
description="Generate and export tryout comparison report directly without scheduling.",
|
||||
)
|
||||
async def export_tryout_comparison_direct(
|
||||
format: Literal["csv", "xlsx", "pdf"],
|
||||
tryout_ids: str, # Comma-separated
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
group_by: Literal["date", "subject"] = "date",
|
||||
):
|
||||
"""Export tryout comparison report directly."""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
tryout_id_list = [tid.strip() for tid in tryout_ids.split(",")]
|
||||
|
||||
if len(tryout_id_list) < 2:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="At least 2 tryout IDs are required for comparison",
|
||||
)
|
||||
|
||||
report = await generate_tryout_comparison_report(
|
||||
tryout_ids=tryout_id_list,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
group_by=group_by,
|
||||
)
|
||||
|
||||
base_filename = "tryout_comparison"
|
||||
|
||||
if format == "csv":
|
||||
file_path = export_report_to_csv(report, base_filename)
|
||||
media_type = "text/csv"
|
||||
elif format == "xlsx":
|
||||
file_path = export_report_to_excel(report, base_filename)
|
||||
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
else:
|
||||
file_path = export_report_to_pdf(report, base_filename)
|
||||
media_type = "application/pdf"
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
media_type=media_type,
|
||||
filename=os.path.basename(file_path),
|
||||
)
|
||||
455
backend/app/routers/sessions.py
Normal file
455
backend/app/routers/sessions.py
Normal file
@@ -0,0 +1,455 @@
|
||||
"""
|
||||
Session API router for tryout session management.
|
||||
|
||||
Endpoints:
|
||||
- POST /session/{session_id}/complete: Submit answers and complete session
|
||||
- GET /session/{session_id}: Get session details
|
||||
- POST /session: Create new session
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.auth import (
|
||||
AuthContext,
|
||||
ensure_website_scope_matches,
|
||||
get_auth_context,
|
||||
require_website_auth,
|
||||
)
|
||||
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.schemas.session import (
|
||||
SessionCompleteRequest,
|
||||
SessionCompleteResponse,
|
||||
SessionCreateRequest,
|
||||
SessionResponse,
|
||||
UserAnswerOutput,
|
||||
)
|
||||
from app.services.ctt_scoring import (
|
||||
calculate_ctt_bobot,
|
||||
calculate_ctt_nm,
|
||||
calculate_ctt_nn,
|
||||
get_total_bobot_max,
|
||||
update_tryout_stats,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/session", tags=["sessions"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{session_id}/complete",
|
||||
response_model=SessionCompleteResponse,
|
||||
summary="Complete session with answers",
|
||||
description="Submit user answers, calculate CTT scores, and complete the session.",
|
||||
)
|
||||
async def complete_session(
|
||||
session_id: str,
|
||||
request: SessionCompleteRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> SessionCompleteResponse:
|
||||
"""
|
||||
Complete a session by submitting answers and calculating CTT scores.
|
||||
|
||||
Process:
|
||||
1. Validate session exists and is not completed
|
||||
2. For each answer: check is_correct, calculate bobot_earned
|
||||
3. Save UserAnswer records
|
||||
4. Calculate CTT scores (total_benar, total_bobot_earned, NM)
|
||||
5. Update Session with CTT results
|
||||
6. Update TryoutStats incrementally
|
||||
7. Return session with scores
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier
|
||||
request: Session completion request with end_time and user_answers
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
SessionCompleteResponse with CTT scores
|
||||
|
||||
Raises:
|
||||
HTTPException: If session not found, already completed, or validation fails
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||
|
||||
# Get session with tryout relationship
|
||||
session_query = (
|
||||
select(Session)
|
||||
.options(selectinload(Session.tryout))
|
||||
.where(Session.session_id == session_id)
|
||||
)
|
||||
if website_id is not None:
|
||||
session_query = session_query.where(Session.website_id == website_id)
|
||||
|
||||
result = await db.execute(session_query)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if session is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Session {session_id} not found",
|
||||
)
|
||||
|
||||
if session.is_completed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Session is already completed",
|
||||
)
|
||||
if auth.role == "student" and session.wp_user_id != auth.wp_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Session does not belong to this authenticated user",
|
||||
)
|
||||
|
||||
effective_website_id = session.website_id
|
||||
|
||||
# Get tryout configuration
|
||||
tryout = session.tryout
|
||||
|
||||
# Get all items for this tryout to calculate bobot
|
||||
items_result = await db.execute(
|
||||
select(Item).where(
|
||||
Item.website_id == effective_website_id,
|
||||
Item.tryout_id == session.tryout_id,
|
||||
)
|
||||
)
|
||||
items = {item.id: item for item in items_result.scalars().all()}
|
||||
|
||||
existing_answers_full_result = await db.execute(
|
||||
select(UserAnswer).where(UserAnswer.session_id == session.session_id)
|
||||
)
|
||||
existing_answer_records = list(existing_answers_full_result.scalars().all())
|
||||
|
||||
# Process each answer
|
||||
submitted_item_ids = [answer.item_id for answer in request.user_answers]
|
||||
if len(submitted_item_ids) != len(set(submitted_item_ids)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Duplicate item answers are not allowed in a session completion",
|
||||
)
|
||||
|
||||
existing_answered_item_ids = {answer.item_id for answer in existing_answer_records}
|
||||
duplicate_existing_ids = sorted(set(submitted_item_ids) & existing_answered_item_ids)
|
||||
if duplicate_existing_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"message": "One or more items were already answered for this session",
|
||||
"item_ids": duplicate_existing_ids,
|
||||
},
|
||||
)
|
||||
|
||||
total_benar = 0
|
||||
total_bobot_earned = 0.0
|
||||
user_answer_records = []
|
||||
|
||||
if request.user_answers:
|
||||
answers_to_score = request.user_answers
|
||||
else:
|
||||
answers_to_score = []
|
||||
user_answer_records = existing_answer_records
|
||||
total_benar = sum(1 for answer in existing_answer_records if answer.is_correct)
|
||||
total_bobot_earned = sum(answer.bobot_earned or 0.0 for answer in existing_answer_records)
|
||||
|
||||
for answer_input in answers_to_score:
|
||||
item = items.get(answer_input.item_id)
|
||||
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Item {answer_input.item_id} not found in tryout {session.tryout_id}",
|
||||
)
|
||||
|
||||
# Check if answer is correct
|
||||
is_correct = answer_input.response.upper() == item.correct_answer.upper()
|
||||
|
||||
# Calculate bobot_earned (only if correct)
|
||||
bobot_earned = 0.0
|
||||
if is_correct:
|
||||
total_benar += 1
|
||||
if item.ctt_bobot is not None:
|
||||
bobot_earned = item.ctt_bobot
|
||||
total_bobot_earned += bobot_earned
|
||||
|
||||
# Create UserAnswer record
|
||||
user_answer = UserAnswer(
|
||||
session_id=session.session_id,
|
||||
wp_user_id=session.wp_user_id,
|
||||
website_id=effective_website_id,
|
||||
tryout_id=session.tryout_id,
|
||||
item_id=item.id,
|
||||
response=answer_input.response.upper(),
|
||||
is_correct=is_correct,
|
||||
time_spent=answer_input.time_spent,
|
||||
scoring_mode_used=session.scoring_mode_used,
|
||||
bobot_earned=bobot_earned,
|
||||
)
|
||||
user_answer_records.append(user_answer)
|
||||
db.add(user_answer)
|
||||
|
||||
# Calculate total_bobot_max for NM calculation
|
||||
try:
|
||||
total_bobot_max = await get_total_bobot_max(
|
||||
db, effective_website_id, session.tryout_id, level="sedang"
|
||||
)
|
||||
except ValueError:
|
||||
# Fallback: calculate from items we have
|
||||
total_bobot_max = sum(
|
||||
item.ctt_bobot or 0 for item in items.values() if item.level == "sedang"
|
||||
)
|
||||
if total_bobot_max == 0:
|
||||
# If no bobot values, use count of questions
|
||||
total_bobot_max = len(items)
|
||||
|
||||
# Calculate CTT NM (Nilai Mentah)
|
||||
nm = calculate_ctt_nm(total_bobot_earned, total_bobot_max)
|
||||
|
||||
# Get normalization parameters based on tryout configuration
|
||||
if tryout.normalization_mode == "static":
|
||||
rataan = tryout.static_rataan
|
||||
sb = tryout.static_sb
|
||||
elif tryout.normalization_mode == "dynamic":
|
||||
# Get current stats for dynamic normalization
|
||||
stats_result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == effective_website_id,
|
||||
TryoutStats.tryout_id == session.tryout_id,
|
||||
)
|
||||
)
|
||||
stats = stats_result.scalar_one_or_none()
|
||||
|
||||
if stats and stats.participant_count >= tryout.min_sample_for_dynamic:
|
||||
rataan = stats.rataan or tryout.static_rataan
|
||||
sb = stats.sb or tryout.static_sb
|
||||
else:
|
||||
# Not enough data, use static values
|
||||
rataan = tryout.static_rataan
|
||||
sb = tryout.static_sb
|
||||
else: # hybrid
|
||||
# Hybrid: use dynamic if enough data, otherwise static
|
||||
stats_result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == effective_website_id,
|
||||
TryoutStats.tryout_id == session.tryout_id,
|
||||
)
|
||||
)
|
||||
stats = stats_result.scalar_one_or_none()
|
||||
|
||||
if stats and stats.participant_count >= tryout.min_sample_for_dynamic:
|
||||
rataan = stats.rataan or tryout.static_rataan
|
||||
sb = stats.sb or tryout.static_sb
|
||||
else:
|
||||
rataan = tryout.static_rataan
|
||||
sb = tryout.static_sb
|
||||
|
||||
# Calculate CTT NN (Nilai Nasional)
|
||||
nn = calculate_ctt_nn(nm, rataan, sb)
|
||||
|
||||
# Update session with results
|
||||
session.end_time = request.end_time
|
||||
session.is_completed = True
|
||||
session.total_benar = total_benar
|
||||
session.total_bobot_earned = total_bobot_earned
|
||||
session.NM = nm
|
||||
session.NN = nn
|
||||
session.rataan_used = rataan
|
||||
session.sb_used = sb
|
||||
|
||||
# Update tryout stats incrementally
|
||||
await update_tryout_stats(db, effective_website_id, session.tryout_id, nm)
|
||||
|
||||
# Commit all changes
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Duplicate item answer detected for this session",
|
||||
) from exc
|
||||
|
||||
# Refresh to get updated relationships
|
||||
await db.refresh(session)
|
||||
|
||||
# Build response
|
||||
return SessionCompleteResponse(
|
||||
id=session.id,
|
||||
session_id=session.session_id,
|
||||
wp_user_id=session.wp_user_id,
|
||||
website_id=session.website_id,
|
||||
tryout_id=session.tryout_id,
|
||||
start_time=session.start_time,
|
||||
end_time=session.end_time,
|
||||
expires_at=session.expires_at,
|
||||
is_completed=session.is_completed,
|
||||
scoring_mode_used=session.scoring_mode_used,
|
||||
total_benar=session.total_benar,
|
||||
total_bobot_earned=session.total_bobot_earned,
|
||||
NM=session.NM,
|
||||
NN=session.NN,
|
||||
rataan_used=session.rataan_used,
|
||||
sb_used=session.sb_used,
|
||||
user_answers=[
|
||||
UserAnswerOutput(
|
||||
id=ua.id,
|
||||
item_id=ua.item_id,
|
||||
response=ua.response,
|
||||
time_spent=ua.time_spent,
|
||||
bobot_earned=ua.bobot_earned,
|
||||
scoring_mode_used=ua.scoring_mode_used,
|
||||
)
|
||||
for ua in user_answer_records
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{session_id}",
|
||||
response_model=SessionResponse,
|
||||
summary="Get session details",
|
||||
description="Retrieve session details including scores if completed.",
|
||||
)
|
||||
async def get_session(
|
||||
session_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> SessionResponse:
|
||||
"""
|
||||
Get session details.
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
SessionResponse with session details
|
||||
|
||||
Raises:
|
||||
HTTPException: If session not found
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||
|
||||
session_query = select(Session).where(Session.session_id == session_id)
|
||||
if website_id is not None:
|
||||
session_query = session_query.where(Session.website_id == website_id)
|
||||
|
||||
result = await db.execute(session_query)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if session is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Session {session_id} not found",
|
||||
)
|
||||
if auth.role == "student" and session.wp_user_id != auth.wp_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Session does not belong to this authenticated user",
|
||||
)
|
||||
|
||||
return SessionResponse.model_validate(session)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=SessionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create new session",
|
||||
description="Create a new tryout session for a student.",
|
||||
)
|
||||
async def create_session(
|
||||
request: SessionCreateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> SessionResponse:
|
||||
"""
|
||||
Create a new session.
|
||||
|
||||
Args:
|
||||
request: Session creation request
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
SessionResponse with created session
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout not found or session already exists
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||
|
||||
ensure_website_scope_matches(website_id, request.website_id)
|
||||
effective_website_id = website_id if website_id is not None else request.website_id
|
||||
if auth.role == "student" and request.wp_user_id != auth.wp_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="wp_user_id must match authenticated user",
|
||||
)
|
||||
|
||||
# Verify tryout exists
|
||||
tryout_result = await db.execute(
|
||||
select(Tryout).where(
|
||||
Tryout.website_id == effective_website_id,
|
||||
Tryout.tryout_id == request.tryout_id,
|
||||
)
|
||||
)
|
||||
tryout = tryout_result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tryout {request.tryout_id} not found for website {effective_website_id}",
|
||||
)
|
||||
|
||||
# Check if session already exists
|
||||
existing_result = await db.execute(
|
||||
select(Session).where(Session.session_id == request.session_id)
|
||||
)
|
||||
existing_session = existing_result.scalar_one_or_none()
|
||||
|
||||
if existing_session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Session {request.session_id} already exists",
|
||||
)
|
||||
|
||||
user_result = await db.execute(
|
||||
select(User).where(
|
||||
User.wp_user_id == request.wp_user_id,
|
||||
User.website_id == effective_website_id,
|
||||
)
|
||||
)
|
||||
if user_result.scalar_one_or_none() is None:
|
||||
db.add(User(wp_user_id=request.wp_user_id, website_id=effective_website_id))
|
||||
|
||||
started_at = datetime.now(timezone.utc)
|
||||
|
||||
# Create new session
|
||||
session = Session(
|
||||
session_id=request.session_id,
|
||||
wp_user_id=request.wp_user_id,
|
||||
website_id=effective_website_id,
|
||||
tryout_id=request.tryout_id,
|
||||
scoring_mode_used=request.scoring_mode,
|
||||
start_time=started_at,
|
||||
expires_at=started_at + timedelta(hours=2),
|
||||
is_completed=False,
|
||||
total_benar=0,
|
||||
total_bobot_earned=0.0,
|
||||
)
|
||||
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
return SessionResponse.model_validate(session)
|
||||
528
backend/app/routers/tryouts.py
Normal file
528
backend/app/routers/tryouts.py
Normal file
@@ -0,0 +1,528 @@
|
||||
"""
|
||||
Tryout API router for tryout configuration and management.
|
||||
|
||||
Endpoints:
|
||||
- GET /tryout/{tryout_id}/config: Get tryout configuration
|
||||
- PUT /tryout/{tryout_id}/normalization: Update normalization settings
|
||||
- GET /tryout: List tryouts for a website
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import Integer, cast, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.auth import AuthContext, get_auth_context, require_website_auth
|
||||
from app.models.item import Item
|
||||
from app.models.tryout import Tryout
|
||||
from app.models.tryout_stats import TryoutStats
|
||||
from app.models.tryout_snapshot_question import TryoutSnapshotQuestion
|
||||
from app.schemas.tryout import (
|
||||
NormalizationUpdateRequest,
|
||||
NormalizationUpdateResponse,
|
||||
TryoutConfigBrief,
|
||||
TryoutConfigResponse,
|
||||
TryoutConfigUpdateRequest,
|
||||
TryoutStatsResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/tryout", tags=["tryouts"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{tryout_id}/config",
|
||||
response_model=TryoutConfigResponse,
|
||||
summary="Get tryout configuration",
|
||||
description="Retrieve tryout configuration including scoring mode, normalization settings, and current stats.",
|
||||
)
|
||||
async def get_tryout_config(
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> TryoutConfigResponse:
|
||||
"""
|
||||
Get tryout configuration.
|
||||
|
||||
Returns:
|
||||
TryoutConfigResponse with scoring_mode, normalization_mode, and current_stats
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout not found
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||
|
||||
# Get tryout with stats
|
||||
query = (
|
||||
select(Tryout)
|
||||
.options(selectinload(Tryout.stats))
|
||||
.where(Tryout.tryout_id == tryout_id)
|
||||
)
|
||||
if website_id is not None:
|
||||
query = query.where(Tryout.website_id == website_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
tryout = result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tryout {tryout_id} not found for website {website_id}",
|
||||
)
|
||||
|
||||
# Build stats response
|
||||
current_stats = None
|
||||
if tryout.stats:
|
||||
current_stats = TryoutStatsResponse(
|
||||
participant_count=tryout.stats.participant_count,
|
||||
rataan=tryout.stats.rataan,
|
||||
sb=tryout.stats.sb,
|
||||
min_nm=tryout.stats.min_nm,
|
||||
max_nm=tryout.stats.max_nm,
|
||||
last_calculated=tryout.stats.last_calculated,
|
||||
)
|
||||
|
||||
return TryoutConfigResponse(
|
||||
id=tryout.id,
|
||||
website_id=tryout.website_id,
|
||||
tryout_id=tryout.tryout_id,
|
||||
name=tryout.name,
|
||||
description=tryout.description,
|
||||
scoring_mode=tryout.scoring_mode,
|
||||
selection_mode=tryout.selection_mode,
|
||||
normalization_mode=tryout.normalization_mode,
|
||||
min_sample_for_dynamic=tryout.min_sample_for_dynamic,
|
||||
static_rataan=tryout.static_rataan,
|
||||
static_sb=tryout.static_sb,
|
||||
ai_generation_enabled=tryout.ai_generation_enabled,
|
||||
hybrid_transition_slot=tryout.hybrid_transition_slot,
|
||||
min_calibration_sample=tryout.min_calibration_sample,
|
||||
theta_estimation_method=tryout.theta_estimation_method,
|
||||
fallback_to_ctt_on_error=tryout.fallback_to_ctt_on_error,
|
||||
current_stats=current_stats,
|
||||
created_at=tryout.created_at,
|
||||
updated_at=tryout.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{tryout_id}/config",
|
||||
response_model=TryoutConfigResponse,
|
||||
summary="Update tryout configuration",
|
||||
description="Update editable tryout configuration fields.",
|
||||
)
|
||||
async def update_tryout_config(
|
||||
tryout_id: str,
|
||||
request: TryoutConfigUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> TryoutConfigResponse:
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
|
||||
query = select(Tryout).options(selectinload(Tryout.stats)).where(Tryout.tryout_id == tryout_id)
|
||||
if website_id is not None:
|
||||
query = query.where(Tryout.website_id == website_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
tryout = result.scalar_one_or_none()
|
||||
if tryout is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tryout {tryout_id} not found for website {website_id}",
|
||||
)
|
||||
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(tryout, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(tryout)
|
||||
|
||||
current_stats = None
|
||||
if tryout.stats:
|
||||
current_stats = TryoutStatsResponse(
|
||||
participant_count=tryout.stats.participant_count,
|
||||
rataan=tryout.stats.rataan,
|
||||
sb=tryout.stats.sb,
|
||||
min_nm=tryout.stats.min_nm,
|
||||
max_nm=tryout.stats.max_nm,
|
||||
last_calculated=tryout.stats.last_calculated,
|
||||
)
|
||||
|
||||
return TryoutConfigResponse(
|
||||
id=tryout.id,
|
||||
website_id=tryout.website_id,
|
||||
tryout_id=tryout.tryout_id,
|
||||
name=tryout.name,
|
||||
description=tryout.description,
|
||||
scoring_mode=tryout.scoring_mode,
|
||||
selection_mode=tryout.selection_mode,
|
||||
normalization_mode=tryout.normalization_mode,
|
||||
min_sample_for_dynamic=tryout.min_sample_for_dynamic,
|
||||
static_rataan=tryout.static_rataan,
|
||||
static_sb=tryout.static_sb,
|
||||
ai_generation_enabled=tryout.ai_generation_enabled,
|
||||
hybrid_transition_slot=tryout.hybrid_transition_slot,
|
||||
min_calibration_sample=tryout.min_calibration_sample,
|
||||
theta_estimation_method=tryout.theta_estimation_method,
|
||||
fallback_to_ctt_on_error=tryout.fallback_to_ctt_on_error,
|
||||
current_stats=current_stats,
|
||||
created_at=tryout.created_at,
|
||||
updated_at=tryout.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{tryout_id}/normalization",
|
||||
response_model=NormalizationUpdateResponse,
|
||||
summary="Update normalization settings",
|
||||
description="Update normalization mode and static values for a tryout.",
|
||||
)
|
||||
async def update_normalization(
|
||||
tryout_id: str,
|
||||
request: NormalizationUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> NormalizationUpdateResponse:
|
||||
"""
|
||||
Update normalization settings for a tryout.
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
request: Normalization update request
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
NormalizationUpdateResponse with updated settings
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout not found or validation fails
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
|
||||
# Get tryout
|
||||
query = select(Tryout).where(Tryout.tryout_id == tryout_id)
|
||||
if website_id is not None:
|
||||
query = query.where(Tryout.website_id == website_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
tryout = result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tryout {tryout_id} not found for website {website_id}",
|
||||
)
|
||||
|
||||
# Update normalization mode if provided
|
||||
if request.normalization_mode is not None:
|
||||
tryout.normalization_mode = request.normalization_mode
|
||||
|
||||
# Update static values if provided
|
||||
if request.static_rataan is not None:
|
||||
tryout.static_rataan = request.static_rataan
|
||||
|
||||
if request.static_sb is not None:
|
||||
tryout.static_sb = request.static_sb
|
||||
|
||||
# Get current stats for participant count
|
||||
stats_query = select(TryoutStats).where(TryoutStats.tryout_id == tryout_id)
|
||||
if website_id is not None:
|
||||
stats_query = stats_query.where(TryoutStats.website_id == website_id)
|
||||
|
||||
stats_result = await db.execute(stats_query)
|
||||
stats = stats_result.scalar_one_or_none()
|
||||
current_participant_count = stats.participant_count if stats else 0
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(tryout)
|
||||
|
||||
return NormalizationUpdateResponse(
|
||||
tryout_id=tryout.tryout_id,
|
||||
normalization_mode=tryout.normalization_mode,
|
||||
static_rataan=tryout.static_rataan,
|
||||
static_sb=tryout.static_sb,
|
||||
will_switch_to_dynamic_at=tryout.min_sample_for_dynamic,
|
||||
current_participant_count=current_participant_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=List[TryoutConfigBrief],
|
||||
summary="List tryouts",
|
||||
description="List all tryouts for a website.",
|
||||
)
|
||||
async def list_tryouts(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> List[TryoutConfigBrief]:
|
||||
"""
|
||||
List all tryouts for a website.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
List of TryoutConfigBrief
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||
|
||||
# Get tryouts with stats and items
|
||||
query = select(Tryout).options(selectinload(Tryout.stats), selectinload(Tryout.items))
|
||||
if website_id is not None:
|
||||
query = query.where(Tryout.website_id == website_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
tryouts = result.scalars().all()
|
||||
|
||||
# Get snapshot counts for tryouts to show accurate item_count for JSON imports
|
||||
snapshot_counts = {}
|
||||
if tryouts:
|
||||
tryout_ids = [t.tryout_id for t in tryouts]
|
||||
count_query = (
|
||||
select(TryoutSnapshotQuestion.source_tryout_id, func.count(TryoutSnapshotQuestion.id))
|
||||
.where(TryoutSnapshotQuestion.source_tryout_id.in_(tryout_ids))
|
||||
)
|
||||
if website_id is not None:
|
||||
count_query = count_query.where(TryoutSnapshotQuestion.website_id == website_id)
|
||||
|
||||
count_query = count_query.group_by(TryoutSnapshotQuestion.source_tryout_id)
|
||||
count_result = await db.execute(count_query)
|
||||
snapshot_counts = dict(count_result.all())
|
||||
|
||||
return [
|
||||
TryoutConfigBrief(
|
||||
website_id=t.website_id,
|
||||
tryout_id=t.tryout_id,
|
||||
name=t.name,
|
||||
scoring_mode=t.scoring_mode,
|
||||
selection_mode=t.selection_mode,
|
||||
normalization_mode=t.normalization_mode,
|
||||
participant_count=t.stats.participant_count if t.stats else 0,
|
||||
rataan=t.stats.rataan if t.stats else None,
|
||||
sb=t.stats.sb if t.stats else None,
|
||||
item_count=len(t.items) or snapshot_counts.get(t.tryout_id, 0),
|
||||
calibrated_item_count=sum(1 for i in t.items if i.calibrated),
|
||||
)
|
||||
for t in tryouts
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{tryout_id}/calibration-status",
|
||||
summary="Get calibration status",
|
||||
description="Get IRT calibration status for items in this tryout.",
|
||||
)
|
||||
async def get_calibration_status(
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
"""
|
||||
Get calibration status for items in a tryout.
|
||||
|
||||
Returns statistics on how many items are calibrated and ready for IRT.
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
Calibration status summary
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout not found
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
|
||||
# Verify tryout exists
|
||||
query = select(Tryout).where(Tryout.tryout_id == tryout_id)
|
||||
if website_id is not None:
|
||||
query = query.where(Tryout.website_id == website_id)
|
||||
|
||||
tryout_result = await db.execute(query)
|
||||
tryout = tryout_result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tryout {tryout_id} not found for website {website_id}",
|
||||
)
|
||||
|
||||
# Get calibration statistics
|
||||
stats_query = select(
|
||||
func.count().label("total_items"),
|
||||
func.sum(cast(Item.calibrated, Integer)).label("calibrated_items"),
|
||||
func.avg(Item.calibration_sample_size).label("avg_sample_size"),
|
||||
).where(Item.tryout_id == tryout_id)
|
||||
|
||||
if website_id is not None:
|
||||
stats_query = stats_query.where(Item.website_id == website_id)
|
||||
|
||||
stats_result = await db.execute(stats_query)
|
||||
stats = stats_result.first()
|
||||
|
||||
total_items = stats.total_items or 0
|
||||
calibrated_items = stats.calibrated_items or 0
|
||||
calibration_percentage = (calibrated_items / total_items * 100) if total_items > 0 else 0
|
||||
|
||||
return {
|
||||
"tryout_id": tryout_id,
|
||||
"total_items": total_items,
|
||||
"calibrated_items": calibrated_items,
|
||||
"calibration_percentage": round(calibration_percentage, 2),
|
||||
"avg_sample_size": round(stats.avg_sample_size, 2) if stats.avg_sample_size else 0,
|
||||
"min_calibration_sample": tryout.min_calibration_sample,
|
||||
"ready_for_irt": calibration_percentage >= 90,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{tryout_id}/calibrate",
|
||||
summary="Trigger IRT calibration",
|
||||
description="Trigger IRT calibration for all items in this tryout with sufficient response data.",
|
||||
)
|
||||
async def trigger_calibration(
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
"""
|
||||
Trigger IRT calibration for all items in a tryout.
|
||||
|
||||
Runs calibration for items with >= min_calibration_sample responses.
|
||||
Updates item.irt_b, item.irt_se, and item.calibrated status.
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
Calibration results summary
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout not found or calibration fails
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
|
||||
from app.services.irt_calibration import (
|
||||
calibrate_all,
|
||||
CALIBRATION_SAMPLE_THRESHOLD,
|
||||
)
|
||||
|
||||
# Verify tryout exists
|
||||
query = select(Tryout).where(Tryout.tryout_id == tryout_id)
|
||||
if website_id is not None:
|
||||
query = query.where(Tryout.website_id == website_id)
|
||||
|
||||
tryout_result = await db.execute(query)
|
||||
tryout = tryout_result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tryout {tryout_id} not found for website {website_id}",
|
||||
)
|
||||
|
||||
# Run calibration
|
||||
result = await calibrate_all(
|
||||
tryout_id=tryout_id,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
min_sample_size=tryout.min_calibration_sample or CALIBRATION_SAMPLE_THRESHOLD,
|
||||
)
|
||||
|
||||
return {
|
||||
"tryout_id": tryout_id,
|
||||
"total_items": result.total_items,
|
||||
"calibrated_items": result.calibrated_items,
|
||||
"failed_items": result.failed_items,
|
||||
"calibration_percentage": round(result.calibration_percentage * 100, 2),
|
||||
"ready_for_irt": result.ready_for_irt,
|
||||
"message": f"Calibration complete: {result.calibrated_items}/{result.total_items} items calibrated",
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{tryout_id}/calibrate/{item_id}",
|
||||
summary="Trigger IRT calibration for single item",
|
||||
description="Trigger IRT calibration for a specific item.",
|
||||
)
|
||||
async def trigger_item_calibration(
|
||||
tryout_id: str,
|
||||
item_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
"""
|
||||
Trigger IRT calibration for a single item.
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
item_id: Item ID to calibrate
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
Calibration result for the item
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout or item not found
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
|
||||
from app.services.irt_calibration import calibrate_item, CALIBRATION_SAMPLE_THRESHOLD
|
||||
|
||||
# Verify tryout exists
|
||||
query = select(Tryout).where(Tryout.tryout_id == tryout_id)
|
||||
if website_id is not None:
|
||||
query = query.where(Tryout.website_id == website_id)
|
||||
|
||||
tryout_result = await db.execute(query)
|
||||
tryout = tryout_result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tryout {tryout_id} not found for website {website_id}",
|
||||
)
|
||||
|
||||
# Verify item belongs to this tryout
|
||||
item_query = select(Item).where(
|
||||
Item.id == item_id,
|
||||
Item.tryout_id == tryout_id,
|
||||
)
|
||||
if website_id is not None:
|
||||
item_query = item_query.where(Item.website_id == website_id)
|
||||
|
||||
item_result = await db.execute(item_query)
|
||||
item = item_result.scalar_one_or_none()
|
||||
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Item {item_id} not found in tryout {tryout_id}",
|
||||
)
|
||||
|
||||
# Run calibration
|
||||
result = await calibrate_item(
|
||||
item_id=item_id,
|
||||
db=db,
|
||||
min_sample_size=tryout.min_calibration_sample or CALIBRATION_SAMPLE_THRESHOLD,
|
||||
)
|
||||
|
||||
return {
|
||||
"item_id": result.item_id,
|
||||
"status": result.status.value,
|
||||
"irt_b": result.irt_b,
|
||||
"irt_se": result.irt_se,
|
||||
"sample_size": result.sample_size,
|
||||
"message": result.message,
|
||||
}
|
||||
84
backend/app/routers/websites.py
Normal file
84
backend/app/routers/websites.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Website
|
||||
from app.core.auth import AuthContext, get_auth_context, require_website_auth
|
||||
|
||||
router = APIRouter(tags=["websites"])
|
||||
|
||||
class WebsiteBase(BaseModel):
|
||||
name: str
|
||||
domain: str
|
||||
|
||||
class WebsiteResponse(WebsiteBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@router.get("/websites", response_model=List[WebsiteResponse])
|
||||
async def get_websites(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
result = await db.execute(select(Website).order_by(Website.id.asc()))
|
||||
websites = result.scalars().all()
|
||||
# Map old columns (site_name, site_url) to new response format
|
||||
return [
|
||||
WebsiteResponse(
|
||||
id=w.id,
|
||||
name=w.site_name,
|
||||
domain=w.site_url
|
||||
) for w in websites
|
||||
]
|
||||
|
||||
@router.post("/websites", response_model=WebsiteResponse)
|
||||
async def create_website(
|
||||
payload: WebsiteBase,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
website = Website(site_name=payload.name, site_url=payload.domain)
|
||||
db.add(website)
|
||||
await db.commit()
|
||||
await db.refresh(website)
|
||||
return WebsiteResponse(id=website.id, name=website.site_name, domain=website.site_url)
|
||||
|
||||
@router.put("/websites/{website_id}", response_model=WebsiteResponse)
|
||||
async def update_website(
|
||||
website_id: int,
|
||||
payload: WebsiteBase,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
website = await db.get(Website, website_id)
|
||||
if not website:
|
||||
raise HTTPException(status_code=404, detail="Website not found")
|
||||
|
||||
website.site_name = payload.name
|
||||
website.site_url = payload.domain
|
||||
await db.commit()
|
||||
await db.refresh(website)
|
||||
return WebsiteResponse(id=website.id, name=website.site_name, domain=website.site_url)
|
||||
|
||||
@router.delete("/websites/{website_id}")
|
||||
async def delete_website(
|
||||
website_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
website = await db.get(Website, website_id)
|
||||
if not website:
|
||||
raise HTTPException(status_code=404, detail="Website not found")
|
||||
|
||||
await db.delete(website)
|
||||
await db.commit()
|
||||
return {"status": "success", "message": "Website deleted"}
|
||||
439
backend/app/routers/wordpress.py
Normal file
439
backend/app/routers/wordpress.py
Normal file
@@ -0,0 +1,439 @@
|
||||
"""
|
||||
WordPress Integration API Router.
|
||||
|
||||
Endpoints:
|
||||
- POST /wordpress/sync_users: Synchronize users from WordPress
|
||||
- POST /wordpress/verify_session: Verify WordPress session/token
|
||||
- GET /wordpress/website/{website_id}/users: Get all users for a website
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Request, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.auth import (
|
||||
AuthContext,
|
||||
ensure_website_scope_matches,
|
||||
get_auth_context,
|
||||
issue_access_token,
|
||||
require_website_auth,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.models.website import Website
|
||||
from app.schemas.wordpress import (
|
||||
SyncUsersResponse,
|
||||
SyncStatsResponse,
|
||||
UserListResponse,
|
||||
VerifySessionRequest,
|
||||
VerifySessionResponse,
|
||||
WordPressUserResponse,
|
||||
)
|
||||
from app.services.wordpress_auth import (
|
||||
get_wordpress_user,
|
||||
sync_wordpress_users,
|
||||
verify_website_exists,
|
||||
verify_wordpress_token,
|
||||
get_or_create_user,
|
||||
WordPressAPIError,
|
||||
WordPressRateLimitError,
|
||||
WordPressTokenInvalidError,
|
||||
WebsiteNotFoundError,
|
||||
)
|
||||
from app.core.rate_limit import enforce_rate_limit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/wordpress", tags=["wordpress"])
|
||||
|
||||
|
||||
def _api_role_from_wordpress_roles(roles: list[str]) -> str:
|
||||
"""Map WordPress roles to API roles used by route authorization."""
|
||||
normalized_roles = {str(role).strip().lower() for role in roles}
|
||||
if normalized_roles & {"super_admin", "system_admin"}:
|
||||
return "system_admin"
|
||||
if normalized_roles & {"administrator", "admin"}:
|
||||
return "admin"
|
||||
return "student"
|
||||
|
||||
|
||||
def get_website_id_from_header(
|
||||
x_website_id: Optional[str] = Header(None, alias="X-Website-ID"),
|
||||
) -> int:
|
||||
"""
|
||||
Extract and validate website_id from request header.
|
||||
|
||||
Args:
|
||||
x_website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
Validated website ID as integer
|
||||
|
||||
Raises:
|
||||
HTTPException: If header is missing or invalid
|
||||
"""
|
||||
if x_website_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="X-Website-ID header is required",
|
||||
)
|
||||
try:
|
||||
return int(x_website_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="X-Website-ID must be a valid integer",
|
||||
)
|
||||
|
||||
|
||||
async def get_valid_website(
|
||||
website_id: int,
|
||||
db: AsyncSession,
|
||||
) -> Website:
|
||||
"""
|
||||
Validate website_id exists and return Website model.
|
||||
|
||||
Args:
|
||||
website_id: Website identifier
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Website model instance
|
||||
|
||||
Raises:
|
||||
HTTPException: If website not found
|
||||
"""
|
||||
try:
|
||||
return await verify_website_exists(website_id, db)
|
||||
except WebsiteNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Website {website_id} not found",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sync_users",
|
||||
response_model=SyncUsersResponse,
|
||||
summary="Synchronize users from WordPress",
|
||||
description="Fetch all users from WordPress API and sync to local database. Requires admin WordPress token.",
|
||||
)
|
||||
async def sync_users_endpoint(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
website_id: int = Depends(get_website_id_from_header),
|
||||
authorization: Optional[str] = Header(None, alias="Authorization"),
|
||||
) -> SyncUsersResponse:
|
||||
"""
|
||||
Synchronize users from WordPress to local database.
|
||||
|
||||
Process:
|
||||
1. Validate website_id exists
|
||||
2. Extract admin token from Authorization header
|
||||
3. Fetch all users from WordPress API
|
||||
4. Upsert: Update existing users, insert new users
|
||||
5. Return sync statistics
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
authorization: Authorization header with Bearer token
|
||||
|
||||
Returns:
|
||||
SyncUsersResponse with sync statistics
|
||||
|
||||
Raises:
|
||||
HTTPException: If website not found, token invalid, or API error
|
||||
"""
|
||||
await enforce_rate_limit(
|
||||
request,
|
||||
scope="wordpress.sync_users",
|
||||
max_requests=20,
|
||||
window_seconds=300,
|
||||
)
|
||||
|
||||
# Validate website exists
|
||||
await get_valid_website(website_id, db)
|
||||
|
||||
# Extract token from Authorization header
|
||||
if authorization is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authorization header is required",
|
||||
)
|
||||
|
||||
# Parse Bearer token
|
||||
parts = authorization.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Authorization header format. Use: Bearer {token}",
|
||||
)
|
||||
|
||||
admin_token = parts[1]
|
||||
|
||||
try:
|
||||
sync_stats = await sync_wordpress_users(
|
||||
website_id=website_id,
|
||||
admin_token=admin_token,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return SyncUsersResponse(
|
||||
synced=SyncStatsResponse(
|
||||
inserted=sync_stats.inserted,
|
||||
updated=sync_stats.updated,
|
||||
total=sync_stats.total,
|
||||
errors=sync_stats.errors,
|
||||
),
|
||||
website_id=website_id,
|
||||
message=f"Sync completed: {sync_stats.inserted} inserted, {sync_stats.updated} updated",
|
||||
)
|
||||
|
||||
except WordPressTokenInvalidError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e),
|
||||
)
|
||||
except WordPressRateLimitError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=str(e),
|
||||
)
|
||||
except WordPressAPIError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(e),
|
||||
)
|
||||
except WebsiteNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/verify_session",
|
||||
response_model=VerifySessionResponse,
|
||||
summary="Verify WordPress session",
|
||||
description="Verify WordPress JWT token and user identity.",
|
||||
)
|
||||
async def verify_session_endpoint(
|
||||
http_request: Request,
|
||||
request: VerifySessionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> VerifySessionResponse:
|
||||
"""
|
||||
Verify WordPress session/token.
|
||||
|
||||
Process:
|
||||
1. Validate website_id exists
|
||||
2. Call WordPress API to verify token
|
||||
3. Verify wp_user_id matches token owner
|
||||
4. Get or create local user
|
||||
5. Return validation result
|
||||
|
||||
Args:
|
||||
request: VerifySessionRequest with wp_user_id, token, website_id
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
VerifySessionResponse with validation result
|
||||
|
||||
Raises:
|
||||
HTTPException: If website not found or API error
|
||||
"""
|
||||
await enforce_rate_limit(
|
||||
http_request,
|
||||
scope="wordpress.verify_session",
|
||||
max_requests=60,
|
||||
window_seconds=300,
|
||||
)
|
||||
|
||||
# Validate website exists
|
||||
await get_valid_website(request.website_id, db)
|
||||
|
||||
try:
|
||||
# Verify token with WordPress
|
||||
wp_user_info = await verify_wordpress_token(
|
||||
token=request.token,
|
||||
website_id=request.website_id,
|
||||
wp_user_id=request.wp_user_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if wp_user_info is None:
|
||||
return VerifySessionResponse(
|
||||
valid=False,
|
||||
error="User ID mismatch or invalid credentials",
|
||||
)
|
||||
|
||||
# Get or create local user
|
||||
user = await get_or_create_user(
|
||||
wp_user_id=request.wp_user_id,
|
||||
website_id=request.website_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return VerifySessionResponse(
|
||||
valid=True,
|
||||
user=WordPressUserResponse.model_validate(user),
|
||||
wp_user_info={
|
||||
"username": wp_user_info.username,
|
||||
"email": wp_user_info.email,
|
||||
"display_name": wp_user_info.display_name,
|
||||
"roles": wp_user_info.roles,
|
||||
},
|
||||
access_token=issue_access_token(
|
||||
website_id=request.website_id,
|
||||
role=_api_role_from_wordpress_roles(wp_user_info.roles),
|
||||
wp_user_id=request.wp_user_id,
|
||||
expires_in_seconds=3600 * 24,
|
||||
),
|
||||
)
|
||||
|
||||
except WordPressTokenInvalidError as e:
|
||||
return VerifySessionResponse(
|
||||
valid=False,
|
||||
error=f"Invalid credentials: {str(e)}",
|
||||
)
|
||||
except WordPressRateLimitError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=str(e),
|
||||
)
|
||||
except WordPressAPIError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(e),
|
||||
)
|
||||
except WebsiteNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/website/{website_id}/users",
|
||||
response_model=UserListResponse,
|
||||
summary="Get users for website",
|
||||
description="Retrieve all users for a specific website from local database with pagination.",
|
||||
)
|
||||
async def get_website_users(
|
||||
website_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> UserListResponse:
|
||||
"""
|
||||
Get all users for a website.
|
||||
|
||||
Args:
|
||||
website_id: Website identifier
|
||||
db: Database session
|
||||
page: Page number (default: 1)
|
||||
page_size: Number of users per page (default: 50, max: 100)
|
||||
|
||||
Returns:
|
||||
UserListResponse with paginated user list
|
||||
|
||||
Raises:
|
||||
HTTPException: If website not found
|
||||
"""
|
||||
auth_website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
ensure_website_scope_matches(auth_website_id, website_id)
|
||||
|
||||
# Validate website exists
|
||||
await get_valid_website(website_id, db)
|
||||
|
||||
# Clamp page_size
|
||||
page_size = min(max(1, page_size), 100)
|
||||
page = max(1, page)
|
||||
|
||||
# Get total count
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(User).where(User.website_id == website_id)
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# Calculate pagination
|
||||
offset = (page - 1) * page_size
|
||||
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
|
||||
|
||||
# Get users
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.website_id == website_id)
|
||||
.order_by(User.id)
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
|
||||
return UserListResponse(
|
||||
users=[WordPressUserResponse.model_validate(user) for user in users],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/website/{website_id}/user/{wp_user_id}",
|
||||
response_model=WordPressUserResponse,
|
||||
summary="Get specific user",
|
||||
description="Retrieve a specific user by WordPress user ID.",
|
||||
)
|
||||
async def get_user_endpoint(
|
||||
website_id: int,
|
||||
wp_user_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> WordPressUserResponse:
|
||||
"""
|
||||
Get a specific user by WordPress user ID.
|
||||
|
||||
Args:
|
||||
website_id: Website identifier
|
||||
wp_user_id: WordPress user ID
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
WordPressUserResponse with user data
|
||||
|
||||
Raises:
|
||||
HTTPException: If website or user not found
|
||||
"""
|
||||
auth_website_id = require_website_auth(
|
||||
auth, allowed_roles={"student", "admin", "system_admin"}
|
||||
)
|
||||
ensure_website_scope_matches(auth_website_id, website_id)
|
||||
if auth.role == "student" and auth.wp_user_id != wp_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User does not belong to this authenticated user",
|
||||
)
|
||||
|
||||
# Validate website exists
|
||||
await get_valid_website(website_id, db)
|
||||
|
||||
# Get user
|
||||
user = await get_wordpress_user(
|
||||
wp_user_id=wp_user_id,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User {wp_user_id} not found for website {website_id}",
|
||||
)
|
||||
|
||||
return WordPressUserResponse.model_validate(user)
|
||||
65
backend/app/schemas/__init__.py
Normal file
65
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Pydantic schemas package.
|
||||
"""
|
||||
|
||||
from app.schemas.ai import (
|
||||
AIGeneratePreviewRequest,
|
||||
AIGeneratePreviewResponse,
|
||||
AISaveRequest,
|
||||
AISaveResponse,
|
||||
AIStatsResponse,
|
||||
GeneratedQuestion,
|
||||
)
|
||||
from app.schemas.session import (
|
||||
SessionCompleteRequest,
|
||||
SessionCompleteResponse,
|
||||
SessionCreateRequest,
|
||||
SessionResponse,
|
||||
UserAnswerInput,
|
||||
UserAnswerOutput,
|
||||
)
|
||||
from app.schemas.tryout import (
|
||||
NormalizationUpdateRequest,
|
||||
NormalizationUpdateResponse,
|
||||
TryoutConfigBrief,
|
||||
TryoutConfigResponse,
|
||||
TryoutStatsResponse,
|
||||
)
|
||||
from app.schemas.wordpress import (
|
||||
SyncStatsResponse,
|
||||
SyncUsersResponse,
|
||||
UserListResponse,
|
||||
VerifySessionRequest,
|
||||
VerifySessionResponse,
|
||||
WordPressUserResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# AI schemas
|
||||
"AIGeneratePreviewRequest",
|
||||
"AIGeneratePreviewResponse",
|
||||
"AISaveRequest",
|
||||
"AISaveResponse",
|
||||
"AIStatsResponse",
|
||||
"GeneratedQuestion",
|
||||
# Session schemas
|
||||
"UserAnswerInput",
|
||||
"UserAnswerOutput",
|
||||
"SessionCompleteRequest",
|
||||
"SessionCompleteResponse",
|
||||
"SessionCreateRequest",
|
||||
"SessionResponse",
|
||||
# Tryout schemas
|
||||
"TryoutConfigResponse",
|
||||
"TryoutStatsResponse",
|
||||
"TryoutConfigBrief",
|
||||
"NormalizationUpdateRequest",
|
||||
"NormalizationUpdateResponse",
|
||||
# WordPress schemas
|
||||
"SyncStatsResponse",
|
||||
"SyncUsersResponse",
|
||||
"UserListResponse",
|
||||
"VerifySessionRequest",
|
||||
"VerifySessionResponse",
|
||||
"WordPressUserResponse",
|
||||
]
|
||||
180
backend/app/schemas/ai.py
Normal file
180
backend/app/schemas/ai.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Pydantic schemas for AI generation endpoints.
|
||||
|
||||
Request/response models for admin AI generation playground.
|
||||
"""
|
||||
|
||||
from typing import Dict, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
OPTION_LABELS = tuple("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
|
||||
|
||||
class AIGeneratePreviewRequest(BaseModel):
|
||||
basis_item_id: int = Field(
|
||||
..., description="ID of the basis item (must be sedang level)"
|
||||
)
|
||||
target_level: Literal["mudah", "sulit"] = Field(
|
||||
..., description="Target difficulty level for generated question"
|
||||
)
|
||||
ai_model: str = Field(
|
||||
default="qwen/qwen2.5-32b-instruct",
|
||||
description="AI model to use for generation",
|
||||
)
|
||||
|
||||
|
||||
class AIModelPricing(BaseModel):
|
||||
prompt: Optional[float] = Field(
|
||||
default=None, description="Input token price in USD per token"
|
||||
)
|
||||
completion: Optional[float] = Field(
|
||||
default=None, description="Output token price in USD per token"
|
||||
)
|
||||
prompt_per_million: Optional[float] = Field(
|
||||
default=None, description="Input token price in USD per 1M tokens"
|
||||
)
|
||||
completion_per_million: Optional[float] = Field(
|
||||
default=None, description="Output token price in USD per 1M tokens"
|
||||
)
|
||||
currency: str = "USD"
|
||||
source: str = "openrouter"
|
||||
|
||||
|
||||
class AIUsageInfo(BaseModel):
|
||||
prompt_tokens: Optional[int] = None
|
||||
completion_tokens: Optional[int] = None
|
||||
total_tokens: Optional[int] = None
|
||||
cost_usd: Optional[float] = None
|
||||
|
||||
|
||||
class AIGeneratePreviewResponse(BaseModel):
|
||||
success: bool = Field(..., description="Whether generation was successful")
|
||||
stem: Optional[str] = None
|
||||
options: Optional[Dict[str, str]] = None
|
||||
correct: Optional[str] = None
|
||||
explanation: Optional[str] = None
|
||||
ai_model: Optional[str] = None
|
||||
basis_item_id: Optional[int] = None
|
||||
target_level: Optional[str] = None
|
||||
usage: Optional[AIUsageInfo] = None
|
||||
error: Optional[str] = None
|
||||
cached: bool = False
|
||||
|
||||
|
||||
class AISaveRequest(BaseModel):
|
||||
stem: str = Field(..., description="Question stem")
|
||||
options: Dict[str, str] = Field(
|
||||
..., description="Answer options. Labels must match the basis item exactly."
|
||||
)
|
||||
correct: str = Field(..., description="Correct answer option label")
|
||||
explanation: Optional[str] = None
|
||||
tryout_id: str = Field(..., description="Tryout identifier")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
basis_item_id: int = Field(..., description="Basis item ID")
|
||||
slot: int = Field(..., description="Question slot position")
|
||||
level: Literal["mudah", "sedang", "sulit"] = Field(
|
||||
..., description="Difficulty level"
|
||||
)
|
||||
variant_status: Literal["active", "draft"] = Field(
|
||||
default="active",
|
||||
description="Lifecycle status for the saved variant. Workspace approvals save active variants.",
|
||||
)
|
||||
ai_model: str = Field(
|
||||
default="qwen/qwen2.5-32b-instruct",
|
||||
description="AI model used for generation",
|
||||
)
|
||||
|
||||
@field_validator("correct")
|
||||
@classmethod
|
||||
def validate_correct(cls, v: str) -> str:
|
||||
label = v.upper()
|
||||
if label not in OPTION_LABELS:
|
||||
raise ValueError("Correct answer must be an option label A-Z")
|
||||
return label
|
||||
|
||||
@field_validator("options")
|
||||
@classmethod
|
||||
def validate_options(cls, v: Dict[str, str]) -> Dict[str, str]:
|
||||
normalized = {
|
||||
str(key).strip().upper(): str(value).strip()
|
||||
for key, value in v.items()
|
||||
if str(key).strip() and str(value).strip()
|
||||
}
|
||||
if len(normalized) < 2:
|
||||
raise ValueError("Options must contain at least two non-empty choices")
|
||||
invalid_keys = sorted(set(normalized) - set(OPTION_LABELS))
|
||||
if invalid_keys:
|
||||
raise ValueError(f"Options contain invalid labels: {', '.join(invalid_keys)}")
|
||||
return normalized
|
||||
|
||||
|
||||
class AISaveResponse(BaseModel):
|
||||
success: bool = Field(..., description="Whether save was successful")
|
||||
item_id: Optional[int] = None
|
||||
run_id: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class AIGenerateBatchRequest(BaseModel):
|
||||
basis_item_id: int = Field(
|
||||
..., description="ID of the basis item (must be sedang level)"
|
||||
)
|
||||
target_level: Literal["mudah", "sulit"] = Field(
|
||||
..., description="Target difficulty level for generated questions"
|
||||
)
|
||||
ai_model: str = Field(
|
||||
default="qwen/qwen2.5-32b-instruct",
|
||||
description="AI model to use for generation",
|
||||
)
|
||||
count: int = Field(default=3, ge=1, le=10, description="Number of variants to generate")
|
||||
operator_notes: Optional[str] = None
|
||||
|
||||
|
||||
class AIBatchGeneratedItem(BaseModel):
|
||||
item_id: int
|
||||
stem: str
|
||||
options: Dict[str, str]
|
||||
correct: str
|
||||
explanation: Optional[str] = None
|
||||
level: str
|
||||
variant_status: str
|
||||
usage: Optional[AIUsageInfo] = None
|
||||
|
||||
|
||||
class AIGenerateBatchResponse(BaseModel):
|
||||
success: bool
|
||||
run_id: Optional[int] = None
|
||||
item_ids: list[int] = Field(default_factory=list)
|
||||
items: list[AIBatchGeneratedItem] = Field(default_factory=list)
|
||||
generated_count: int = 0
|
||||
usage: Optional[AIUsageInfo] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class AIStatsResponse(BaseModel):
|
||||
total_ai_items: int = Field(..., description="Total AI-generated items")
|
||||
items_by_model: Dict[str, int] = Field(
|
||||
default_factory=dict, description="Items count by AI model"
|
||||
)
|
||||
cache_hit_rate: float = Field(
|
||||
default=0.0, description="Cache hit rate (0.0 to 1.0)"
|
||||
)
|
||||
total_cache_hits: int = Field(default=0, description="Total cache hits")
|
||||
total_requests: int = Field(default=0, description="Total generation requests")
|
||||
|
||||
|
||||
class GeneratedQuestion(BaseModel):
|
||||
stem: str
|
||||
options: Dict[str, str]
|
||||
correct: str
|
||||
explanation: Optional[str] = None
|
||||
usage: Optional[AIUsageInfo] = None
|
||||
|
||||
@field_validator("correct")
|
||||
@classmethod
|
||||
def validate_correct(cls, v: str) -> str:
|
||||
label = v.upper()
|
||||
if label not in OPTION_LABELS:
|
||||
raise ValueError("Correct answer must be an option label A-Z")
|
||||
return label
|
||||
264
backend/app/schemas/report.py
Normal file
264
backend/app/schemas/report.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
Pydantic schemas for Report API endpoints.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Student Performance Report Schemas
|
||||
# =============================================================================
|
||||
|
||||
class StudentPerformanceRecordOutput(BaseModel):
|
||||
"""Individual student performance record output."""
|
||||
|
||||
session_id: str
|
||||
wp_user_id: str
|
||||
tryout_id: str
|
||||
NM: Optional[int] = None
|
||||
NN: Optional[int] = None
|
||||
theta: Optional[float] = None
|
||||
theta_se: Optional[float] = None
|
||||
total_benar: int
|
||||
time_spent: int # Total time in seconds
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
scoring_mode_used: str
|
||||
rataan_used: Optional[float] = None
|
||||
sb_used: Optional[float] = None
|
||||
|
||||
|
||||
class AggregatePerformanceStatsOutput(BaseModel):
|
||||
"""Aggregate statistics for student performance output."""
|
||||
|
||||
tryout_id: str
|
||||
participant_count: int
|
||||
avg_nm: Optional[float] = None
|
||||
std_nm: Optional[float] = None
|
||||
min_nm: Optional[int] = None
|
||||
max_nm: Optional[int] = None
|
||||
median_nm: Optional[float] = None
|
||||
avg_nn: Optional[float] = None
|
||||
std_nn: Optional[float] = None
|
||||
avg_theta: Optional[float] = None
|
||||
pass_rate: float # Percentage with NN >= 500
|
||||
avg_time_spent: float # Average time in seconds
|
||||
|
||||
|
||||
class StudentPerformanceReportOutput(BaseModel):
|
||||
"""Complete student performance report output."""
|
||||
|
||||
generated_at: datetime
|
||||
tryout_id: str
|
||||
website_id: int
|
||||
date_range: Optional[Dict[str, str]] = None
|
||||
aggregate: AggregatePerformanceStatsOutput
|
||||
individual_records: List[StudentPerformanceRecordOutput] = []
|
||||
|
||||
|
||||
class StudentPerformanceReportRequest(BaseModel):
|
||||
"""Request schema for student performance report."""
|
||||
|
||||
tryout_id: str = Field(..., description="Tryout identifier")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
date_start: Optional[datetime] = Field(None, description="Filter by start date")
|
||||
date_end: Optional[datetime] = Field(None, description="Filter by end date")
|
||||
format_type: Literal["individual", "aggregate", "both"] = Field(
|
||||
default="both", description="Report format"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Item Analysis Report Schemas
|
||||
# =============================================================================
|
||||
|
||||
class ItemAnalysisRecordOutput(BaseModel):
|
||||
"""Item analysis record output for a single item."""
|
||||
|
||||
item_id: int
|
||||
slot: int
|
||||
level: str
|
||||
ctt_p: Optional[float] = None
|
||||
ctt_bobot: Optional[float] = None
|
||||
ctt_category: Optional[str] = None
|
||||
irt_b: Optional[float] = None
|
||||
irt_se: Optional[float] = None
|
||||
calibrated: bool
|
||||
calibration_sample_size: int
|
||||
correctness_rate: float
|
||||
item_total_correlation: Optional[float] = None
|
||||
information_values: Dict[float, float] = Field(default_factory=dict)
|
||||
optimal_theta_range: str = "N/A"
|
||||
|
||||
|
||||
class ItemAnalysisReportOutput(BaseModel):
|
||||
"""Complete item analysis report output."""
|
||||
|
||||
generated_at: datetime
|
||||
tryout_id: str
|
||||
website_id: int
|
||||
total_items: int
|
||||
items: List[ItemAnalysisRecordOutput]
|
||||
summary: Dict[str, Any]
|
||||
|
||||
|
||||
class ItemAnalysisReportRequest(BaseModel):
|
||||
"""Request schema for item analysis report."""
|
||||
|
||||
tryout_id: str = Field(..., description="Tryout identifier")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
filter_by: Optional[Literal["difficulty", "calibrated", "discrimination"]] = Field(
|
||||
None, description="Filter items by category"
|
||||
)
|
||||
difficulty_level: Optional[Literal["mudah", "sedang", "sulit"]] = Field(
|
||||
None, description="Filter by difficulty level (only when filter_by='difficulty')"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Calibration Status Report Schemas
|
||||
# =============================================================================
|
||||
|
||||
class CalibrationItemStatusOutput(BaseModel):
|
||||
"""Calibration status for a single item output."""
|
||||
|
||||
item_id: int
|
||||
slot: int
|
||||
level: str
|
||||
sample_size: int
|
||||
calibrated: bool
|
||||
irt_b: Optional[float] = None
|
||||
irt_se: Optional[float] = None
|
||||
ctt_p: Optional[float] = None
|
||||
|
||||
|
||||
class CalibrationStatusReportOutput(BaseModel):
|
||||
"""Complete calibration status report output."""
|
||||
|
||||
generated_at: datetime
|
||||
tryout_id: str
|
||||
website_id: int
|
||||
total_items: int
|
||||
calibrated_items: int
|
||||
calibration_percentage: float
|
||||
items_awaiting_calibration: List[CalibrationItemStatusOutput]
|
||||
avg_calibration_sample_size: float
|
||||
estimated_time_to_90_percent: Optional[str] = None
|
||||
ready_for_irt_rollout: bool
|
||||
items: List[CalibrationItemStatusOutput]
|
||||
|
||||
|
||||
class CalibrationStatusReportRequest(BaseModel):
|
||||
"""Request schema for calibration status report."""
|
||||
|
||||
tryout_id: str = Field(..., description="Tryout identifier")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tryout Comparison Report Schemas
|
||||
# =============================================================================
|
||||
|
||||
class TryoutComparisonRecordOutput(BaseModel):
|
||||
"""Tryout comparison data point output."""
|
||||
|
||||
tryout_id: str
|
||||
date: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
participant_count: int
|
||||
avg_nm: Optional[float] = None
|
||||
avg_nn: Optional[float] = None
|
||||
avg_theta: Optional[float] = None
|
||||
std_nm: Optional[float] = None
|
||||
calibration_percentage: float
|
||||
|
||||
|
||||
class TryoutComparisonReportOutput(BaseModel):
|
||||
"""Complete tryout comparison report output."""
|
||||
|
||||
generated_at: datetime
|
||||
comparison_type: Literal["date", "subject"]
|
||||
tryouts: List[TryoutComparisonRecordOutput]
|
||||
trends: Optional[Dict[str, Any]] = None
|
||||
normalization_impact: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class TryoutComparisonReportRequest(BaseModel):
|
||||
"""Request schema for tryout comparison report."""
|
||||
|
||||
tryout_ids: List[str] = Field(..., min_length=2, description="List of tryout IDs to compare")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
group_by: Literal["date", "subject"] = Field(
|
||||
default="date", description="Group comparison by date or subject"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Report Scheduling Schemas
|
||||
# =============================================================================
|
||||
|
||||
class ReportScheduleRequest(BaseModel):
|
||||
"""Request schema for scheduling a report."""
|
||||
|
||||
report_type: Literal["student_performance", "item_analysis", "calibration_status", "tryout_comparison"] = Field(
|
||||
..., description="Type of report to generate"
|
||||
)
|
||||
schedule: Literal["daily", "weekly", "monthly"] = Field(
|
||||
..., description="Schedule frequency"
|
||||
)
|
||||
tryout_ids: List[str] = Field(..., description="List of tryout IDs for the report")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
recipients: List[str] = Field(..., description="List of email addresses to send report to")
|
||||
export_format: Literal["csv", "xlsx", "pdf"] = Field(
|
||||
default="xlsx", description="Export format for the report"
|
||||
)
|
||||
|
||||
|
||||
class ReportScheduleOutput(BaseModel):
|
||||
"""Output schema for scheduled report."""
|
||||
|
||||
schedule_id: str
|
||||
report_type: str
|
||||
schedule: str
|
||||
tryout_ids: List[str]
|
||||
website_id: int
|
||||
recipients: List[str]
|
||||
format: str
|
||||
created_at: datetime
|
||||
last_run: Optional[datetime] = None
|
||||
next_run: Optional[datetime] = None
|
||||
is_active: bool
|
||||
|
||||
|
||||
class ReportScheduleResponse(BaseModel):
|
||||
"""Response schema for schedule creation."""
|
||||
|
||||
schedule_id: str
|
||||
message: str
|
||||
next_run: Optional[datetime] = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Export Schemas
|
||||
# =============================================================================
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
"""Request schema for exporting a report."""
|
||||
|
||||
schedule_id: str = Field(..., description="Schedule ID to generate report for")
|
||||
export_format: Literal["csv", "xlsx", "pdf"] = Field(
|
||||
default="xlsx", description="Export format"
|
||||
)
|
||||
|
||||
|
||||
class ExportResponse(BaseModel):
|
||||
"""Response schema for export request."""
|
||||
|
||||
file_path: str
|
||||
file_name: str
|
||||
format: str
|
||||
generated_at: datetime
|
||||
download_url: Optional[str] = None
|
||||
121
backend/app/schemas/session.py
Normal file
121
backend/app/schemas/session.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Pydantic schemas for Session API endpoints.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UserAnswerInput(BaseModel):
|
||||
"""Input schema for a single user answer."""
|
||||
|
||||
item_id: int = Field(..., description="Item/question ID")
|
||||
response: str = Field(..., min_length=1, max_length=10, description="User's answer (A, B, C, D)")
|
||||
time_spent: int = Field(default=0, ge=0, description="Time spent on this question (seconds)")
|
||||
|
||||
|
||||
class SessionCompleteRequest(BaseModel):
|
||||
"""Request schema for completing a session."""
|
||||
|
||||
end_time: datetime = Field(..., description="Session end timestamp")
|
||||
user_answers: List[UserAnswerInput] = Field(..., description="List of user answers")
|
||||
|
||||
|
||||
class UserAnswerOutput(BaseModel):
|
||||
"""Output schema for a single user answer."""
|
||||
|
||||
id: int
|
||||
item_id: int
|
||||
response: str
|
||||
time_spent: int
|
||||
bobot_earned: float
|
||||
scoring_mode_used: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserAnswerReviewOutput(UserAnswerOutput):
|
||||
"""Review output for a single answer."""
|
||||
|
||||
is_correct: bool
|
||||
|
||||
|
||||
class SessionCompleteResponse(BaseModel):
|
||||
"""Response schema for completed session with CTT scores."""
|
||||
|
||||
id: int
|
||||
session_id: str
|
||||
wp_user_id: str
|
||||
website_id: int
|
||||
tryout_id: str
|
||||
start_time: datetime
|
||||
end_time: Optional[datetime]
|
||||
expires_at: Optional[datetime] = None
|
||||
is_completed: bool
|
||||
scoring_mode_used: str
|
||||
|
||||
# CTT scores
|
||||
total_benar: int = Field(description="Total correct answers")
|
||||
total_bobot_earned: float = Field(description="Total weight earned")
|
||||
NM: Optional[int] = Field(description="Nilai Mentah (raw score) [0, 1000]")
|
||||
NN: Optional[int] = Field(description="Nilai Nasional (normalized score) [0, 1000]")
|
||||
|
||||
# Normalization metadata
|
||||
rataan_used: Optional[float] = Field(description="Mean value used for normalization")
|
||||
sb_used: Optional[float] = Field(description="Standard deviation used for normalization")
|
||||
|
||||
# User answers
|
||||
user_answers: List[UserAnswerOutput]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SessionCompleteAdminResponse(SessionCompleteResponse):
|
||||
"""Completed session response with answer correctness for admin/review contexts."""
|
||||
|
||||
user_answers: List[UserAnswerReviewOutput]
|
||||
|
||||
|
||||
class SessionCreateRequest(BaseModel):
|
||||
"""Request schema for creating a new session."""
|
||||
|
||||
session_id: str = Field(..., description="Unique session identifier")
|
||||
wp_user_id: str = Field(..., description="WordPress user ID")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
tryout_id: str = Field(..., description="Tryout identifier")
|
||||
scoring_mode: Literal["ctt", "irt", "hybrid"] = Field(
|
||||
default="ctt", description="Scoring mode for this session"
|
||||
)
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
"""Response schema for session data."""
|
||||
|
||||
id: int
|
||||
session_id: str
|
||||
wp_user_id: str
|
||||
website_id: int
|
||||
tryout_id: str
|
||||
start_time: datetime
|
||||
end_time: Optional[datetime]
|
||||
expires_at: Optional[datetime] = None
|
||||
is_completed: bool
|
||||
scoring_mode_used: str
|
||||
|
||||
# CTT scores (populated after completion)
|
||||
total_benar: int
|
||||
total_bobot_earned: float
|
||||
NM: Optional[int]
|
||||
NN: Optional[int]
|
||||
|
||||
# IRT scores (populated after completion)
|
||||
theta: Optional[float]
|
||||
theta_se: Optional[float]
|
||||
|
||||
# Normalization metadata
|
||||
rataan_used: Optional[float]
|
||||
sb_used: Optional[float]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
120
backend/app/schemas/tryout.py
Normal file
120
backend/app/schemas/tryout.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Pydantic schemas for Tryout API endpoints.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TryoutConfigResponse(BaseModel):
|
||||
"""Response schema for tryout configuration."""
|
||||
|
||||
id: int
|
||||
website_id: int
|
||||
tryout_id: str
|
||||
name: str
|
||||
description: Optional[str]
|
||||
|
||||
# Scoring configuration
|
||||
scoring_mode: Literal["ctt", "irt", "hybrid"]
|
||||
selection_mode: Literal["fixed", "adaptive", "hybrid"]
|
||||
normalization_mode: Literal["static", "dynamic", "hybrid"]
|
||||
|
||||
# Normalization settings
|
||||
min_sample_for_dynamic: int
|
||||
static_rataan: float
|
||||
static_sb: float
|
||||
|
||||
# AI generation
|
||||
ai_generation_enabled: bool
|
||||
|
||||
# Hybrid mode settings
|
||||
hybrid_transition_slot: Optional[int]
|
||||
|
||||
# IRT settings
|
||||
min_calibration_sample: int
|
||||
theta_estimation_method: Literal["mle", "map", "eap"]
|
||||
fallback_to_ctt_on_error: bool
|
||||
|
||||
# Current stats
|
||||
current_stats: Optional["TryoutStatsResponse"]
|
||||
|
||||
# Timestamps
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TryoutStatsResponse(BaseModel):
|
||||
"""Response schema for tryout statistics."""
|
||||
|
||||
participant_count: int
|
||||
rataan: Optional[float]
|
||||
sb: Optional[float]
|
||||
min_nm: Optional[int]
|
||||
max_nm: Optional[int]
|
||||
last_calculated: Optional[datetime]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TryoutConfigBrief(BaseModel):
|
||||
"""Brief tryout config for list responses."""
|
||||
|
||||
website_id: int
|
||||
tryout_id: str
|
||||
name: str
|
||||
scoring_mode: str
|
||||
selection_mode: str
|
||||
normalization_mode: str
|
||||
participant_count: Optional[int] = None
|
||||
rataan: Optional[float] = None
|
||||
sb: Optional[float] = None
|
||||
item_count: int = 0
|
||||
calibrated_item_count: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TryoutConfigUpdateRequest(BaseModel):
|
||||
"""Request schema for updating editable tryout configuration."""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
scoring_mode: Optional[Literal["ctt", "irt", "hybrid"]] = None
|
||||
selection_mode: Optional[Literal["fixed", "adaptive", "hybrid"]] = None
|
||||
normalization_mode: Optional[Literal["static", "dynamic", "hybrid"]] = None
|
||||
min_sample_for_dynamic: Optional[int] = Field(None, ge=1)
|
||||
static_rataan: Optional[float] = Field(None, ge=0)
|
||||
static_sb: Optional[float] = Field(None, gt=0)
|
||||
ai_generation_enabled: Optional[bool] = None
|
||||
hybrid_transition_slot: Optional[int] = Field(None, ge=1)
|
||||
min_calibration_sample: Optional[int] = Field(None, ge=1)
|
||||
theta_estimation_method: Optional[Literal["mle", "map", "eap"]] = None
|
||||
fallback_to_ctt_on_error: Optional[bool] = None
|
||||
|
||||
|
||||
class NormalizationUpdateRequest(BaseModel):
|
||||
"""Request schema for updating normalization settings."""
|
||||
|
||||
normalization_mode: Optional[Literal["static", "dynamic", "hybrid"]] = None
|
||||
static_rataan: Optional[float] = Field(None, ge=0)
|
||||
static_sb: Optional[float] = Field(None, gt=0)
|
||||
|
||||
|
||||
class NormalizationUpdateResponse(BaseModel):
|
||||
"""Response schema for normalization update."""
|
||||
|
||||
tryout_id: str
|
||||
normalization_mode: str
|
||||
static_rataan: float
|
||||
static_sb: float
|
||||
will_switch_to_dynamic_at: int
|
||||
current_participant_count: int
|
||||
|
||||
|
||||
# Update forward reference
|
||||
TryoutConfigResponse.model_rebuild()
|
||||
90
backend/app/schemas/wordpress.py
Normal file
90
backend/app/schemas/wordpress.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Pydantic schemas for WordPress Integration API endpoints.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class VerifySessionRequest(BaseModel):
|
||||
"""Request schema for verifying WordPress session."""
|
||||
|
||||
wp_user_id: str = Field(..., description="WordPress user ID")
|
||||
token: str = Field(..., description="WordPress JWT authentication token")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
|
||||
|
||||
class WordPressUserResponse(BaseModel):
|
||||
"""Response schema for WordPress user data."""
|
||||
|
||||
id: int = Field(..., description="Local database user ID")
|
||||
wp_user_id: str = Field(..., description="WordPress user ID")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
created_at: datetime = Field(..., description="User creation timestamp")
|
||||
updated_at: datetime = Field(..., description="User last update timestamp")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class VerifySessionResponse(BaseModel):
|
||||
"""Response schema for session verification."""
|
||||
|
||||
valid: bool = Field(..., description="Whether the session is valid")
|
||||
user: Optional[WordPressUserResponse] = Field(
|
||||
default=None, description="User data if session is valid"
|
||||
)
|
||||
error: Optional[str] = Field(
|
||||
default=None, description="Error message if session is invalid"
|
||||
)
|
||||
wp_user_info: Optional[dict[str, Any]] = Field(
|
||||
default=None, description="WordPress user info from API"
|
||||
)
|
||||
access_token: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Signed API access token for authenticated website-scoped calls",
|
||||
)
|
||||
|
||||
|
||||
class SyncUsersRequest(BaseModel):
|
||||
"""Request schema for user synchronization (optional body)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SyncStatsResponse(BaseModel):
|
||||
"""Response schema for user synchronization statistics."""
|
||||
|
||||
inserted: int = Field(..., description="Number of users inserted")
|
||||
updated: int = Field(..., description="Number of users updated")
|
||||
total: int = Field(..., description="Total users processed")
|
||||
errors: int = Field(default=0, description="Number of errors during sync")
|
||||
|
||||
|
||||
class SyncUsersResponse(BaseModel):
|
||||
"""Response schema for user synchronization."""
|
||||
|
||||
synced: SyncStatsResponse = Field(..., description="Synchronization statistics")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
message: str = Field(default="Sync completed", description="Status message")
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
"""Response schema for paginated user list."""
|
||||
|
||||
users: List[WordPressUserResponse] = Field(..., description="List of users")
|
||||
total: int = Field(..., description="Total number of users")
|
||||
page: int = Field(default=1, description="Current page number")
|
||||
page_size: int = Field(default=50, description="Number of users per page")
|
||||
total_pages: int = Field(default=1, description="Total number of pages")
|
||||
|
||||
|
||||
class WordPressErrorDetail(BaseModel):
|
||||
"""Detail schema for WordPress errors."""
|
||||
|
||||
code: str = Field(..., description="Error code")
|
||||
message: str = Field(..., description="Error message")
|
||||
details: Optional[dict[str, Any]] = Field(
|
||||
default=None, description="Additional error details"
|
||||
)
|
||||
155
backend/app/services/__init__.py
Normal file
155
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Services module for IRT Bank Soal.
|
||||
|
||||
Contains business logic services for:
|
||||
- IRT calibration
|
||||
- CAT selection
|
||||
- WordPress authentication
|
||||
- AI question generation
|
||||
- Reporting
|
||||
"""
|
||||
|
||||
from app.services.irt_calibration import (
|
||||
IRTCalibrationError,
|
||||
calculate_fisher_information,
|
||||
calculate_item_information,
|
||||
calculate_probability,
|
||||
calculate_theta_se,
|
||||
estimate_b_from_ctt_p,
|
||||
estimate_theta_mle,
|
||||
get_session_responses,
|
||||
nn_to_theta,
|
||||
theta_to_nn,
|
||||
update_session_theta,
|
||||
update_theta_after_response,
|
||||
)
|
||||
from app.services.cat_selection import (
|
||||
CATSelectionError,
|
||||
NextItemResult,
|
||||
TerminationCheck,
|
||||
check_user_level_reuse,
|
||||
get_available_levels_for_slot,
|
||||
get_next_item,
|
||||
get_next_item_adaptive,
|
||||
get_next_item_fixed,
|
||||
get_next_item_hybrid,
|
||||
should_terminate,
|
||||
simulate_cat_selection,
|
||||
update_theta,
|
||||
)
|
||||
from app.services.wordpress_auth import (
|
||||
WordPressAPIError,
|
||||
WordPressAuthError,
|
||||
WordPressRateLimitError,
|
||||
WordPressTokenInvalidError,
|
||||
WordPressUserInfo,
|
||||
WebsiteNotFoundError,
|
||||
SyncStats,
|
||||
fetch_wordpress_users,
|
||||
get_or_create_user,
|
||||
get_wordpress_user,
|
||||
sync_wordpress_users,
|
||||
verify_website_exists,
|
||||
verify_wordpress_token,
|
||||
)
|
||||
from app.services.ai_generation import (
|
||||
call_openrouter_api,
|
||||
check_cache_reuse,
|
||||
generate_question,
|
||||
generate_with_cache_check,
|
||||
get_ai_stats,
|
||||
get_prompt_template,
|
||||
parse_ai_response,
|
||||
save_ai_question,
|
||||
validate_ai_model,
|
||||
SUPPORTED_MODELS,
|
||||
)
|
||||
from app.services.reporting import (
|
||||
generate_student_performance_report,
|
||||
generate_item_analysis_report,
|
||||
generate_calibration_status_report,
|
||||
generate_tryout_comparison_report,
|
||||
export_report_to_csv,
|
||||
export_report_to_excel,
|
||||
export_report_to_pdf,
|
||||
schedule_report,
|
||||
get_scheduled_report,
|
||||
list_scheduled_reports,
|
||||
cancel_scheduled_report,
|
||||
StudentPerformanceReport,
|
||||
ItemAnalysisReport,
|
||||
CalibrationStatusReport,
|
||||
TryoutComparisonReport,
|
||||
ReportSchedule,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# IRT Calibration
|
||||
"IRTCalibrationError",
|
||||
"calculate_fisher_information",
|
||||
"calculate_item_information",
|
||||
"calculate_probability",
|
||||
"calculate_theta_se",
|
||||
"estimate_b_from_ctt_p",
|
||||
"estimate_theta_mle",
|
||||
"get_session_responses",
|
||||
"nn_to_theta",
|
||||
"theta_to_nn",
|
||||
"update_session_theta",
|
||||
"update_theta_after_response",
|
||||
# CAT Selection
|
||||
"CATSelectionError",
|
||||
"NextItemResult",
|
||||
"TerminationCheck",
|
||||
"check_user_level_reuse",
|
||||
"get_available_levels_for_slot",
|
||||
"get_next_item",
|
||||
"get_next_item_adaptive",
|
||||
"get_next_item_fixed",
|
||||
"get_next_item_hybrid",
|
||||
"should_terminate",
|
||||
"simulate_cat_selection",
|
||||
"update_theta",
|
||||
# WordPress Auth
|
||||
"WordPressAPIError",
|
||||
"WordPressAuthError",
|
||||
"WordPressRateLimitError",
|
||||
"WordPressTokenInvalidError",
|
||||
"WordPressUserInfo",
|
||||
"WebsiteNotFoundError",
|
||||
"SyncStats",
|
||||
"fetch_wordpress_users",
|
||||
"get_or_create_user",
|
||||
"get_wordpress_user",
|
||||
"sync_wordpress_users",
|
||||
"verify_website_exists",
|
||||
"verify_wordpress_token",
|
||||
# AI Generation
|
||||
"call_openrouter_api",
|
||||
"check_cache_reuse",
|
||||
"generate_question",
|
||||
"generate_with_cache_check",
|
||||
"get_ai_stats",
|
||||
"get_prompt_template",
|
||||
"parse_ai_response",
|
||||
"save_ai_question",
|
||||
"validate_ai_model",
|
||||
"SUPPORTED_MODELS",
|
||||
# Reporting
|
||||
"generate_student_performance_report",
|
||||
"generate_item_analysis_report",
|
||||
"generate_calibration_status_report",
|
||||
"generate_tryout_comparison_report",
|
||||
"export_report_to_csv",
|
||||
"export_report_to_excel",
|
||||
"export_report_to_pdf",
|
||||
"schedule_report",
|
||||
"get_scheduled_report",
|
||||
"list_scheduled_reports",
|
||||
"cancel_scheduled_report",
|
||||
"StudentPerformanceReport",
|
||||
"ItemAnalysisReport",
|
||||
"CalibrationStatusReport",
|
||||
"TryoutComparisonReport",
|
||||
"ReportSchedule",
|
||||
]
|
||||
950
backend/app/services/ai_generation.py
Normal file
950
backend/app/services/ai_generation.py
Normal file
@@ -0,0 +1,950 @@
|
||||
"""
|
||||
AI Question Generation Service.
|
||||
|
||||
Handles OpenRouter API integration for generating question variants.
|
||||
Implements caching, user-level reuse checking, and prompt engineering.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import ast
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Literal, Optional, Union
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.item import Item
|
||||
from app.models.ai_generation_run import AIGenerationRun
|
||||
from app.models.tryout import Tryout
|
||||
from app.models.user_answer import UserAnswer
|
||||
from app.schemas.ai import AIModelPricing, AIUsageInfo, GeneratedQuestion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
# OpenRouter API configuration
|
||||
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||
OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"
|
||||
|
||||
# Supported AI models
|
||||
SUPPORTED_MODELS = {
|
||||
settings.OPENROUTER_MODEL_CHEAP: "Mistral Small 4 (Cheap / Fast)",
|
||||
settings.OPENROUTER_MODEL_QWEN: "Qwen 2.5 32B Instruct (Balanced)",
|
||||
settings.OPENROUTER_MODEL_LLAMA: "Llama 3.3 70B (Premium)",
|
||||
}
|
||||
|
||||
# Level mapping for prompts
|
||||
LEVEL_DESCRIPTIONS = {
|
||||
"mudah": "easier (simpler concepts, more straightforward calculations)",
|
||||
"sedang": "medium difficulty",
|
||||
"sulit": "harder (more complex concepts, multi-step reasoning)",
|
||||
}
|
||||
|
||||
OPTION_LABELS = tuple("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
MODEL_PRICING_CACHE_TTL_SECONDS = 60 * 30
|
||||
_model_pricing_cache: dict[str, tuple[float, AIModelPricing | None]] = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenRouterCallResult:
|
||||
content: str
|
||||
usage: AIUsageInfo | None = None
|
||||
|
||||
|
||||
def get_option_labels(options: Dict[str, str] | None) -> list[str]:
|
||||
labels = {
|
||||
str(key).strip().upper()
|
||||
for key, value in (options or {}).items()
|
||||
if str(key).strip() and str(value).strip()
|
||||
}
|
||||
return [label for label in OPTION_LABELS if label in labels]
|
||||
|
||||
|
||||
def _parse_openrouter_price(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
price = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return price if price >= 0 else None
|
||||
|
||||
|
||||
def _build_pricing(raw_pricing: dict[str, Any] | None) -> AIModelPricing | None:
|
||||
if not raw_pricing:
|
||||
return None
|
||||
prompt = _parse_openrouter_price(raw_pricing.get("prompt"))
|
||||
completion = _parse_openrouter_price(raw_pricing.get("completion"))
|
||||
if prompt is None and completion is None:
|
||||
return None
|
||||
return AIModelPricing(
|
||||
prompt=prompt,
|
||||
completion=completion,
|
||||
prompt_per_million=prompt * 1_000_000 if prompt is not None else None,
|
||||
completion_per_million=completion * 1_000_000 if completion is not None else None,
|
||||
)
|
||||
|
||||
|
||||
async def get_model_pricing(model_id: str) -> AIModelPricing | None:
|
||||
cached = _model_pricing_cache.get(model_id)
|
||||
now = time.monotonic()
|
||||
if cached and now - cached[0] < MODEL_PRICING_CACHE_TTL_SECONDS:
|
||||
return cached[1]
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if settings.OPENROUTER_API_KEY:
|
||||
headers["Authorization"] = f"Bearer {settings.OPENROUTER_API_KEY}"
|
||||
|
||||
try:
|
||||
timeout = httpx.Timeout(min(settings.OPENROUTER_TIMEOUT, 5))
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.get(OPENROUTER_MODELS_URL, headers=headers)
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"OpenRouter models pricing request failed: %s - %s",
|
||||
response.status_code,
|
||||
response.text[:240],
|
||||
)
|
||||
_model_pricing_cache[model_id] = (now, None)
|
||||
return None
|
||||
|
||||
for model in response.json().get("data", []):
|
||||
if model.get("id") == model_id:
|
||||
pricing = _build_pricing(model.get("pricing"))
|
||||
_model_pricing_cache[model_id] = (now, pricing)
|
||||
return pricing
|
||||
except Exception as exc:
|
||||
logger.warning("OpenRouter model pricing lookup failed for %s: %s", model_id, exc)
|
||||
|
||||
_model_pricing_cache[model_id] = (now, None)
|
||||
return None
|
||||
|
||||
|
||||
def _calculate_usage_cost(
|
||||
prompt_tokens: int | None,
|
||||
completion_tokens: int | None,
|
||||
pricing: AIModelPricing | None,
|
||||
provider_cost: Any = None,
|
||||
) -> float | None:
|
||||
provider_cost_value = _parse_openrouter_price(provider_cost)
|
||||
if provider_cost_value is not None:
|
||||
return provider_cost_value
|
||||
if pricing is None:
|
||||
return None
|
||||
cost = 0.0
|
||||
has_cost_component = False
|
||||
if prompt_tokens is not None and pricing.prompt is not None:
|
||||
cost += prompt_tokens * pricing.prompt
|
||||
has_cost_component = True
|
||||
if completion_tokens is not None and pricing.completion is not None:
|
||||
cost += completion_tokens * pricing.completion
|
||||
has_cost_component = True
|
||||
return cost if has_cost_component else None
|
||||
|
||||
|
||||
async def build_usage_info(raw_usage: dict[str, Any] | None, model_id: str) -> AIUsageInfo | None:
|
||||
if not raw_usage:
|
||||
return None
|
||||
|
||||
def token_count(key: str) -> int | None:
|
||||
value = raw_usage.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
prompt_tokens = token_count("prompt_tokens")
|
||||
completion_tokens = token_count("completion_tokens")
|
||||
total_tokens = token_count("total_tokens")
|
||||
if total_tokens is None and (prompt_tokens is not None or completion_tokens is not None):
|
||||
total_tokens = (prompt_tokens or 0) + (completion_tokens or 0)
|
||||
|
||||
pricing = await get_model_pricing(model_id)
|
||||
cost_usd = _calculate_usage_cost(
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
pricing,
|
||||
provider_cost=raw_usage.get("cost"),
|
||||
)
|
||||
return AIUsageInfo(
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total_tokens,
|
||||
cost_usd=cost_usd,
|
||||
)
|
||||
|
||||
|
||||
def combine_usage(usages: list[AIUsageInfo | None]) -> AIUsageInfo | None:
|
||||
filtered = [usage for usage in usages if usage is not None]
|
||||
if not filtered:
|
||||
return None
|
||||
|
||||
def summed(field: str) -> int | float | None:
|
||||
values = [getattr(usage, field) for usage in filtered]
|
||||
present = [value for value in values if value is not None]
|
||||
return sum(present) if present else None
|
||||
|
||||
return AIUsageInfo(
|
||||
prompt_tokens=summed("prompt_tokens"),
|
||||
completion_tokens=summed("completion_tokens"),
|
||||
total_tokens=summed("total_tokens"),
|
||||
cost_usd=summed("cost_usd"),
|
||||
)
|
||||
|
||||
|
||||
def get_prompt_template(
|
||||
basis_stem: str,
|
||||
basis_options: Dict[str, str],
|
||||
basis_correct: str,
|
||||
basis_explanation: Optional[str],
|
||||
target_level: Literal["mudah", "sulit"],
|
||||
operator_notes: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate standardized prompt for AI question generation.
|
||||
|
||||
Args:
|
||||
basis_stem: The basis question stem
|
||||
basis_options: The basis question options
|
||||
basis_correct: The basis correct answer
|
||||
basis_explanation: The basis explanation
|
||||
target_level: Target difficulty level
|
||||
|
||||
Returns:
|
||||
Formatted prompt string
|
||||
"""
|
||||
level_desc = LEVEL_DESCRIPTIONS.get(target_level, target_level)
|
||||
option_labels = get_option_labels(basis_options) or ["A", "B", "C", "D"]
|
||||
option_count = len(option_labels)
|
||||
option_label_text = ", ".join(option_labels)
|
||||
example_options = {label: f"Option {label} text" for label in option_labels}
|
||||
|
||||
options_text = "\n".join(
|
||||
[f" {key}: {value}" for key, value in basis_options.items()]
|
||||
)
|
||||
|
||||
explanation_text = (
|
||||
f"Explanation: {basis_explanation}"
|
||||
if basis_explanation
|
||||
else "Explanation: (not provided)"
|
||||
)
|
||||
|
||||
notes_block = ""
|
||||
if operator_notes and operator_notes.strip():
|
||||
notes_block = f"""
|
||||
ADDITIONAL OPERATOR NOTES:
|
||||
{operator_notes.strip()}
|
||||
|
||||
Apply these notes as style constraints as long as they do not conflict with correctness.
|
||||
"""
|
||||
|
||||
prompt = f"""You are an educational content creator specializing in creating assessment questions.
|
||||
|
||||
Given a "Sedang" (medium difficulty) question, generate a new question at a different difficulty level.
|
||||
|
||||
BASIS QUESTION (Sedang level):
|
||||
Question: {basis_stem}
|
||||
Options:
|
||||
{options_text}
|
||||
Correct Answer: {basis_correct}
|
||||
{explanation_text}
|
||||
|
||||
TASK:
|
||||
Generate 1 new question that is {level_desc} than the basis question above.
|
||||
{notes_block}
|
||||
|
||||
REQUIREMENTS:
|
||||
1. Keep the SAME topic/subject matter as the basis question
|
||||
2. Use similar context and terminology
|
||||
3. Create exactly {option_count} answer options with labels exactly: {option_label_text}
|
||||
4. Preserve the basis option count and option labels. Do not omit, add, rename, or merge answer options.
|
||||
5. Only ONE correct answer, and it must be one of: {option_label_text}
|
||||
6. Include a clear explanation of why the correct answer is correct
|
||||
7. Make the question noticeably {level_desc} - not just a minor variation
|
||||
8. Follow and preserve the basis question's inline HTML style. Keep structural and inline tags such as <p>, <br>, <strong>, <b>, <em>, <i>, <u>, <sub>, <sup>, and simple inline attributes such as text alignment when the basis uses them.
|
||||
9. Do not escape HTML tags as text. Return HTML markup in the JSON string values exactly as markup.
|
||||
|
||||
OUTPUT FORMAT:
|
||||
Return ONLY a valid JSON object with this exact structure (no markdown, no code blocks):
|
||||
{{"stem": "Your question text here", "options": {json.dumps(example_options, ensure_ascii=False)}, "correct": "{option_labels[0]}", "explanation": "Explanation text here"}}
|
||||
|
||||
Remember: The correct field must be exactly one of: {option_label_text}."""
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
def parse_ai_response(response_text: str) -> Optional[GeneratedQuestion]:
|
||||
"""
|
||||
Parse AI response to extract question data.
|
||||
|
||||
Handles various response formats including JSON code blocks.
|
||||
|
||||
Args:
|
||||
response_text: Raw AI response text
|
||||
|
||||
Returns:
|
||||
GeneratedQuestion if parsing successful, None otherwise
|
||||
"""
|
||||
if not response_text:
|
||||
return None
|
||||
|
||||
cleaned = response_text.strip()
|
||||
candidates = _extract_json_candidates(cleaned)
|
||||
for candidate in candidates:
|
||||
candidate_clean = _sanitize_json_candidate(candidate)
|
||||
parsed = _try_parse_json_like(candidate_clean)
|
||||
if isinstance(parsed, dict):
|
||||
question = validate_and_create_question(parsed)
|
||||
if question:
|
||||
return question
|
||||
|
||||
logger.warning(f"Failed to parse AI response: {cleaned[:240]}...")
|
||||
return None
|
||||
|
||||
|
||||
def validate_and_create_question(data: Dict[str, Any]) -> Optional[GeneratedQuestion]:
|
||||
"""
|
||||
Validate parsed data and create GeneratedQuestion.
|
||||
|
||||
Args:
|
||||
data: Parsed JSON data
|
||||
|
||||
Returns:
|
||||
GeneratedQuestion if valid, None otherwise
|
||||
"""
|
||||
stem = str(data.get("stem") or data.get("question") or "").strip()
|
||||
if not stem:
|
||||
logger.warning(f"Missing question stem in AI response: {data.keys()}")
|
||||
return None
|
||||
|
||||
options = _normalize_options(data.get("options"))
|
||||
if not options:
|
||||
logger.warning("Options cannot be normalized to a labeled option map")
|
||||
return None
|
||||
|
||||
correct = _normalize_correct_answer(
|
||||
data.get("correct") or data.get("correct_answer") or data.get("answer")
|
||||
)
|
||||
if correct not in set(options.keys()):
|
||||
logger.warning(f"Invalid correct answer: {correct}")
|
||||
return None
|
||||
|
||||
return GeneratedQuestion(
|
||||
stem=stem,
|
||||
options=options,
|
||||
correct=correct,
|
||||
explanation=str(data.get("explanation") or data.get("rationale") or "").strip() or None,
|
||||
)
|
||||
|
||||
|
||||
def _extract_json_candidates(text: str) -> list[str]:
|
||||
candidates: list[str] = []
|
||||
|
||||
code_blocks = re.findall(r"```(?:json)?\s*([\s\S]*?)\s*```", text)
|
||||
candidates.extend(block.strip() for block in code_blocks if block.strip())
|
||||
|
||||
balanced = _extract_first_balanced_object(text)
|
||||
if balanced:
|
||||
candidates.append(balanced)
|
||||
|
||||
candidates.append(text.strip())
|
||||
deduped: list[str] = []
|
||||
seen = set()
|
||||
for candidate in candidates:
|
||||
if candidate and candidate not in seen:
|
||||
deduped.append(candidate)
|
||||
seen.add(candidate)
|
||||
return deduped
|
||||
|
||||
|
||||
def _extract_first_balanced_object(text: str) -> str | None:
|
||||
start = text.find("{")
|
||||
if start == -1:
|
||||
return None
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
for idx in range(start, len(text)):
|
||||
ch = text[idx]
|
||||
if escape_next:
|
||||
escape_next = False
|
||||
continue
|
||||
if ch == "\\" and in_string:
|
||||
escape_next = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return text[start: idx + 1]
|
||||
return None
|
||||
|
||||
|
||||
def _sanitize_json_candidate(candidate: str) -> str:
|
||||
cleaned = candidate.strip().lstrip("\ufeff")
|
||||
cleaned = cleaned.replace("“", '"').replace("”", '"').replace("’", "'")
|
||||
cleaned = re.sub(r",\s*([}\]])", r"\1", cleaned)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _try_parse_json_like(candidate: str) -> Any:
|
||||
try:
|
||||
return json.loads(candidate)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
try:
|
||||
# Fallback for Python-like dict outputs using single quotes.
|
||||
return ast.literal_eval(candidate)
|
||||
except (ValueError, SyntaxError):
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_options(raw_options: Any) -> dict[str, str]:
|
||||
if isinstance(raw_options, dict):
|
||||
normalized = {str(k).strip().upper(): str(v).strip() for k, v in raw_options.items()}
|
||||
return {k: normalized[k] for k in OPTION_LABELS if normalized.get(k, "")}
|
||||
|
||||
if isinstance(raw_options, list):
|
||||
mapped: dict[str, str] = {}
|
||||
for idx, opt in enumerate(raw_options):
|
||||
if isinstance(opt, dict):
|
||||
key = str(opt.get("increment") or opt.get("key") or "").strip().upper()
|
||||
text = str(opt.get("text") or opt.get("label") or opt.get("value") or "").strip()
|
||||
else:
|
||||
key = ""
|
||||
text = str(opt).strip()
|
||||
if not key and idx < len(OPTION_LABELS):
|
||||
key = OPTION_LABELS[idx]
|
||||
if key in OPTION_LABELS and text:
|
||||
mapped[key] = text
|
||||
return mapped
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def _normalize_correct_answer(raw_correct: Any) -> str:
|
||||
if raw_correct is None:
|
||||
return ""
|
||||
raw_text = str(raw_correct).strip().upper()
|
||||
if raw_text in OPTION_LABELS:
|
||||
return raw_text
|
||||
if raw_text.isdigit():
|
||||
idx = int(raw_text)
|
||||
if 1 <= idx <= len(OPTION_LABELS):
|
||||
return OPTION_LABELS[idx - 1]
|
||||
if 0 <= idx < len(OPTION_LABELS):
|
||||
return OPTION_LABELS[idx]
|
||||
if raw_text.startswith("OPTION ") and raw_text[-1:] in OPTION_LABELS:
|
||||
return raw_text[-1]
|
||||
return raw_text[:1]
|
||||
|
||||
|
||||
def generated_matches_basis_options(generated: GeneratedQuestion, basis_item: Item) -> bool:
|
||||
basis_labels = get_option_labels(basis_item.options)
|
||||
generated_labels = get_option_labels(generated.options)
|
||||
if basis_labels != generated_labels:
|
||||
logger.warning(
|
||||
"Generated option labels do not match basis: basis=%s generated=%s",
|
||||
basis_labels,
|
||||
generated_labels,
|
||||
)
|
||||
return False
|
||||
if generated.correct not in set(basis_labels):
|
||||
logger.warning(
|
||||
"Generated correct answer %s is outside basis labels %s",
|
||||
generated.correct,
|
||||
basis_labels,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def call_openrouter_api(
|
||||
prompt: str,
|
||||
model: str,
|
||||
max_retries: int = 3,
|
||||
) -> Optional[OpenRouterCallResult]:
|
||||
"""
|
||||
Call OpenRouter API to generate question.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to send
|
||||
model: AI model to use
|
||||
max_retries: Maximum retry attempts
|
||||
|
||||
Returns:
|
||||
OpenRouterCallResult with response text and usage, or None if failed
|
||||
"""
|
||||
if not settings.OPENROUTER_API_KEY:
|
||||
logger.error("OPENROUTER_API_KEY not configured")
|
||||
return None
|
||||
|
||||
if model not in SUPPORTED_MODELS:
|
||||
logger.error(f"Unsupported AI model: {model}")
|
||||
return None
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://github.com/irt-bank-soal",
|
||||
"X-Title": "IRT Bank Soal",
|
||||
}
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
}
|
||||
],
|
||||
"max_tokens": 2000,
|
||||
"temperature": 0.7,
|
||||
}
|
||||
provider_order = [
|
||||
provider for provider in settings.OPENROUTER_PROVIDER_ORDER if provider.strip()
|
||||
]
|
||||
if provider_order:
|
||||
payload["provider"] = {
|
||||
"order": provider_order,
|
||||
"allow_fallbacks": settings.OPENROUTER_ALLOW_PROVIDER_FALLBACKS,
|
||||
}
|
||||
|
||||
timeout = httpx.Timeout(settings.OPENROUTER_TIMEOUT)
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(
|
||||
OPENROUTER_API_URL,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
choices = data.get("choices", [])
|
||||
if choices:
|
||||
message = choices[0].get("message", {})
|
||||
content = message.get("content")
|
||||
if not content:
|
||||
logger.warning("OpenRouter response had no message content")
|
||||
return None
|
||||
usage = await build_usage_info(data.get("usage"), model)
|
||||
return OpenRouterCallResult(content=content, usage=usage)
|
||||
logger.warning("No choices in OpenRouter response")
|
||||
return None
|
||||
|
||||
elif response.status_code == 429:
|
||||
# Rate limited - wait and retry
|
||||
logger.warning(f"Rate limited, attempt {attempt + 1}/{max_retries}")
|
||||
if attempt < max_retries - 1:
|
||||
import asyncio
|
||||
await asyncio.sleep(2 ** attempt)
|
||||
continue
|
||||
return None
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
f"OpenRouter API error: {response.status_code} - {response.text}"
|
||||
)
|
||||
return None
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(f"OpenRouter timeout, attempt {attempt + 1}/{max_retries}")
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"OpenRouter API call failed: {e}")
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def generate_question(
|
||||
basis_item: Item,
|
||||
target_level: Literal["mudah", "sulit"],
|
||||
ai_model: str = settings.OPENROUTER_MODEL_QWEN,
|
||||
operator_notes: Optional[str] = None,
|
||||
) -> Optional[GeneratedQuestion]:
|
||||
"""
|
||||
Generate a new question based on a basis item.
|
||||
|
||||
Args:
|
||||
basis_item: The basis item (must be sedang level)
|
||||
target_level: Target difficulty level
|
||||
ai_model: AI model to use
|
||||
|
||||
Returns:
|
||||
GeneratedQuestion if successful, None otherwise
|
||||
"""
|
||||
# Build prompt
|
||||
prompt = get_prompt_template(
|
||||
basis_stem=basis_item.stem,
|
||||
basis_options=basis_item.options,
|
||||
basis_correct=basis_item.correct_answer,
|
||||
basis_explanation=basis_item.explanation,
|
||||
target_level=target_level,
|
||||
operator_notes=operator_notes,
|
||||
)
|
||||
|
||||
max_generation_attempts = 3
|
||||
for attempt in range(1, max_generation_attempts + 1):
|
||||
api_result = await call_openrouter_api(prompt, ai_model)
|
||||
if not api_result:
|
||||
logger.error("No response from OpenRouter API")
|
||||
continue
|
||||
|
||||
generated = parse_ai_response(api_result.content)
|
||||
if generated and generated_matches_basis_options(generated, basis_item):
|
||||
generated = generated.model_copy(update={"usage": api_result.usage})
|
||||
return generated
|
||||
|
||||
logger.warning(
|
||||
"Failed to parse or validate AI response (attempt %s/%s), retrying",
|
||||
attempt,
|
||||
max_generation_attempts,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def check_cache_reuse(
|
||||
tryout_id: str,
|
||||
slot: int,
|
||||
level: str,
|
||||
wp_user_id: str,
|
||||
website_id: int,
|
||||
db: AsyncSession,
|
||||
) -> Optional[Item]:
|
||||
"""
|
||||
Check if there's a cached item that the user hasn't answered yet.
|
||||
|
||||
Query DB for existing item matching (tryout_id, slot, level).
|
||||
Check if user already answered this item at this difficulty level.
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
slot: Question slot
|
||||
level: Difficulty level
|
||||
wp_user_id: WordPress user ID
|
||||
website_id: Website identifier
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Cached item if found and user hasn't answered, None otherwise
|
||||
"""
|
||||
# Find existing items at this slot/level
|
||||
result = await db.execute(
|
||||
select(Item).where(
|
||||
and_(
|
||||
Item.tryout_id == tryout_id,
|
||||
Item.website_id == website_id,
|
||||
Item.slot == slot,
|
||||
Item.level == level,
|
||||
)
|
||||
)
|
||||
)
|
||||
existing_items = result.scalars().all()
|
||||
|
||||
if not existing_items:
|
||||
return None
|
||||
|
||||
# Check each item to find one the user hasn't answered
|
||||
for item in existing_items:
|
||||
# Check if user has answered this item
|
||||
answer_result = await db.execute(
|
||||
select(UserAnswer).where(
|
||||
and_(
|
||||
UserAnswer.item_id == item.id,
|
||||
UserAnswer.wp_user_id == wp_user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
user_answer = answer_result.scalar_one_or_none()
|
||||
|
||||
if user_answer is None:
|
||||
# User hasn't answered this item - can reuse
|
||||
logger.info(
|
||||
f"Cache hit for tryout={tryout_id}, slot={slot}, level={level}, "
|
||||
f"item_id={item.id}, user={wp_user_id}"
|
||||
)
|
||||
return item
|
||||
|
||||
# All items have been answered by this user
|
||||
logger.info(
|
||||
f"Cache miss (user answered all) for tryout={tryout_id}, slot={slot}, "
|
||||
f"level={level}, user={wp_user_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def generate_with_cache_check(
|
||||
tryout_id: str,
|
||||
slot: int,
|
||||
level: Literal["mudah", "sulit"],
|
||||
wp_user_id: str,
|
||||
website_id: int,
|
||||
db: AsyncSession,
|
||||
ai_model: str = settings.OPENROUTER_MODEL_QWEN,
|
||||
) -> tuple[Optional[Union[Item, GeneratedQuestion]], bool]:
|
||||
"""
|
||||
Generate question with cache checking.
|
||||
|
||||
First checks if AI generation is enabled for the tryout.
|
||||
Then checks for cached items the user hasn't answered.
|
||||
If cache miss, generates new question via AI.
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
slot: Question slot
|
||||
level: Target difficulty level
|
||||
wp_user_id: WordPress user ID
|
||||
website_id: Website identifier
|
||||
db: Database session
|
||||
ai_model: AI model to use
|
||||
|
||||
Returns:
|
||||
Tuple of (item/question or None, is_cached)
|
||||
"""
|
||||
# Check if AI generation is enabled for this tryout
|
||||
tryout_result = await db.execute(
|
||||
select(Tryout).where(
|
||||
and_(
|
||||
Tryout.tryout_id == tryout_id,
|
||||
Tryout.website_id == website_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
tryout = tryout_result.scalar_one_or_none()
|
||||
|
||||
if tryout and not tryout.ai_generation_enabled:
|
||||
logger.info(f"AI generation disabled for tryout={tryout_id}")
|
||||
# Still check cache even if AI disabled
|
||||
cached_item = await check_cache_reuse(
|
||||
tryout_id, slot, level, wp_user_id, website_id, db
|
||||
)
|
||||
if cached_item:
|
||||
return cached_item, True
|
||||
return None, False
|
||||
|
||||
# Check cache for reusable item
|
||||
cached_item = await check_cache_reuse(
|
||||
tryout_id, slot, level, wp_user_id, website_id, db
|
||||
)
|
||||
|
||||
if cached_item:
|
||||
return cached_item, True
|
||||
|
||||
# Cache miss - need to generate
|
||||
# Get basis item (sedang level at same slot)
|
||||
basis_result = await db.execute(
|
||||
select(Item).where(
|
||||
and_(
|
||||
Item.tryout_id == tryout_id,
|
||||
Item.website_id == website_id,
|
||||
Item.slot == slot,
|
||||
Item.level == "sedang",
|
||||
)
|
||||
).limit(1)
|
||||
)
|
||||
basis_item = basis_result.scalar_one_or_none()
|
||||
|
||||
if not basis_item:
|
||||
logger.error(
|
||||
f"No basis item found for tryout={tryout_id}, slot={slot}"
|
||||
)
|
||||
return None, False
|
||||
|
||||
# Generate new question
|
||||
generated = await generate_question(basis_item, level, ai_model)
|
||||
|
||||
if not generated:
|
||||
logger.error(
|
||||
f"Failed to generate question for tryout={tryout_id}, slot={slot}, level={level}"
|
||||
)
|
||||
return None, False
|
||||
|
||||
return generated, False
|
||||
|
||||
|
||||
async def save_ai_question(
|
||||
generated_data: GeneratedQuestion,
|
||||
tryout_id: str,
|
||||
website_id: int,
|
||||
basis_item_id: int,
|
||||
slot: int,
|
||||
level: Literal["mudah", "sedang", "sulit"],
|
||||
ai_model: str,
|
||||
db: AsyncSession,
|
||||
generation_run_id: int | None = None,
|
||||
source_snapshot_question_id: int | None = None,
|
||||
variant_status: str = "draft",
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Save AI-generated question to database.
|
||||
|
||||
Args:
|
||||
generated_data: Generated question data
|
||||
tryout_id: Tryout identifier
|
||||
website_id: Website identifier
|
||||
basis_item_id: Basis item ID
|
||||
slot: Question slot
|
||||
level: Difficulty level
|
||||
ai_model: AI model used
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Created item ID or None if failed
|
||||
"""
|
||||
try:
|
||||
new_item = Item(
|
||||
tryout_id=tryout_id,
|
||||
website_id=website_id,
|
||||
slot=slot,
|
||||
level=level,
|
||||
stem=generated_data.stem,
|
||||
options=generated_data.options,
|
||||
correct_answer=generated_data.correct,
|
||||
explanation=generated_data.explanation,
|
||||
generated_by="ai",
|
||||
ai_model=ai_model,
|
||||
basis_item_id=basis_item_id,
|
||||
generation_run_id=generation_run_id,
|
||||
source_snapshot_question_id=source_snapshot_question_id,
|
||||
variant_status=variant_status,
|
||||
calibrated=False,
|
||||
ctt_p=None,
|
||||
ctt_bobot=None,
|
||||
ctt_category=None,
|
||||
irt_b=None,
|
||||
irt_se=None,
|
||||
calibration_sample_size=0,
|
||||
)
|
||||
|
||||
db.add(new_item)
|
||||
await db.flush() # Get the ID without committing
|
||||
|
||||
logger.info(
|
||||
f"Saved AI-generated item: id={new_item.id}, tryout={tryout_id}, "
|
||||
f"slot={slot}, level={level}, model={ai_model}"
|
||||
)
|
||||
|
||||
return new_item.id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save AI-generated question: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def create_generation_run(
|
||||
basis_item_id: int,
|
||||
target_level: Literal["mudah", "sulit"],
|
||||
requested_count: int,
|
||||
model: str,
|
||||
created_by: str,
|
||||
db: AsyncSession,
|
||||
source_snapshot_question_id: int | None = None,
|
||||
operator_notes: str | None = None,
|
||||
prompt_version: str = "v1",
|
||||
) -> int:
|
||||
run = AIGenerationRun(
|
||||
basis_item_id=basis_item_id,
|
||||
source_snapshot_question_id=source_snapshot_question_id,
|
||||
target_level=target_level,
|
||||
requested_count=requested_count,
|
||||
model=model,
|
||||
prompt_version=prompt_version,
|
||||
operator_notes=operator_notes,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(run)
|
||||
await db.flush()
|
||||
return int(run.id)
|
||||
|
||||
|
||||
async def generate_questions_batch(
|
||||
basis_item: Item,
|
||||
target_level: Literal["mudah", "sulit"],
|
||||
ai_model: str,
|
||||
count: int,
|
||||
operator_notes: Optional[str] = None,
|
||||
) -> list[GeneratedQuestion]:
|
||||
generated_items: list[GeneratedQuestion] = []
|
||||
for _ in range(count):
|
||||
generated = await generate_question(
|
||||
basis_item=basis_item,
|
||||
target_level=target_level,
|
||||
ai_model=ai_model,
|
||||
operator_notes=operator_notes,
|
||||
)
|
||||
if generated is not None:
|
||||
generated_items.append(generated)
|
||||
return generated_items
|
||||
|
||||
|
||||
async def get_ai_stats(db: AsyncSession, website_id: int | None = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get AI generation statistics.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Statistics dictionary
|
||||
"""
|
||||
filters = [Item.generated_by == "ai"]
|
||||
if website_id is not None:
|
||||
filters.append(Item.website_id == website_id)
|
||||
|
||||
# Total AI-generated items
|
||||
total_result = await db.execute(select(func.count(Item.id)).where(*filters))
|
||||
total_ai_items = total_result.scalar() or 0
|
||||
|
||||
# Items by model
|
||||
model_result = await db.execute(
|
||||
select(Item.ai_model, func.count(Item.id))
|
||||
.where(*filters)
|
||||
.where(Item.ai_model.isnot(None))
|
||||
.group_by(Item.ai_model)
|
||||
)
|
||||
items_by_model = {row[0]: row[1] for row in model_result.all()}
|
||||
|
||||
# Note: Cache hit rate would need to be tracked separately
|
||||
# This is a placeholder for now
|
||||
return {
|
||||
"total_ai_items": total_ai_items,
|
||||
"items_by_model": items_by_model,
|
||||
"cache_hit_rate": 0.0,
|
||||
"total_cache_hits": 0,
|
||||
"total_requests": 0,
|
||||
}
|
||||
|
||||
|
||||
def validate_ai_model(model: str) -> bool:
|
||||
"""
|
||||
Validate that the AI model is supported.
|
||||
|
||||
Args:
|
||||
model: AI model identifier
|
||||
|
||||
Returns:
|
||||
True if model is supported
|
||||
"""
|
||||
return model in SUPPORTED_MODELS
|
||||
748
backend/app/services/cat_selection.py
Normal file
748
backend/app/services/cat_selection.py
Normal file
@@ -0,0 +1,748 @@
|
||||
"""
|
||||
CAT (Computerized Adaptive Testing) Selection Service.
|
||||
|
||||
Implements adaptive item selection algorithms for IRT-based testing.
|
||||
Supports three modes: CTT (fixed), IRT (adaptive), and hybrid.
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from sqlalchemy import and_, not_, or_, select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import Item, Session, Tryout, UserAnswer
|
||||
from app.services.irt_calibration import (
|
||||
calculate_item_information,
|
||||
estimate_b_from_ctt_p,
|
||||
estimate_theta_mle,
|
||||
update_theta_after_response,
|
||||
)
|
||||
|
||||
|
||||
class CATSelectionError(Exception):
|
||||
"""Exception raised for CAT selection errors."""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class NextItemResult:
|
||||
"""Result of next item selection."""
|
||||
item: Optional[Item]
|
||||
selection_method: str # 'fixed', 'adaptive', 'hybrid'
|
||||
slot: Optional[int]
|
||||
level: Optional[str]
|
||||
reason: str # Why this item was selected
|
||||
|
||||
|
||||
@dataclass
|
||||
class TerminationCheck:
|
||||
"""Result of termination condition check."""
|
||||
should_terminate: bool
|
||||
reason: str
|
||||
items_answered: int
|
||||
current_se: Optional[float]
|
||||
max_items: Optional[int]
|
||||
se_threshold_met: bool
|
||||
|
||||
|
||||
# Default SE threshold for termination
|
||||
DEFAULT_SE_THRESHOLD = 0.5
|
||||
# Default max items if not configured
|
||||
DEFAULT_MAX_ITEMS = 50
|
||||
SERVABLE_VARIANT_STATUSES = ("active", "approved")
|
||||
|
||||
|
||||
def _servable_item_filter():
|
||||
return Item.variant_status.in_(SERVABLE_VARIANT_STATUSES)
|
||||
|
||||
|
||||
async def _get_user_answered_slot_levels(
|
||||
db: AsyncSession,
|
||||
wp_user_id: str,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> set[tuple[int, str]]:
|
||||
"""Return slot/level pairs this user has already seen for this tryout."""
|
||||
result = await db.execute(
|
||||
select(Item.slot, Item.level)
|
||||
.join(UserAnswer, UserAnswer.item_id == Item.id)
|
||||
.where(
|
||||
UserAnswer.wp_user_id == wp_user_id,
|
||||
UserAnswer.website_id == website_id,
|
||||
UserAnswer.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
return {(int(slot), str(level)) for slot, level in result.all()}
|
||||
|
||||
|
||||
async def get_next_item_fixed(
|
||||
db: AsyncSession,
|
||||
session_id: str,
|
||||
tryout_id: str,
|
||||
website_id: int,
|
||||
level_filter: Optional[str] = None
|
||||
) -> NextItemResult:
|
||||
"""
|
||||
Get next item in fixed order (CTT mode).
|
||||
|
||||
Returns items in slot order (1, 2, 3, ...).
|
||||
Filters by level if specified.
|
||||
Checks if student already answered this item.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session_id: Session identifier
|
||||
tryout_id: Tryout identifier
|
||||
website_id: Website identifier
|
||||
level_filter: Optional difficulty level filter ('mudah', 'sedang', 'sulit')
|
||||
|
||||
Returns:
|
||||
NextItemResult with selected item or None if no more items
|
||||
"""
|
||||
# Get session to find current position and answered items
|
||||
session_query = select(Session).where(Session.session_id == session_id)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise CATSelectionError(f"Session {session_id} not found")
|
||||
|
||||
# Get all item IDs already answered by this user in this session
|
||||
answered_query = select(UserAnswer.item_id).where(
|
||||
UserAnswer.session_id == session_id
|
||||
)
|
||||
answered_result = await db.execute(answered_query)
|
||||
answered_item_ids = [row[0] for row in answered_result.all()]
|
||||
|
||||
# Build query for available items
|
||||
query = (
|
||||
select(Item)
|
||||
.where(
|
||||
Item.tryout_id == tryout_id,
|
||||
Item.website_id == website_id,
|
||||
_servable_item_filter(),
|
||||
)
|
||||
.order_by(Item.slot, Item.level)
|
||||
)
|
||||
|
||||
# Apply level filter if specified
|
||||
if level_filter:
|
||||
query = query.where(Item.level == level_filter)
|
||||
|
||||
# Exclude already answered items
|
||||
if answered_item_ids:
|
||||
query = query.where(not_(Item.id.in_(answered_item_ids)))
|
||||
|
||||
result = await db.execute(query)
|
||||
items = list(result.scalars().all())
|
||||
user_answered_slot_levels = await _get_user_answered_slot_levels(
|
||||
db, session.wp_user_id, website_id, tryout_id
|
||||
)
|
||||
if user_answered_slot_levels:
|
||||
items = [
|
||||
item
|
||||
for item in items
|
||||
if (item.slot, item.level) not in user_answered_slot_levels
|
||||
]
|
||||
|
||||
if not items:
|
||||
return NextItemResult(
|
||||
item=None,
|
||||
selection_method="fixed",
|
||||
slot=None,
|
||||
level=None,
|
||||
reason="No more items available"
|
||||
)
|
||||
|
||||
# Return first available item (lowest slot)
|
||||
next_item = items[0]
|
||||
|
||||
return NextItemResult(
|
||||
item=next_item,
|
||||
selection_method="fixed",
|
||||
slot=next_item.slot,
|
||||
level=next_item.level,
|
||||
reason=f"Fixed order selection - slot {next_item.slot}"
|
||||
)
|
||||
|
||||
|
||||
async def get_next_item_adaptive(
|
||||
db: AsyncSession,
|
||||
session_id: str,
|
||||
tryout_id: str,
|
||||
website_id: int,
|
||||
ai_generation_enabled: bool = False,
|
||||
level_filter: Optional[str] = None
|
||||
) -> NextItemResult:
|
||||
"""
|
||||
Get next item using adaptive selection (IRT mode).
|
||||
|
||||
Finds item where b ≈ current theta.
|
||||
Only uses calibrated items (calibrated=True).
|
||||
Filters: student hasn't answered this item.
|
||||
Filters: AI-generated items only if AI generation is enabled.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session_id: Session identifier
|
||||
tryout_id: Tryout identifier
|
||||
website_id: Website identifier
|
||||
ai_generation_enabled: Whether to include AI-generated items
|
||||
level_filter: Optional difficulty level filter
|
||||
|
||||
Returns:
|
||||
NextItemResult with selected item or None if no suitable items
|
||||
"""
|
||||
# Get session for current theta
|
||||
session_query = select(Session).where(Session.session_id == session_id)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise CATSelectionError(f"Session {session_id} not found")
|
||||
|
||||
# Get current theta (default to 0.0 for first item)
|
||||
current_theta = session.theta if session.theta is not None else 0.0
|
||||
|
||||
# Get all item IDs already answered by this user in this session
|
||||
answered_query = select(UserAnswer.item_id).where(
|
||||
UserAnswer.session_id == session_id
|
||||
)
|
||||
answered_result = await db.execute(answered_query)
|
||||
answered_item_ids = [row[0] for row in answered_result.all()]
|
||||
|
||||
# Build query for available calibrated items
|
||||
query = (
|
||||
select(Item)
|
||||
.where(
|
||||
Item.tryout_id == tryout_id,
|
||||
Item.website_id == website_id,
|
||||
_servable_item_filter(),
|
||||
Item.calibrated == True # Only calibrated items for IRT
|
||||
)
|
||||
)
|
||||
|
||||
# Apply level filter if specified
|
||||
if level_filter:
|
||||
query = query.where(Item.level == level_filter)
|
||||
|
||||
# Exclude already answered items
|
||||
if answered_item_ids:
|
||||
query = query.where(not_(Item.id.in_(answered_item_ids)))
|
||||
|
||||
# Filter AI-generated items if AI generation is disabled
|
||||
if not ai_generation_enabled:
|
||||
query = query.where(Item.generated_by == 'manual')
|
||||
|
||||
result = await db.execute(query)
|
||||
items = list(result.scalars().all())
|
||||
user_answered_slot_levels = await _get_user_answered_slot_levels(
|
||||
db, session.wp_user_id, website_id, tryout_id
|
||||
)
|
||||
if user_answered_slot_levels:
|
||||
items = [
|
||||
item
|
||||
for item in items
|
||||
if (item.slot, item.level) not in user_answered_slot_levels
|
||||
]
|
||||
|
||||
if not items:
|
||||
return NextItemResult(
|
||||
item=None,
|
||||
selection_method="adaptive",
|
||||
slot=None,
|
||||
level=None,
|
||||
reason="No calibrated items available"
|
||||
)
|
||||
|
||||
# Find item with b closest to current theta
|
||||
# Also consider item information (prefer items with higher information at current theta)
|
||||
best_item = None
|
||||
best_score = float('inf')
|
||||
|
||||
for item in items:
|
||||
if item.irt_b is None:
|
||||
# Skip items without b parameter (shouldn't happen with calibrated=True)
|
||||
continue
|
||||
|
||||
# Calculate distance from theta
|
||||
b_distance = abs(item.irt_b - current_theta)
|
||||
|
||||
# Calculate item information at current theta
|
||||
information = calculate_item_information(current_theta, item.irt_b)
|
||||
|
||||
# Score: minimize distance, maximize information
|
||||
# Use weighted combination: lower score is better
|
||||
# Add small penalty for lower information
|
||||
score = b_distance - (0.1 * information)
|
||||
|
||||
if score < best_score:
|
||||
best_score = score
|
||||
best_item = item
|
||||
|
||||
if not best_item:
|
||||
return NextItemResult(
|
||||
item=None,
|
||||
selection_method="adaptive",
|
||||
slot=None,
|
||||
level=None,
|
||||
reason="No items with valid IRT parameters available"
|
||||
)
|
||||
|
||||
return NextItemResult(
|
||||
item=best_item,
|
||||
selection_method="adaptive",
|
||||
slot=best_item.slot,
|
||||
level=best_item.level,
|
||||
reason=f"Adaptive selection - b={best_item.irt_b:.3f} ≈ θ={current_theta:.3f}"
|
||||
)
|
||||
|
||||
|
||||
async def get_next_item_hybrid(
|
||||
db: AsyncSession,
|
||||
session_id: str,
|
||||
tryout_id: str,
|
||||
website_id: int,
|
||||
hybrid_transition_slot: int = 10,
|
||||
ai_generation_enabled: bool = False,
|
||||
level_filter: Optional[str] = None
|
||||
) -> NextItemResult:
|
||||
"""
|
||||
Get next item using hybrid selection.
|
||||
|
||||
Uses fixed order for first N items, then switches to adaptive.
|
||||
Falls back to CTT if no calibrated items available.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session_id: Session identifier
|
||||
tryout_id: Tryout identifier
|
||||
website_id: Website identifier
|
||||
hybrid_transition_slot: Slot number to transition from fixed to adaptive
|
||||
ai_generation_enabled: Whether to include AI-generated items
|
||||
level_filter: Optional difficulty level filter
|
||||
|
||||
Returns:
|
||||
NextItemResult with selected item or None if no items available
|
||||
"""
|
||||
# Get session to check current position
|
||||
session_query = select(Session).where(Session.session_id == session_id)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise CATSelectionError(f"Session {session_id} not found")
|
||||
|
||||
# Count answered items to determine current position
|
||||
count_query = select(func.count(UserAnswer.id)).where(
|
||||
UserAnswer.session_id == session_id
|
||||
)
|
||||
count_result = await db.execute(count_query)
|
||||
items_answered = count_result.scalar() or 0
|
||||
|
||||
# Determine current slot (next slot to fill)
|
||||
current_slot = items_answered + 1
|
||||
|
||||
# Check if we're still in fixed phase
|
||||
if current_slot <= hybrid_transition_slot:
|
||||
# Use fixed selection for initial items
|
||||
result = await get_next_item_fixed(
|
||||
db, session_id, tryout_id, website_id, level_filter
|
||||
)
|
||||
result.selection_method = "hybrid_fixed"
|
||||
result.reason = f"Hybrid mode (fixed phase) - slot {current_slot}"
|
||||
return result
|
||||
|
||||
# Try adaptive selection
|
||||
adaptive_result = await get_next_item_adaptive(
|
||||
db, session_id, tryout_id, website_id, ai_generation_enabled, level_filter
|
||||
)
|
||||
|
||||
if adaptive_result.item is not None:
|
||||
adaptive_result.selection_method = "hybrid_adaptive"
|
||||
adaptive_result.reason = f"Hybrid mode (adaptive phase) - {adaptive_result.reason}"
|
||||
return adaptive_result
|
||||
|
||||
# Fallback to fixed selection if no calibrated items available
|
||||
fixed_result = await get_next_item_fixed(
|
||||
db, session_id, tryout_id, website_id, level_filter
|
||||
)
|
||||
fixed_result.selection_method = "hybrid_fallback"
|
||||
fixed_result.reason = f"Hybrid mode (CTT fallback) - {fixed_result.reason}"
|
||||
return fixed_result
|
||||
|
||||
|
||||
async def update_theta(
|
||||
db: AsyncSession,
|
||||
session_id: str,
|
||||
item_id: int,
|
||||
is_correct: bool
|
||||
) -> tuple[float, float]:
|
||||
"""
|
||||
Update session theta estimate based on response.
|
||||
|
||||
Calls estimate_theta from irt_calibration.py.
|
||||
Updates session.theta and session.theta_se.
|
||||
Handles initial theta (uses 0.0 for first item).
|
||||
Clamps theta to [-3, +3].
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session_id: Session identifier
|
||||
item_id: Item that was answered
|
||||
is_correct: Whether the answer was correct
|
||||
|
||||
Returns:
|
||||
Tuple of (theta, theta_se)
|
||||
"""
|
||||
return await update_theta_after_response(db, session_id, item_id, is_correct)
|
||||
|
||||
|
||||
async def should_terminate(
|
||||
db: AsyncSession,
|
||||
session_id: str,
|
||||
max_items: Optional[int] = None,
|
||||
se_threshold: float = DEFAULT_SE_THRESHOLD
|
||||
) -> TerminationCheck:
|
||||
"""
|
||||
Check if session should terminate.
|
||||
|
||||
Termination conditions:
|
||||
- Reached max_items
|
||||
- Reached SE threshold (theta_se < se_threshold)
|
||||
- No more items available
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session_id: Session identifier
|
||||
max_items: Maximum items allowed (None = no limit)
|
||||
se_threshold: SE threshold for termination
|
||||
|
||||
Returns:
|
||||
TerminationCheck with termination status and reason
|
||||
"""
|
||||
# Get session
|
||||
session_query = select(Session).where(Session.session_id == session_id)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise CATSelectionError(f"Session {session_id} not found")
|
||||
|
||||
# Count answered items
|
||||
count_query = select(func.count(UserAnswer.id)).where(
|
||||
UserAnswer.session_id == session_id
|
||||
)
|
||||
count_result = await db.execute(count_query)
|
||||
items_answered = count_result.scalar() or 0
|
||||
|
||||
# Check max items
|
||||
max_items_reached = False
|
||||
if max_items is not None and items_answered >= max_items:
|
||||
max_items_reached = True
|
||||
|
||||
# Check SE threshold
|
||||
current_se = session.theta_se
|
||||
se_threshold_met = False
|
||||
if current_se is not None and current_se < se_threshold:
|
||||
se_threshold_met = True
|
||||
|
||||
# Check if we have enough items for SE threshold (at least 15 items per PRD)
|
||||
min_items_for_se = 15
|
||||
se_threshold_met = se_threshold_met and items_answered >= min_items_for_se
|
||||
|
||||
# Determine termination
|
||||
should_term = max_items_reached or se_threshold_met
|
||||
|
||||
# Build reason
|
||||
reasons = []
|
||||
if max_items_reached:
|
||||
reasons.append(f"max items reached ({items_answered}/{max_items})")
|
||||
if se_threshold_met:
|
||||
reasons.append(f"SE threshold met ({current_se:.3f} < {se_threshold})")
|
||||
|
||||
if not reasons:
|
||||
reasons.append("continuing")
|
||||
|
||||
return TerminationCheck(
|
||||
should_terminate=should_term,
|
||||
reason="; ".join(reasons),
|
||||
items_answered=items_answered,
|
||||
current_se=current_se,
|
||||
max_items=max_items,
|
||||
se_threshold_met=se_threshold_met
|
||||
)
|
||||
|
||||
|
||||
async def get_next_item(
|
||||
db: AsyncSession,
|
||||
session_id: str,
|
||||
selection_mode: Literal["fixed", "adaptive", "hybrid"] = "fixed",
|
||||
hybrid_transition_slot: int = 10,
|
||||
ai_generation_enabled: bool = False,
|
||||
level_filter: Optional[str] = None
|
||||
) -> NextItemResult:
|
||||
"""
|
||||
Get next item based on selection mode.
|
||||
|
||||
Main entry point for item selection.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session_id: Session identifier
|
||||
selection_mode: Selection mode ('fixed', 'adaptive', 'hybrid')
|
||||
hybrid_transition_slot: Slot to transition in hybrid mode
|
||||
ai_generation_enabled: Whether AI generation is enabled
|
||||
level_filter: Optional difficulty level filter
|
||||
|
||||
Returns:
|
||||
NextItemResult with selected item
|
||||
"""
|
||||
# Get session for tryout info
|
||||
session_query = select(Session).where(Session.session_id == session_id)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise CATSelectionError(f"Session {session_id} not found")
|
||||
|
||||
tryout_id = session.tryout_id
|
||||
website_id = session.website_id
|
||||
|
||||
if selection_mode == "fixed":
|
||||
return await get_next_item_fixed(
|
||||
db, session_id, tryout_id, website_id, level_filter
|
||||
)
|
||||
elif selection_mode == "adaptive":
|
||||
return await get_next_item_adaptive(
|
||||
db, session_id, tryout_id, website_id, ai_generation_enabled, level_filter
|
||||
)
|
||||
elif selection_mode == "hybrid":
|
||||
return await get_next_item_hybrid(
|
||||
db, session_id, tryout_id, website_id,
|
||||
hybrid_transition_slot, ai_generation_enabled, level_filter
|
||||
)
|
||||
else:
|
||||
raise CATSelectionError(f"Unknown selection mode: {selection_mode}")
|
||||
|
||||
|
||||
async def check_user_level_reuse(
|
||||
db: AsyncSession,
|
||||
wp_user_id: str,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
slot: int,
|
||||
level: str
|
||||
) -> bool:
|
||||
"""
|
||||
Check if user has already answered a question at this difficulty level.
|
||||
|
||||
Per PRD FR-5.3: Check if student user_id already answered question
|
||||
at specific difficulty level.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
wp_user_id: WordPress user ID
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
slot: Question slot
|
||||
level: Difficulty level
|
||||
|
||||
Returns:
|
||||
True if user has answered at this level, False otherwise
|
||||
"""
|
||||
# Check if user has answered any item at this slot/level combination
|
||||
query = (
|
||||
select(func.count(UserAnswer.id))
|
||||
.join(Item, UserAnswer.item_id == Item.id)
|
||||
.where(
|
||||
UserAnswer.wp_user_id == wp_user_id,
|
||||
UserAnswer.website_id == website_id,
|
||||
UserAnswer.tryout_id == tryout_id,
|
||||
Item.slot == slot,
|
||||
Item.level == level
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
count = result.scalar() or 0
|
||||
|
||||
return count > 0
|
||||
|
||||
|
||||
async def get_available_levels_for_slot(
|
||||
db: AsyncSession,
|
||||
tryout_id: str,
|
||||
website_id: int,
|
||||
slot: int
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get available difficulty levels for a specific slot.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tryout_id: Tryout identifier
|
||||
website_id: Website identifier
|
||||
slot: Question slot
|
||||
|
||||
Returns:
|
||||
List of available levels
|
||||
"""
|
||||
query = (
|
||||
select(Item.level)
|
||||
.where(
|
||||
Item.tryout_id == tryout_id,
|
||||
Item.website_id == website_id,
|
||||
Item.slot == slot,
|
||||
_servable_item_filter(),
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
levels = [row[0] for row in result.all()]
|
||||
|
||||
return levels
|
||||
|
||||
|
||||
# Admin playground functions for testing CAT behavior
|
||||
|
||||
async def simulate_cat_selection(
|
||||
db: AsyncSession,
|
||||
tryout_id: str,
|
||||
website_id: int,
|
||||
initial_theta: float = 0.0,
|
||||
selection_mode: Literal["fixed", "adaptive", "hybrid"] = "adaptive",
|
||||
max_items: int = 15,
|
||||
se_threshold: float = DEFAULT_SE_THRESHOLD,
|
||||
hybrid_transition_slot: int = 10
|
||||
) -> dict:
|
||||
"""
|
||||
Simulate CAT selection for admin testing.
|
||||
|
||||
Returns sequence of selected items with b values and theta progression.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tryout_id: Tryout identifier
|
||||
website_id: Website identifier
|
||||
initial_theta: Starting theta value
|
||||
selection_mode: Selection mode to use
|
||||
max_items: Maximum items to simulate
|
||||
se_threshold: SE threshold for termination
|
||||
hybrid_transition_slot: Slot to transition in hybrid mode
|
||||
|
||||
Returns:
|
||||
Dict with simulation results
|
||||
"""
|
||||
# Get all items for this tryout
|
||||
items_query = (
|
||||
select(Item)
|
||||
.where(
|
||||
Item.tryout_id == tryout_id,
|
||||
Item.website_id == website_id,
|
||||
_servable_item_filter(),
|
||||
)
|
||||
.order_by(Item.slot)
|
||||
)
|
||||
|
||||
items_result = await db.execute(items_query)
|
||||
all_items = list(items_result.scalars().all())
|
||||
|
||||
if not all_items:
|
||||
return {
|
||||
"error": "No items found for this tryout",
|
||||
"tryout_id": tryout_id,
|
||||
"website_id": website_id
|
||||
}
|
||||
|
||||
# Simulate selection
|
||||
selected_items = []
|
||||
current_theta = initial_theta
|
||||
current_se = 3.0 # Start with high uncertainty
|
||||
used_item_ids = set()
|
||||
|
||||
for i in range(max_items):
|
||||
# Get available items
|
||||
available_items = [item for item in all_items if item.id not in used_item_ids]
|
||||
|
||||
if not available_items:
|
||||
break
|
||||
|
||||
# Select based on mode
|
||||
if selection_mode == "adaptive":
|
||||
# Filter to calibrated items only
|
||||
calibrated_items = [item for item in available_items if item.calibrated and item.irt_b is not None]
|
||||
|
||||
if not calibrated_items:
|
||||
# Fallback to any available item
|
||||
calibrated_items = available_items
|
||||
|
||||
# Find item closest to current theta
|
||||
best_item = min(
|
||||
calibrated_items,
|
||||
key=lambda item: abs((item.irt_b or 0) - current_theta)
|
||||
)
|
||||
elif selection_mode == "fixed":
|
||||
# Select in slot order
|
||||
best_item = min(available_items, key=lambda item: item.slot)
|
||||
else: # hybrid
|
||||
if i < hybrid_transition_slot:
|
||||
best_item = min(available_items, key=lambda item: item.slot)
|
||||
else:
|
||||
calibrated_items = [item for item in available_items if item.calibrated and item.irt_b is not None]
|
||||
if calibrated_items:
|
||||
best_item = min(
|
||||
calibrated_items,
|
||||
key=lambda item: abs((item.irt_b or 0) - current_theta)
|
||||
)
|
||||
else:
|
||||
best_item = min(available_items, key=lambda item: item.slot)
|
||||
|
||||
used_item_ids.add(best_item.id)
|
||||
|
||||
# Simulate response (random based on probability)
|
||||
import random
|
||||
b = best_item.irt_b or estimate_b_from_ctt_p(best_item.ctt_p) if best_item.ctt_p else 0.0
|
||||
p_correct = 1.0 / (1.0 + math.exp(-(current_theta - b)))
|
||||
is_correct = random.random() < p_correct
|
||||
|
||||
# Update theta (simplified)
|
||||
responses = [1 if item.get('is_correct', True) else 0 for item in selected_items]
|
||||
responses.append(1 if is_correct else 0)
|
||||
b_params = [item['b'] for item in selected_items]
|
||||
b_params.append(b)
|
||||
|
||||
new_theta, new_se = estimate_theta_mle(responses, b_params, current_theta)
|
||||
current_theta = new_theta
|
||||
current_se = new_se
|
||||
|
||||
selected_items.append({
|
||||
"slot": best_item.slot,
|
||||
"level": best_item.level,
|
||||
"b": b,
|
||||
"is_correct": is_correct,
|
||||
"theta_after": current_theta,
|
||||
"se_after": current_se,
|
||||
"calibrated": best_item.calibrated
|
||||
})
|
||||
|
||||
# Check SE threshold
|
||||
if current_se < se_threshold and i >= 14: # At least 15 items
|
||||
break
|
||||
|
||||
return {
|
||||
"tryout_id": tryout_id,
|
||||
"website_id": website_id,
|
||||
"initial_theta": initial_theta,
|
||||
"selection_mode": selection_mode,
|
||||
"total_items": len(selected_items),
|
||||
"final_theta": current_theta,
|
||||
"final_se": current_se,
|
||||
"se_threshold_met": current_se < se_threshold,
|
||||
"items": selected_items
|
||||
}
|
||||
431
backend/app/services/config_management.py
Normal file
431
backend/app/services/config_management.py
Normal file
@@ -0,0 +1,431 @@
|
||||
"""
|
||||
Configuration Management Service.
|
||||
|
||||
Provides functions to retrieve and update tryout configurations.
|
||||
Handles configuration changes for scoring, selection, and normalization modes.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Literal, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.tryout import Tryout
|
||||
from app.models.tryout_stats import TryoutStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_config(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> Tryout:
|
||||
"""
|
||||
Fetch tryout configuration for a specific tryout.
|
||||
|
||||
Returns all configuration fields including scoring_mode, selection_mode,
|
||||
normalization_mode, and other settings.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
Tryout model with all configuration fields
|
||||
|
||||
Raises:
|
||||
ValueError: If tryout not found
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Tryout).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
tryout = result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise ValueError(
|
||||
f"Tryout {tryout_id} not found for website {website_id}"
|
||||
)
|
||||
|
||||
return tryout
|
||||
|
||||
|
||||
async def update_config(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
config_updates: Dict[str, Any],
|
||||
) -> Tryout:
|
||||
"""
|
||||
Update tryout configuration with provided fields.
|
||||
|
||||
Accepts a dictionary of configuration updates and applies them to the
|
||||
tryout configuration. Only provided fields are updated.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
config_updates: Dictionary of configuration fields to update
|
||||
|
||||
Returns:
|
||||
Updated Tryout model
|
||||
|
||||
Raises:
|
||||
ValueError: If tryout not found or invalid field provided
|
||||
"""
|
||||
# Fetch tryout
|
||||
result = await db.execute(
|
||||
select(Tryout).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
tryout = result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise ValueError(
|
||||
f"Tryout {tryout_id} not found for website {website_id}"
|
||||
)
|
||||
|
||||
# Valid configuration fields
|
||||
valid_fields = {
|
||||
"name", "description",
|
||||
"scoring_mode", "selection_mode", "normalization_mode",
|
||||
"min_sample_for_dynamic", "static_rataan", "static_sb",
|
||||
"ai_generation_enabled",
|
||||
"hybrid_transition_slot",
|
||||
"min_calibration_sample", "theta_estimation_method", "fallback_to_ctt_on_error",
|
||||
}
|
||||
|
||||
# Update only valid fields
|
||||
updated_fields = []
|
||||
for field, value in config_updates.items():
|
||||
if field not in valid_fields:
|
||||
logger.warning(f"Skipping invalid config field: {field}")
|
||||
continue
|
||||
|
||||
setattr(tryout, field, value)
|
||||
updated_fields.append(field)
|
||||
|
||||
if not updated_fields:
|
||||
logger.warning(f"No valid config fields to update for tryout {tryout_id}")
|
||||
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Updated config for tryout {tryout_id}, website {website_id}: "
|
||||
f"{', '.join(updated_fields)}"
|
||||
)
|
||||
|
||||
return tryout
|
||||
|
||||
|
||||
async def toggle_normalization_mode(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
new_mode: Literal["static", "dynamic", "hybrid"],
|
||||
) -> Tryout:
|
||||
"""
|
||||
Toggle normalization mode for a tryout.
|
||||
|
||||
Updates the normalization_mode field. If switching to "auto" (dynamic mode),
|
||||
checks if threshold is met and logs appropriate warnings.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
new_mode: New normalization mode ("static", "dynamic", "hybrid")
|
||||
|
||||
Returns:
|
||||
Updated Tryout model
|
||||
|
||||
Raises:
|
||||
ValueError: If tryout not found or invalid mode provided
|
||||
"""
|
||||
if new_mode not in ["static", "dynamic", "hybrid"]:
|
||||
raise ValueError(
|
||||
f"Invalid normalization_mode: {new_mode}. "
|
||||
"Must be 'static', 'dynamic', or 'hybrid'"
|
||||
)
|
||||
|
||||
# Fetch tryout with stats
|
||||
result = await db.execute(
|
||||
select(Tryout).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
tryout = result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise ValueError(
|
||||
f"Tryout {tryout_id} not found for website {website_id}"
|
||||
)
|
||||
|
||||
old_mode = tryout.normalization_mode
|
||||
tryout.normalization_mode = new_mode
|
||||
|
||||
# Fetch stats for participant count
|
||||
stats_result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = stats_result.scalar_one_or_none()
|
||||
participant_count = stats.participant_count if stats else 0
|
||||
min_sample = tryout.min_sample_for_dynamic
|
||||
|
||||
# Log warnings and suggestions based on mode change
|
||||
if new_mode == "dynamic":
|
||||
if participant_count < min_sample:
|
||||
logger.warning(
|
||||
f"Switching to dynamic normalization with only {participant_count} "
|
||||
f"participants (threshold: {min_sample}). "
|
||||
"Dynamic normalization may produce unreliable results."
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Switching to dynamic normalization with {participant_count} "
|
||||
f"participants (threshold: {min_sample}). "
|
||||
"Ready for dynamic normalization."
|
||||
)
|
||||
|
||||
elif new_mode == "hybrid":
|
||||
if participant_count >= min_sample:
|
||||
logger.info(
|
||||
f"Switching to hybrid normalization with {participant_count} "
|
||||
f"participants (threshold: {min_sample}). "
|
||||
"Will use dynamic normalization immediately."
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Switching to hybrid normalization with {participant_count} "
|
||||
f"participants (threshold: {min_sample}). "
|
||||
f"Will use static normalization until {min_sample} participants reached."
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Toggled normalization mode for tryout {tryout_id}, "
|
||||
f"website {website_id}: {old_mode} -> {new_mode}"
|
||||
)
|
||||
|
||||
return tryout
|
||||
|
||||
|
||||
async def get_normalization_config(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get normalization configuration summary.
|
||||
|
||||
Returns current normalization mode, static values, dynamic values,
|
||||
participant count, and threshold status.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
Dictionary with normalization configuration summary
|
||||
|
||||
Raises:
|
||||
ValueError: If tryout not found
|
||||
"""
|
||||
# Fetch tryout config
|
||||
tryout = await get_config(db, website_id, tryout_id)
|
||||
|
||||
# Fetch stats
|
||||
stats_result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = stats_result.scalar_one_or_none()
|
||||
|
||||
# Determine threshold status
|
||||
participant_count = stats.participant_count if stats else 0
|
||||
min_sample = tryout.min_sample_for_dynamic
|
||||
threshold_ready = participant_count >= min_sample
|
||||
participants_needed = max(0, min_sample - participant_count)
|
||||
|
||||
# Determine current effective mode
|
||||
current_mode = tryout.normalization_mode
|
||||
if current_mode == "hybrid":
|
||||
effective_mode = "dynamic" if threshold_ready else "static"
|
||||
else:
|
||||
effective_mode = current_mode
|
||||
|
||||
return {
|
||||
"tryout_id": tryout_id,
|
||||
"normalization_mode": current_mode,
|
||||
"effective_mode": effective_mode,
|
||||
"static_rataan": tryout.static_rataan,
|
||||
"static_sb": tryout.static_sb,
|
||||
"dynamic_rataan": stats.rataan if stats else None,
|
||||
"dynamic_sb": stats.sb if stats else None,
|
||||
"participant_count": participant_count,
|
||||
"min_sample_for_dynamic": min_sample,
|
||||
"threshold_ready": threshold_ready,
|
||||
"participants_needed": participants_needed,
|
||||
}
|
||||
|
||||
|
||||
async def reset_normalization_stats(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> TryoutStats:
|
||||
"""
|
||||
Reset TryoutStats to initial values.
|
||||
|
||||
Resets participant_count to 0 and clears running sums.
|
||||
Switches normalization_mode to "static" temporarily.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
Reset TryoutStats record
|
||||
|
||||
Raises:
|
||||
ValueError: If tryout not found
|
||||
"""
|
||||
# Fetch tryout
|
||||
tryout_result = await db.execute(
|
||||
select(Tryout).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
tryout = tryout_result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise ValueError(
|
||||
f"Tryout {tryout_id} not found for website {website_id}"
|
||||
)
|
||||
|
||||
# Switch to static mode temporarily
|
||||
tryout.normalization_mode = "static"
|
||||
|
||||
# Fetch or create stats
|
||||
stats_result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = stats_result.scalar_one_or_none()
|
||||
|
||||
if stats is None:
|
||||
# Create new empty stats record
|
||||
stats = TryoutStats(
|
||||
website_id=website_id,
|
||||
tryout_id=tryout_id,
|
||||
participant_count=0,
|
||||
total_nm_sum=0.0,
|
||||
total_nm_sq_sum=0.0,
|
||||
rataan=None,
|
||||
sb=None,
|
||||
min_nm=None,
|
||||
max_nm=None,
|
||||
)
|
||||
db.add(stats)
|
||||
else:
|
||||
# Reset existing stats
|
||||
stats.participant_count = 0
|
||||
stats.total_nm_sum = 0.0
|
||||
stats.total_nm_sq_sum = 0.0
|
||||
stats.rataan = None
|
||||
stats.sb = None
|
||||
stats.min_nm = None
|
||||
stats.max_nm = None
|
||||
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Reset normalization stats for tryout {tryout_id}, "
|
||||
f"website {website_id}. Normalization mode switched to static."
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
async def get_full_config(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get full tryout configuration including stats.
|
||||
|
||||
Returns all configuration fields plus current statistics.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
Dictionary with full configuration and stats
|
||||
|
||||
Raises:
|
||||
ValueError: If tryout not found
|
||||
"""
|
||||
# Fetch tryout config
|
||||
tryout = await get_config(db, website_id, tryout_id)
|
||||
|
||||
# Fetch stats
|
||||
stats_result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = stats_result.scalar_one_or_none()
|
||||
|
||||
# Build config dictionary
|
||||
config = {
|
||||
"tryout_id": tryout.tryout_id,
|
||||
"name": tryout.name,
|
||||
"description": tryout.description,
|
||||
"scoring_mode": tryout.scoring_mode,
|
||||
"selection_mode": tryout.selection_mode,
|
||||
"normalization_mode": tryout.normalization_mode,
|
||||
"min_sample_for_dynamic": tryout.min_sample_for_dynamic,
|
||||
"static_rataan": tryout.static_rataan,
|
||||
"static_sb": tryout.static_sb,
|
||||
"ai_generation_enabled": tryout.ai_generation_enabled,
|
||||
"hybrid_transition_slot": tryout.hybrid_transition_slot,
|
||||
"min_calibration_sample": tryout.min_calibration_sample,
|
||||
"theta_estimation_method": tryout.theta_estimation_method,
|
||||
"fallback_to_ctt_on_error": tryout.fallback_to_ctt_on_error,
|
||||
"stats": {
|
||||
"participant_count": stats.participant_count if stats else 0,
|
||||
"rataan": stats.rataan if stats else None,
|
||||
"sb": stats.sb if stats else None,
|
||||
"min_nm": stats.min_nm if stats else None,
|
||||
"max_nm": stats.max_nm if stats else None,
|
||||
"last_calculated": stats.last_calculated if stats else None,
|
||||
},
|
||||
"created_at": tryout.created_at,
|
||||
"updated_at": tryout.updated_at,
|
||||
}
|
||||
|
||||
return config
|
||||
385
backend/app/services/ctt_scoring.py
Normal file
385
backend/app/services/ctt_scoring.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
CTT (Classical Test Theory) Scoring Engine.
|
||||
|
||||
Implements exact Excel formulas for:
|
||||
- p-value (Tingkat Kesukaran): p = Σ Benar / Total Peserta
|
||||
- Bobot (Weight): Bobot = 1 - p
|
||||
- NM (Nilai Mentah): NM = (Total_Bobot_Siswa / Total_Bobot_Max) × 1000
|
||||
- NN (Nilai Nasional): NN = 500 + 100 × ((NM - Rataan) / SB)
|
||||
|
||||
All formulas match PRD Section 13.1 exactly.
|
||||
"""
|
||||
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Integer, cast, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.item import Item
|
||||
from app.models.tryout_stats import TryoutStats
|
||||
from app.models.user_answer import UserAnswer
|
||||
|
||||
|
||||
def calculate_ctt_p(total_correct: int, total_participants: int) -> float:
|
||||
"""
|
||||
Calculate CTT p-value (Tingkat Kesukaran / Difficulty).
|
||||
|
||||
Formula: p = Σ Benar / Total Peserta
|
||||
|
||||
Args:
|
||||
total_correct: Number of correct answers (Σ Benar)
|
||||
total_participants: Total number of participants (Total Peserta)
|
||||
|
||||
Returns:
|
||||
p-value in range [0.0, 1.0]
|
||||
|
||||
Raises:
|
||||
ValueError: If total_participants is 0 or values are invalid
|
||||
"""
|
||||
if total_participants <= 0:
|
||||
raise ValueError("total_participants must be greater than 0")
|
||||
if total_correct < 0:
|
||||
raise ValueError("total_correct cannot be negative")
|
||||
if total_correct > total_participants:
|
||||
raise ValueError("total_correct cannot exceed total_participants")
|
||||
|
||||
p = total_correct / total_participants
|
||||
|
||||
# Clamp to valid range [0, 1]
|
||||
return max(0.0, min(1.0, p))
|
||||
|
||||
|
||||
def calculate_ctt_bobot(p_value: float) -> float:
|
||||
"""
|
||||
Calculate CTT bobot (weight) from p-value.
|
||||
|
||||
Formula: Bobot = 1 - p
|
||||
|
||||
Interpretation:
|
||||
- Easy questions (p > 0.70) have low bobot (< 0.30)
|
||||
- Difficult questions (p < 0.30) have high bobot (> 0.70)
|
||||
- Medium questions (0.30 ≤ p ≤ 0.70) have moderate bobot
|
||||
|
||||
Args:
|
||||
p_value: CTT p-value in range [0.0, 1.0]
|
||||
|
||||
Returns:
|
||||
bobot (weight) in range [0.0, 1.0]
|
||||
|
||||
Raises:
|
||||
ValueError: If p_value is outside [0, 1] range
|
||||
"""
|
||||
if not 0.0 <= p_value <= 1.0:
|
||||
raise ValueError(f"p_value must be in range [0, 1], got {p_value}")
|
||||
|
||||
bobot = 1.0 - p_value
|
||||
|
||||
# Clamp to valid range [0, 1]
|
||||
return max(0.0, min(1.0, bobot))
|
||||
|
||||
|
||||
def calculate_ctt_nm(total_bobot_siswa: float, total_bobot_max: float) -> int:
|
||||
"""
|
||||
Calculate CTT NM (Nilai Mentah / Raw Score).
|
||||
|
||||
Formula: NM = (Total_Bobot_Siswa / Total_Bobot_Max) × 1000
|
||||
|
||||
This is equivalent to Excel's SUMPRODUCT calculation where:
|
||||
- Total_Bobot_Siswa = Σ(bobot_earned for each correct answer)
|
||||
- Total_Bobot_Max = Σ(bobot for all questions)
|
||||
|
||||
Args:
|
||||
total_bobot_siswa: Total weight earned by student
|
||||
total_bobot_max: Maximum possible weight (sum of all item bobots)
|
||||
|
||||
Returns:
|
||||
NM (raw score) in range [0, 1000]
|
||||
|
||||
Raises:
|
||||
ValueError: If total_bobot_max is 0 or values are invalid
|
||||
"""
|
||||
if total_bobot_max <= 0:
|
||||
raise ValueError("total_bobot_max must be greater than 0")
|
||||
if total_bobot_siswa < 0:
|
||||
raise ValueError("total_bobot_siswa cannot be negative")
|
||||
|
||||
nm = (total_bobot_siswa / total_bobot_max) * 1000
|
||||
|
||||
# Round to integer and clamp to valid range [0, 1000]
|
||||
nm_int = round(nm)
|
||||
return max(0, min(1000, nm_int))
|
||||
|
||||
|
||||
def calculate_ctt_nn(nm: int, rataan: float, sb: float) -> int:
|
||||
"""
|
||||
Calculate CTT NN (Nilai Nasional / Normalized Score).
|
||||
|
||||
Formula: NN = 500 + 100 × ((NM - Rataan) / SB)
|
||||
|
||||
Normalizes scores to mean=500, SD=100 distribution.
|
||||
|
||||
Args:
|
||||
nm: Nilai Mentah (raw score) in range [0, 1000]
|
||||
rataan: Mean of NM scores
|
||||
sb: Standard deviation of NM scores (Simpangan Baku)
|
||||
|
||||
Returns:
|
||||
NN (normalized score) in range [0, 1000]
|
||||
|
||||
Raises:
|
||||
ValueError: If nm is out of range or sb is invalid
|
||||
"""
|
||||
if not 0 <= nm <= 1000:
|
||||
raise ValueError(f"nm must be in range [0, 1000], got {nm}")
|
||||
if sb <= 0:
|
||||
# If SD is 0 or negative, return default normalized score
|
||||
# This handles edge case where all scores are identical
|
||||
return 500
|
||||
|
||||
# Calculate normalized score
|
||||
z_score = (nm - rataan) / sb
|
||||
nn = 500 + 100 * z_score
|
||||
|
||||
# Round to integer and clamp to valid range [0, 1000]
|
||||
nn_int = round(nn)
|
||||
return max(0, min(1000, nn_int))
|
||||
|
||||
|
||||
def categorize_difficulty(p_value: float) -> str:
|
||||
"""
|
||||
Categorize question difficulty based on CTT p-value.
|
||||
|
||||
Categories per CTT standards (PRD Section 13.2):
|
||||
- p < 0.30 → Sukar (Sulit)
|
||||
- 0.30 ≤ p ≤ 0.70 → Sedang
|
||||
- p > 0.70 → Mudah
|
||||
|
||||
Args:
|
||||
p_value: CTT p-value in range [0.0, 1.0]
|
||||
|
||||
Returns:
|
||||
Difficulty category: "mudah", "sedang", or "sulit"
|
||||
"""
|
||||
if p_value > 0.70:
|
||||
return "mudah"
|
||||
elif p_value >= 0.30:
|
||||
return "sedang"
|
||||
else:
|
||||
return "sulit"
|
||||
|
||||
|
||||
async def calculate_ctt_p_for_item(
|
||||
db: AsyncSession, item_id: int
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Calculate CTT p-value for a specific item from existing responses.
|
||||
|
||||
Queries all UserAnswer records for the item to calculate:
|
||||
p = Σ Benar / Total Peserta
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
item_id: Item ID to calculate p-value for
|
||||
|
||||
Returns:
|
||||
p-value in range [0.0, 1.0], or None if no responses exist
|
||||
"""
|
||||
# Count total responses and correct responses
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.count().label("total"),
|
||||
func.sum(cast(UserAnswer.is_correct, Integer)).label("correct"),
|
||||
).where(UserAnswer.item_id == item_id)
|
||||
)
|
||||
row = result.first()
|
||||
|
||||
if row is None or row.total == 0:
|
||||
return None
|
||||
|
||||
return calculate_ctt_p(row.correct or 0, row.total)
|
||||
|
||||
|
||||
async def update_tryout_stats(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
nm: int,
|
||||
) -> TryoutStats:
|
||||
"""
|
||||
Incrementally update TryoutStats with new NM score.
|
||||
|
||||
Updates:
|
||||
- participant_count += 1
|
||||
- total_nm_sum += nm
|
||||
- total_nm_sq_sum += nm²
|
||||
- Recalculates rataan (mean) and sb (standard deviation)
|
||||
- Updates min_nm and max_nm if applicable
|
||||
|
||||
Uses Welford's online algorithm for numerically stable variance calculation.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
nm: New NM score to add
|
||||
|
||||
Returns:
|
||||
Updated TryoutStats record
|
||||
"""
|
||||
# Get or create TryoutStats
|
||||
result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = result.scalar_one_or_none()
|
||||
|
||||
if stats is None:
|
||||
# Create new stats record
|
||||
stats = TryoutStats(
|
||||
website_id=website_id,
|
||||
tryout_id=tryout_id,
|
||||
participant_count=1,
|
||||
total_nm_sum=float(nm),
|
||||
total_nm_sq_sum=float(nm * nm),
|
||||
rataan=float(nm),
|
||||
sb=0.0, # SD is 0 for single data point
|
||||
min_nm=nm,
|
||||
max_nm=nm,
|
||||
last_calculated=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(stats)
|
||||
else:
|
||||
# Incrementally update existing stats
|
||||
stats.participant_count += 1
|
||||
stats.total_nm_sum += nm
|
||||
stats.total_nm_sq_sum += nm * nm
|
||||
|
||||
# Update min/max
|
||||
if stats.min_nm is None or nm < stats.min_nm:
|
||||
stats.min_nm = nm
|
||||
if stats.max_nm is None or nm > stats.max_nm:
|
||||
stats.max_nm = nm
|
||||
|
||||
# Recalculate mean and SD
|
||||
n = stats.participant_count
|
||||
sum_nm = stats.total_nm_sum
|
||||
sum_nm_sq = stats.total_nm_sq_sum
|
||||
|
||||
# Mean = Σ NM / n
|
||||
stats.rataan = sum_nm / n
|
||||
|
||||
# Variance = (Σ NM² / n) - (mean)²
|
||||
# Using population standard deviation
|
||||
if n > 1:
|
||||
variance = (sum_nm_sq / n) - (stats.rataan ** 2)
|
||||
# Clamp variance to non-negative (handles floating point errors)
|
||||
variance = max(0.0, variance)
|
||||
stats.sb = math.sqrt(variance)
|
||||
else:
|
||||
stats.sb = 0.0
|
||||
|
||||
stats.last_calculated = datetime.now(timezone.utc)
|
||||
|
||||
await db.flush()
|
||||
return stats
|
||||
|
||||
|
||||
async def get_total_bobot_max(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
level: str = "sedang",
|
||||
) -> float:
|
||||
"""
|
||||
Calculate total maximum bobot for a tryout.
|
||||
|
||||
Total_Bobot_Max = Σ bobot for all questions in the tryout
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
level: Difficulty level to filter by (default: "sedang")
|
||||
|
||||
Returns:
|
||||
Sum of all item bobots
|
||||
|
||||
Raises:
|
||||
ValueError: If no items found or items have no bobot values
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(func.sum(Item.ctt_bobot)).where(
|
||||
Item.website_id == website_id,
|
||||
Item.tryout_id == tryout_id,
|
||||
Item.level == level,
|
||||
)
|
||||
)
|
||||
total_bobot = result.scalar()
|
||||
|
||||
if total_bobot is None or total_bobot == 0:
|
||||
raise ValueError(
|
||||
f"No items with bobot found for tryout {tryout_id}, level {level}"
|
||||
)
|
||||
|
||||
return float(total_bobot)
|
||||
|
||||
|
||||
def convert_ctt_p_to_irt_b(p_value: float) -> float:
|
||||
"""
|
||||
Convert CTT p-value to IRT difficulty parameter (b).
|
||||
|
||||
Formula: b ≈ -ln((1-p)/p)
|
||||
|
||||
This provides an initial estimate for IRT calibration.
|
||||
Maps p ∈ (0, 1) to b ∈ (-∞, +∞), typically [-3, +3].
|
||||
|
||||
Args:
|
||||
p_value: CTT p-value in range (0.0, 1.0)
|
||||
|
||||
Returns:
|
||||
IRT b-parameter estimate
|
||||
|
||||
Raises:
|
||||
ValueError: If p_value is at boundaries (0 or 1)
|
||||
"""
|
||||
if p_value <= 0.0 or p_value >= 1.0:
|
||||
# Handle edge cases by clamping
|
||||
if p_value <= 0.0:
|
||||
return 3.0 # Very difficult
|
||||
else:
|
||||
return -3.0 # Very easy
|
||||
|
||||
# b ≈ -ln((1-p)/p)
|
||||
odds_ratio = (1 - p_value) / p_value
|
||||
b = -math.log(odds_ratio)
|
||||
|
||||
# Clamp to valid IRT range [-3, +3]
|
||||
return max(-3.0, min(3.0, b))
|
||||
|
||||
|
||||
def map_theta_to_nn(theta: float) -> int:
|
||||
"""
|
||||
Map IRT theta (ability) to NN score for comparison.
|
||||
|
||||
Formula: NN = 500 + (θ / 3) × 500
|
||||
|
||||
Maps θ ∈ [-3, +3] to NN ∈ [0, 1000].
|
||||
|
||||
Args:
|
||||
theta: IRT ability estimate in range [-3.0, +3.0]
|
||||
|
||||
Returns:
|
||||
NN score in range [0, 1000]
|
||||
"""
|
||||
# Clamp theta to valid range
|
||||
theta_clamped = max(-3.0, min(3.0, theta))
|
||||
|
||||
# Map to NN
|
||||
nn = 500 + (theta_clamped / 3) * 500
|
||||
|
||||
# Round and clamp to valid range
|
||||
return max(0, min(1000, round(nn)))
|
||||
521
backend/app/services/excel_import.py
Normal file
521
backend/app/services/excel_import.py
Normal file
@@ -0,0 +1,521 @@
|
||||
"""
|
||||
Excel Import/Export Service for Question Migration.
|
||||
|
||||
Handles import from standardized Excel format with:
|
||||
- Row 2: KUNCI (answer key)
|
||||
- Row 4: TK (tingkat kesukaran p-value)
|
||||
- Row 5: BOBOT (weight 1-p)
|
||||
- Rows 6+: Individual question data
|
||||
|
||||
Ensures 100% data integrity with comprehensive validation.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import openpyxl
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.item import Item
|
||||
from app.services.ctt_scoring import (
|
||||
convert_ctt_p_to_irt_b,
|
||||
categorize_difficulty,
|
||||
)
|
||||
|
||||
|
||||
def validate_excel_structure(file_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate Excel file structure against required format.
|
||||
|
||||
Checks:
|
||||
- File exists and is valid Excel (.xlsx)
|
||||
- Sheet "CONTOH" exists
|
||||
- Required rows exist (Row 2 KUNCI, Row 4 TK, Row 5 BOBOT)
|
||||
- Question data rows have required columns
|
||||
|
||||
Args:
|
||||
file_path: Path to Excel file
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- valid: bool - Whether structure is valid
|
||||
- errors: List[str] - Validation errors if any
|
||||
"""
|
||||
errors: List[str] = []
|
||||
|
||||
# Check file exists
|
||||
if not os.path.exists(file_path):
|
||||
return {"valid": False, "errors": [f"File not found: {file_path}"]}
|
||||
|
||||
# Check file extension
|
||||
if not file_path.lower().endswith('.xlsx'):
|
||||
return {"valid": False, "errors": ["File must be .xlsx format"]}
|
||||
|
||||
try:
|
||||
wb = openpyxl.load_workbook(file_path, data_only=False)
|
||||
except Exception as e:
|
||||
return {"valid": False, "errors": [f"Failed to load Excel file: {str(e)}"]}
|
||||
|
||||
# Check sheet "CONTOH" exists
|
||||
if "CONTOH" not in wb.sheetnames:
|
||||
return {
|
||||
"valid": False,
|
||||
"errors": ['Sheet "CONTOH" not found. Available sheets: ' + ", ".join(wb.sheetnames)]
|
||||
}
|
||||
|
||||
ws = wb["CONTOH"]
|
||||
|
||||
# Check minimum rows exist
|
||||
if ws.max_row < 6:
|
||||
errors.append(f"Excel file must have at least 6 rows (found {ws.max_row})")
|
||||
|
||||
# Check Row 2 exists (KUNCI)
|
||||
if ws.max_row < 2:
|
||||
errors.append("Row 2 (KUNCI - answer key) is required")
|
||||
|
||||
# Check Row 4 exists (TK - p-values)
|
||||
if ws.max_row < 4:
|
||||
errors.append("Row 4 (TK - p-values) is required")
|
||||
|
||||
# Check Row 5 exists (BOBOT - weights)
|
||||
if ws.max_row < 5:
|
||||
errors.append("Row 5 (BOBOT - weights) is required")
|
||||
|
||||
# Check question data rows exist (6+)
|
||||
if ws.max_row < 6:
|
||||
errors.append("Question data rows (6+) are required")
|
||||
|
||||
# Check minimum columns (at least slot, level, soal_text, options, correct_answer)
|
||||
if ws.max_column < 8:
|
||||
errors.append(
|
||||
f"Excel file must have at least 8 columns (found {ws.max_column}). "
|
||||
"Expected: slot, level, soal_text, options_A, options_B, options_C, options_D, correct_answer"
|
||||
)
|
||||
|
||||
# Check KUNCI row has values
|
||||
if ws.max_row >= 2:
|
||||
kunce_row_values = [ws.cell(2, col).value for col in range(4, ws.max_column + 1)]
|
||||
if not any(v for v in kunce_row_values if v and v != "KUNCI"):
|
||||
errors.append("Row 2 (KUNCI) must contain answer key values")
|
||||
|
||||
# Check TK row has numeric values
|
||||
if ws.max_row >= 4:
|
||||
wb_data = openpyxl.load_workbook(file_path, data_only=True)
|
||||
ws_data = wb_data["CONTOH"]
|
||||
tk_row_values = [ws_data.cell(4, col).value for col in range(4, ws.max_column + 1)]
|
||||
if not any(v for v in tk_row_values if isinstance(v, (int, float))):
|
||||
errors.append("Row 4 (TK) must contain numeric p-values")
|
||||
|
||||
# Check BOBOT row has numeric values
|
||||
if ws.max_row >= 5:
|
||||
wb_data = openpyxl.load_workbook(file_path, data_only=True)
|
||||
ws_data = wb_data["CONTOH"]
|
||||
bobot_row_values = [ws_data.cell(5, col).value for col in range(4, ws.max_column + 1)]
|
||||
if not any(v for v in bobot_row_values if isinstance(v, (int, float))):
|
||||
errors.append("Row 5 (BOBOT) must contain numeric weight values")
|
||||
|
||||
return {"valid": len(errors) == 0, "errors": errors}
|
||||
|
||||
|
||||
def parse_excel_import(
|
||||
file_path: str,
|
||||
website_id: int,
|
||||
tryout_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse Excel file and extract items with full validation.
|
||||
|
||||
Excel structure:
|
||||
- Sheet name: "CONTOH"
|
||||
- Row 2: KUNCI (answer key) - extract correct answers per slot
|
||||
- Row 4: TK (tingkat kesukaran p-value) - extract p-values per slot
|
||||
- Row 5: BOBOT (weight 1-p) - extract bobot per slot
|
||||
- Rows 6+: Individual question data
|
||||
|
||||
Args:
|
||||
file_path: Path to Excel file
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- items: List[Dict] - Parsed items ready for database
|
||||
- validation_errors: List[str] - Any validation errors
|
||||
- items_count: int - Number of items parsed
|
||||
"""
|
||||
# First validate structure
|
||||
validation = validate_excel_structure(file_path)
|
||||
if not validation["valid"]:
|
||||
return {
|
||||
"items": [],
|
||||
"validation_errors": validation["errors"],
|
||||
"items_count": 0
|
||||
}
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
errors: List[str] = []
|
||||
|
||||
try:
|
||||
# Load workbook twice: once with formulas, once with data_only
|
||||
wb = openpyxl.load_workbook(file_path, data_only=False)
|
||||
ws = wb["CONTOH"]
|
||||
|
||||
wb_data = openpyxl.load_workbook(file_path, data_only=True)
|
||||
ws_data = wb_data["CONTOH"]
|
||||
|
||||
# Extract answer key from Row 2
|
||||
answer_key: Dict[int, str] = {}
|
||||
for col in range(4, ws.max_column + 1):
|
||||
key_cell = ws.cell(2, col).value
|
||||
if key_cell and key_cell != "KUNCI":
|
||||
slot_num = col - 3 # Column 4 -> slot 1
|
||||
answer_key[slot_num] = str(key_cell).strip().upper()
|
||||
|
||||
# Extract p-values from Row 4
|
||||
p_values: Dict[int, float] = {}
|
||||
for col in range(4, ws.max_column + 1):
|
||||
slot_num = col - 3
|
||||
if slot_num in answer_key:
|
||||
p_cell = ws_data.cell(4, col).value
|
||||
if p_cell and isinstance(p_cell, (int, float)):
|
||||
p_values[slot_num] = float(p_cell)
|
||||
|
||||
# Extract bobot from Row 5
|
||||
bobot_values: Dict[int, float] = {}
|
||||
for col in range(4, ws.max_column + 1):
|
||||
slot_num = col - 3
|
||||
if slot_num in answer_key:
|
||||
bobot_cell = ws_data.cell(5, col).value
|
||||
if bobot_cell and isinstance(bobot_cell, (int, float)):
|
||||
bobot_values[slot_num] = float(bobot_cell)
|
||||
|
||||
# Parse question data rows (6+)
|
||||
for row_idx in range(6, ws.max_row + 1):
|
||||
# Column mapping (based on project-brief):
|
||||
# Column 1 (A): slot (question number)
|
||||
# Column 2 (B): level (mudah/sedang/sulit)
|
||||
# Column 3 (C): soal_text (question stem)
|
||||
# Column 4 (D): options_A
|
||||
# Column 5 (E): options_B
|
||||
# Column 6 (F): options_C
|
||||
# Column 7 (G): options_D
|
||||
# Column 8 (H): correct_answer
|
||||
|
||||
slot_cell = ws.cell(row_idx, 1).value
|
||||
level_cell = ws.cell(row_idx, 2).value
|
||||
soal_text_cell = ws.cell(row_idx, 3).value
|
||||
option_a = ws.cell(row_idx, 4).value
|
||||
option_b = ws.cell(row_idx, 5).value
|
||||
option_c = ws.cell(row_idx, 6).value
|
||||
option_d = ws.cell(row_idx, 7).value
|
||||
correct_cell = ws.cell(row_idx, 8).value
|
||||
|
||||
# Skip empty rows
|
||||
if not slot_cell and not soal_text_cell:
|
||||
continue
|
||||
|
||||
# Validate required fields
|
||||
if not slot_cell:
|
||||
errors.append(f"Row {row_idx}: Missing slot value")
|
||||
continue
|
||||
|
||||
slot_num = int(slot_cell) if isinstance(slot_cell, (int, float)) else None
|
||||
if slot_num is None:
|
||||
try:
|
||||
slot_num = int(str(slot_cell).strip())
|
||||
except (ValueError, AttributeError):
|
||||
errors.append(f"Row {row_idx}: Invalid slot value: {slot_cell}")
|
||||
continue
|
||||
|
||||
# Get or infer level
|
||||
if not level_cell:
|
||||
# Use p-value from Row 4 to determine level
|
||||
p_val = p_values.get(slot_num, 0.5)
|
||||
level_val = categorize_difficulty(p_val)
|
||||
else:
|
||||
level_val = str(level_cell).strip().lower()
|
||||
if level_val not in ["mudah", "sedang", "sulit"]:
|
||||
errors.append(
|
||||
f"Row {row_idx}: Invalid level '{level_cell}'. Must be 'mudah', 'sedang', or 'sulit'"
|
||||
)
|
||||
continue
|
||||
|
||||
# Validate soal_text
|
||||
if not soal_text_cell:
|
||||
errors.append(f"Row {row_idx} (slot {slot_num}): Missing soal_text (question stem)")
|
||||
continue
|
||||
|
||||
# Build options JSON
|
||||
options: Dict[str, str] = {}
|
||||
if option_a:
|
||||
options["A"] = str(option_a).strip()
|
||||
if option_b:
|
||||
options["B"] = str(option_b).strip()
|
||||
if option_c:
|
||||
options["C"] = str(option_c).strip()
|
||||
if option_d:
|
||||
options["D"] = str(option_d).strip()
|
||||
|
||||
if len(options) < 4:
|
||||
errors.append(
|
||||
f"Row {row_idx} (slot {slot_num}): Missing options. Expected 4 options (A, B, C, D)"
|
||||
)
|
||||
continue
|
||||
|
||||
# Get correct answer
|
||||
if not correct_cell:
|
||||
# Fall back to answer key from Row 2
|
||||
correct_ans = answer_key.get(slot_num)
|
||||
if not correct_ans:
|
||||
errors.append(
|
||||
f"Row {row_idx} (slot {slot_num}): Missing correct_answer and no answer key found"
|
||||
)
|
||||
continue
|
||||
else:
|
||||
correct_ans = str(correct_cell).strip().upper()
|
||||
|
||||
if correct_ans not in ["A", "B", "C", "D"]:
|
||||
errors.append(
|
||||
f"Row {row_idx} (slot {slot_num}): Invalid correct_answer '{correct_ans}'. Must be A, B, C, or D"
|
||||
)
|
||||
continue
|
||||
|
||||
# Get CTT parameters
|
||||
p_val = p_values.get(slot_num, 0.5)
|
||||
bobot_val = bobot_values.get(slot_num, 1.0 - p_val)
|
||||
|
||||
# Validate p-value range
|
||||
if p_val < 0 or p_val > 1:
|
||||
errors.append(
|
||||
f"Slot {slot_num}: Invalid p-value {p_val}. Must be in range [0, 1]"
|
||||
)
|
||||
continue
|
||||
|
||||
# Validate bobot range
|
||||
if bobot_val < 0 or bobot_val > 1:
|
||||
errors.append(
|
||||
f"Slot {slot_num}: Invalid bobot {bobot_val}. Must be in range [0, 1]"
|
||||
)
|
||||
continue
|
||||
|
||||
# Calculate CTT category and IRT b parameter
|
||||
ctt_cat = categorize_difficulty(p_val)
|
||||
irt_b = convert_ctt_p_to_irt_b(p_val)
|
||||
|
||||
# Build item dict
|
||||
item = {
|
||||
"tryout_id": tryout_id,
|
||||
"website_id": website_id,
|
||||
"slot": slot_num,
|
||||
"level": level_val,
|
||||
"stem": str(soal_text_cell).strip(),
|
||||
"options": options,
|
||||
"correct_answer": correct_ans,
|
||||
"explanation": None,
|
||||
"ctt_p": p_val,
|
||||
"ctt_bobot": bobot_val,
|
||||
"ctt_category": ctt_cat,
|
||||
"irt_b": irt_b,
|
||||
"irt_se": None,
|
||||
"calibrated": False,
|
||||
"calibration_sample_size": 0,
|
||||
"generated_by": "manual",
|
||||
"ai_model": None,
|
||||
"basis_item_id": None,
|
||||
}
|
||||
|
||||
items.append(item)
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"validation_errors": errors,
|
||||
"items_count": len(items)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"items": [],
|
||||
"validation_errors": [f"Parsing error: {str(e)}"],
|
||||
"items_count": 0
|
||||
}
|
||||
|
||||
|
||||
async def bulk_insert_items(
|
||||
items_list: List[Dict[str, Any]],
|
||||
db: AsyncSession
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Bulk insert items with duplicate detection.
|
||||
|
||||
Skips duplicates based on (tryout_id, website_id, slot).
|
||||
|
||||
Args:
|
||||
items_list: List of item dictionaries to insert
|
||||
db: Async SQLAlchemy database session
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- inserted_count: int - Number of items inserted
|
||||
- duplicate_count: int - Number of duplicates skipped
|
||||
- errors: List[str] - Any errors during insertion
|
||||
"""
|
||||
inserted_count = 0
|
||||
duplicate_count = 0
|
||||
errors: List[str] = []
|
||||
|
||||
try:
|
||||
for item_data in items_list:
|
||||
# Check for duplicate
|
||||
result = await db.execute(
|
||||
select(Item).where(
|
||||
Item.tryout_id == item_data["tryout_id"],
|
||||
Item.website_id == item_data["website_id"],
|
||||
Item.slot == item_data["slot"]
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
duplicate_count += 1
|
||||
continue
|
||||
|
||||
# Create new item
|
||||
item = Item(**item_data)
|
||||
db.add(item)
|
||||
inserted_count += 1
|
||||
|
||||
# Commit all inserts
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"inserted_count": inserted_count,
|
||||
"duplicate_count": duplicate_count,
|
||||
"errors": errors
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
return {
|
||||
"inserted_count": 0,
|
||||
"duplicate_count": duplicate_count,
|
||||
"errors": [f"Insertion failed: {str(e)}"]
|
||||
}
|
||||
|
||||
|
||||
async def export_questions_to_excel(
|
||||
tryout_id: str,
|
||||
website_id: int,
|
||||
db: AsyncSession,
|
||||
output_path: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Export questions to Excel in standardized format.
|
||||
|
||||
Creates Excel workbook with:
|
||||
- Sheet "CONTOH"
|
||||
- Row 2: KUNCI (answer key)
|
||||
- Row 4: TK (p-values)
|
||||
- Row 5: BOBOT (weights)
|
||||
- Rows 6+: Question data
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
website_id: Website identifier
|
||||
db: Async SQLAlchemy database session
|
||||
output_path: Optional output file path. If not provided, generates temp file.
|
||||
|
||||
Returns:
|
||||
Path to exported Excel file
|
||||
"""
|
||||
# Fetch all items for this tryout
|
||||
result = await db.execute(
|
||||
select(Item).filter(
|
||||
Item.tryout_id == tryout_id,
|
||||
Item.website_id == website_id
|
||||
).order_by(Item.slot)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
|
||||
if not items:
|
||||
raise ValueError(f"No items found for tryout_id={tryout_id}, website_id={website_id}")
|
||||
|
||||
# Create workbook
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "CONTOH"
|
||||
|
||||
# Determine max slot for column sizing
|
||||
max_slot = max(item.slot for item in items)
|
||||
|
||||
# Row 1: Header
|
||||
ws.cell(1, 1, "No")
|
||||
ws.cell(1, 2, "Level")
|
||||
ws.cell(1, 3, "Soal")
|
||||
for slot_idx in range(max_slot):
|
||||
col = slot_idx + 4
|
||||
ws.cell(1, col, f"Soal {slot_idx + 1}")
|
||||
|
||||
# Row 2: KUNCI (answer key)
|
||||
ws.cell(2, 1, "")
|
||||
ws.cell(2, 2, "")
|
||||
ws.cell(2, 3, "KUNCI")
|
||||
for item in items:
|
||||
col = item.slot + 3
|
||||
ws.cell(2, col, item.correct_answer)
|
||||
|
||||
# Row 3: Empty
|
||||
ws.cell(3, 1, "")
|
||||
ws.cell(3, 2, "")
|
||||
ws.cell(3, 3, "")
|
||||
|
||||
# Row 4: TK (p-values)
|
||||
ws.cell(4, 1, "")
|
||||
ws.cell(4, 2, "")
|
||||
ws.cell(4, 3, "TK")
|
||||
for item in items:
|
||||
col = item.slot + 3
|
||||
ws.cell(4, col, item.ctt_p or 0.5)
|
||||
|
||||
# Row 5: BOBOT (weights)
|
||||
ws.cell(5, 1, "")
|
||||
ws.cell(5, 2, "")
|
||||
ws.cell(5, 3, "BOBOT")
|
||||
for item in items:
|
||||
col = item.slot + 3
|
||||
ws.cell(5, col, item.ctt_bobot or (1.0 - (item.ctt_p or 0.5)))
|
||||
|
||||
# Rows 6+: Question data
|
||||
row_idx = 6
|
||||
for item in items:
|
||||
# Column 1: Slot number
|
||||
ws.cell(row_idx, 1, item.slot)
|
||||
|
||||
# Column 2: Level
|
||||
ws.cell(row_idx, 2, item.level)
|
||||
|
||||
# Column 3: Soal text (stem)
|
||||
ws.cell(row_idx, 3, item.stem)
|
||||
|
||||
# Columns 4+: Options
|
||||
options = item.options or {}
|
||||
ws.cell(row_idx, 4, options.get("A", ""))
|
||||
ws.cell(row_idx, 5, options.get("B", ""))
|
||||
ws.cell(row_idx, 6, options.get("C", ""))
|
||||
ws.cell(row_idx, 7, options.get("D", ""))
|
||||
|
||||
# Column 8: Correct answer
|
||||
ws.cell(row_idx, 8, item.correct_answer)
|
||||
|
||||
row_idx += 1
|
||||
|
||||
# Generate output path if not provided
|
||||
if output_path is None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
output_path = f"/tmp/tryout_{tryout_id}_export_{timestamp}.xlsx"
|
||||
|
||||
# Save workbook
|
||||
wb.save(output_path)
|
||||
|
||||
return output_path
|
||||
1124
backend/app/services/irt_calibration.py
Normal file
1124
backend/app/services/irt_calibration.py
Normal file
File diff suppressed because it is too large
Load Diff
538
backend/app/services/normalization.py
Normal file
538
backend/app/services/normalization.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""
|
||||
Dynamic Normalization Service.
|
||||
|
||||
Implements dynamic normalization with real-time calculation of rataan and SB
|
||||
for each tryout. Supports multiple normalization modes:
|
||||
- Static: Use hardcoded rataan/SB from config
|
||||
- Dynamic: Calculate rataan/SB from participant NM scores in real-time
|
||||
- Hybrid: Use static until threshold reached, then switch to dynamic
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal, Optional, Tuple
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.tryout import Tryout
|
||||
from app.models.tryout_stats import TryoutStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def calculate_dynamic_stats(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> Tuple[Optional[float], Optional[float]]:
|
||||
"""
|
||||
Calculate current dynamic stats (rataan and SB) from TryoutStats.
|
||||
|
||||
Fetches current TryoutStats for this (tryout_id, website_id) pair
|
||||
and returns the calculated rataan and SB values.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
Tuple of (rataan, sb), both None if no stats exist
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = result.scalar_one_or_none()
|
||||
|
||||
if stats is None:
|
||||
return None, None
|
||||
|
||||
return stats.rataan, stats.sb
|
||||
|
||||
|
||||
async def update_dynamic_normalization(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
nm: int,
|
||||
) -> Tuple[float, float]:
|
||||
"""
|
||||
Update dynamic normalization with new NM score.
|
||||
|
||||
Fetches current TryoutStats and incrementally updates it with the new NM:
|
||||
- Increments participant_count by 1
|
||||
- Adds NM to total_nm_sum
|
||||
- Adds NM² to total_nm_sq_sum
|
||||
- Recalculates rataan and sb
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
nm: Nilai Mentah (raw score) to add
|
||||
|
||||
Returns:
|
||||
Tuple of updated (rataan, sb)
|
||||
|
||||
Raises:
|
||||
ValueError: If nm is out of valid range [0, 1000]
|
||||
"""
|
||||
if not 0 <= nm <= 1000:
|
||||
raise ValueError(f"nm must be in range [0, 1000], got {nm}")
|
||||
|
||||
result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = result.scalar_one_or_none()
|
||||
|
||||
if stats is None:
|
||||
# Initialize new stats record
|
||||
stats = TryoutStats(
|
||||
website_id=website_id,
|
||||
tryout_id=tryout_id,
|
||||
participant_count=1,
|
||||
total_nm_sum=float(nm),
|
||||
total_nm_sq_sum=float(nm * nm),
|
||||
rataan=float(nm),
|
||||
sb=0.0, # SD is 0 for single data point
|
||||
min_nm=nm,
|
||||
max_nm=nm,
|
||||
last_calculated=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(stats)
|
||||
else:
|
||||
# Incrementally update existing stats
|
||||
stats.participant_count += 1
|
||||
stats.total_nm_sum += nm
|
||||
stats.total_nm_sq_sum += nm * nm
|
||||
|
||||
# Update min/max
|
||||
if stats.min_nm is None or nm < stats.min_nm:
|
||||
stats.min_nm = nm
|
||||
if stats.max_nm is None or nm > stats.max_nm:
|
||||
stats.max_nm = nm
|
||||
|
||||
# Recalculate mean and SD
|
||||
n = stats.participant_count
|
||||
sum_nm = stats.total_nm_sum
|
||||
sum_nm_sq = stats.total_nm_sq_sum
|
||||
|
||||
# Mean = Σ NM / n
|
||||
mean = sum_nm / n
|
||||
stats.rataan = mean
|
||||
|
||||
# Variance = (Σ NM² / n) - (mean)²
|
||||
# Using population standard deviation
|
||||
if n > 1:
|
||||
variance = (sum_nm_sq / n) - (mean ** 2)
|
||||
# Clamp variance to non-negative (handles floating point errors)
|
||||
variance = max(0.0, variance)
|
||||
stats.sb = math.sqrt(variance)
|
||||
else:
|
||||
stats.sb = 0.0
|
||||
|
||||
stats.last_calculated = datetime.now(timezone.utc)
|
||||
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Updated dynamic normalization for tryout {tryout_id}, "
|
||||
f"website {website_id}: participant_count={stats.participant_count}, "
|
||||
f"rataan={stats.rataan:.2f}, sb={stats.sb:.2f}"
|
||||
)
|
||||
|
||||
# rataan and sb are always set by this function
|
||||
assert stats.rataan is not None
|
||||
assert stats.sb is not None
|
||||
return stats.rataan, stats.sb
|
||||
|
||||
|
||||
def apply_normalization(
|
||||
nm: int,
|
||||
rataan: float,
|
||||
sb: float,
|
||||
) -> int:
|
||||
"""
|
||||
Apply normalization to NM to get NN (Nilai Nasional).
|
||||
|
||||
Formula: NN = 500 + 100 × ((NM - Rataan) / SB)
|
||||
|
||||
Normalizes scores to mean=500, SD=100 distribution.
|
||||
|
||||
Args:
|
||||
nm: Nilai Mentah (raw score) in range [0, 1000]
|
||||
rataan: Mean of NM scores
|
||||
sb: Standard deviation of NM scores
|
||||
|
||||
Returns:
|
||||
NN (normalized score) in range [0, 1000]
|
||||
|
||||
Raises:
|
||||
ValueError: If nm is out of range or sb is invalid
|
||||
"""
|
||||
if not 0 <= nm <= 1000:
|
||||
raise ValueError(f"nm must be in range [0, 1000], got {nm}")
|
||||
if sb <= 0:
|
||||
# If SD is 0 or negative, return default normalized score
|
||||
# This handles edge case where all scores are identical
|
||||
return 500
|
||||
|
||||
# Calculate normalized score
|
||||
z_score = (nm - rataan) / sb
|
||||
nn = 500 + 100 * z_score
|
||||
|
||||
# Round to integer and clamp to valid range [0, 1000]
|
||||
nn_int = round(nn)
|
||||
return max(0, min(1000, nn_int))
|
||||
|
||||
|
||||
async def get_normalization_mode(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> Literal["static", "dynamic", "hybrid"]:
|
||||
"""
|
||||
Get the current normalization mode for a tryout.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
Normalization mode: "static", "dynamic", or "hybrid"
|
||||
|
||||
Raises:
|
||||
ValueError: If tryout not found
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Tryout).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
tryout = result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise ValueError(
|
||||
f"Tryout {tryout_id} not found for website {website_id}"
|
||||
)
|
||||
|
||||
return tryout.normalization_mode
|
||||
|
||||
|
||||
async def check_threshold_for_dynamic(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if participant count meets threshold for dynamic normalization.
|
||||
|
||||
Compares current participant_count with min_sample_for_dynamic from config.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
True if participant_count >= min_sample_for_dynamic, else False
|
||||
"""
|
||||
# Fetch current TryoutStats
|
||||
stats_result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = stats_result.scalar_one_or_none()
|
||||
current_participant_count = stats.participant_count if stats else 0
|
||||
|
||||
# Fetch min_sample_for_dynamic from config
|
||||
tryout_result = await db.execute(
|
||||
select(Tryout.min_sample_for_dynamic).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
min_sample = tryout_result.scalar_one_or_none()
|
||||
|
||||
if min_sample is None:
|
||||
# Default to 100 if not configured
|
||||
min_sample = 100
|
||||
|
||||
return current_participant_count >= min_sample
|
||||
|
||||
|
||||
async def get_normalization_params(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> Tuple[float, float, Literal["static", "dynamic"]]:
|
||||
"""
|
||||
Get normalization parameters (rataan, sb) based on current mode.
|
||||
|
||||
Determines which normalization parameters to use:
|
||||
- Static mode: Use config.static_rataan and config.static_sb
|
||||
- Dynamic mode: Use calculated rataan and sb from TryoutStats
|
||||
- Hybrid mode: Use static until threshold reached, then dynamic
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
Tuple of (rataan, sb, mode_used)
|
||||
|
||||
Raises:
|
||||
ValueError: If tryout not found or dynamic stats unavailable
|
||||
"""
|
||||
# Get normalization mode
|
||||
mode = await get_normalization_mode(db, website_id, tryout_id)
|
||||
|
||||
if mode == "static":
|
||||
# Use static values from config
|
||||
result = await db.execute(
|
||||
select(Tryout.static_rataan, Tryout.static_sb).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
|
||||
if row is None:
|
||||
raise ValueError(
|
||||
f"Tryout {tryout_id} not found for website {website_id}"
|
||||
)
|
||||
|
||||
rataan, sb = row
|
||||
return rataan, sb, "static"
|
||||
|
||||
elif mode == "dynamic":
|
||||
# Use dynamic values from stats
|
||||
rataan, sb = await calculate_dynamic_stats(db, website_id, tryout_id)
|
||||
|
||||
if rataan is None or sb is None:
|
||||
raise ValueError(
|
||||
f"Dynamic normalization not available for tryout {tryout_id}. "
|
||||
"No stats have been calculated yet."
|
||||
)
|
||||
|
||||
if sb == 0:
|
||||
logger.warning(
|
||||
f"Standard deviation is 0 for tryout {tryout_id}. "
|
||||
"All NM scores are identical."
|
||||
)
|
||||
|
||||
return rataan, sb, "dynamic"
|
||||
|
||||
else: # hybrid
|
||||
# Check threshold
|
||||
threshold_met = await check_threshold_for_dynamic(db, website_id, tryout_id)
|
||||
|
||||
if threshold_met:
|
||||
# Use dynamic values
|
||||
rataan, sb = await calculate_dynamic_stats(db, website_id, tryout_id)
|
||||
|
||||
if rataan is None or sb is None:
|
||||
# Fallback to static if dynamic not available
|
||||
result = await db.execute(
|
||||
select(Tryout.static_rataan, Tryout.static_sb).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if row is None:
|
||||
raise ValueError(
|
||||
f"Tryout {tryout_id} not found for website {website_id}"
|
||||
)
|
||||
rataan, sb = row
|
||||
return rataan, sb, "static"
|
||||
|
||||
return rataan, sb, "dynamic"
|
||||
else:
|
||||
# Use static values
|
||||
result = await db.execute(
|
||||
select(Tryout.static_rataan, Tryout.static_sb).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if row is None:
|
||||
raise ValueError(
|
||||
f"Tryout {tryout_id} not found for website {website_id}"
|
||||
)
|
||||
rataan, sb = row
|
||||
return rataan, sb, "static"
|
||||
|
||||
|
||||
async def calculate_skewness(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Calculate skewness of NM distribution for validation.
|
||||
|
||||
Skewness measures the asymmetry of the probability distribution.
|
||||
Values:
|
||||
- Skewness ≈ 0: Symmetric distribution
|
||||
- Skewness > 0: Right-skewed (tail to the right)
|
||||
- Skewness < 0: Left-skewed (tail to the left)
|
||||
|
||||
Formula: Skewness = (n / ((n-1)(n-2))) * Σ((x - mean) / SD)³
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
Skewness value, or None if insufficient data
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = result.scalar_one_or_none()
|
||||
|
||||
if stats is None or stats.participant_count < 3:
|
||||
# Need at least 3 samples for skewness calculation
|
||||
return None
|
||||
|
||||
n = stats.participant_count
|
||||
mean = stats.rataan
|
||||
sd = stats.sb
|
||||
|
||||
if sd == 0:
|
||||
return 0.0 # All values are identical
|
||||
|
||||
# Calculate skewness
|
||||
# We need individual NM values, which we don't have in TryoutStats
|
||||
# For now, return None as we need a different approach
|
||||
# This would require storing all NM values or calculating on-the-fly
|
||||
return None
|
||||
|
||||
|
||||
async def validate_dynamic_normalization(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
target_mean: float = 500.0,
|
||||
target_sd: float = 100.0,
|
||||
mean_tolerance: float = 5.0,
|
||||
sd_tolerance: float = 5.0,
|
||||
) -> Tuple[bool, dict]:
|
||||
"""
|
||||
Validate that dynamic normalization produces expected distribution.
|
||||
|
||||
Checks if calculated rataan and sb are close to target values.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
target_mean: Target mean (default: 500)
|
||||
target_sd: Target standard deviation (default: 100)
|
||||
mean_tolerance: Allowed deviation from target mean (default: 5)
|
||||
sd_tolerance: Allowed deviation from target SD (default: 5)
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, validation_details)
|
||||
|
||||
validation_details contains:
|
||||
- participant_count: Number of participants
|
||||
- current_rataan: Current mean
|
||||
- current_sb: Current standard deviation
|
||||
- mean_deviation: Absolute deviation from target mean
|
||||
- sd_deviation: Absolute deviation from target SD
|
||||
- mean_within_tolerance: True if mean deviation < mean_tolerance
|
||||
- sd_within_tolerance: True if SD deviation < sd_tolerance
|
||||
- warnings: List of warning messages
|
||||
- suggestions: List of suggestions
|
||||
"""
|
||||
# Get current stats
|
||||
result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = result.scalar_one_or_none()
|
||||
|
||||
if stats is None or stats.rataan is None or stats.sb is None:
|
||||
return False, {
|
||||
"participant_count": 0,
|
||||
"current_rataan": None,
|
||||
"current_sb": None,
|
||||
"mean_deviation": None,
|
||||
"sd_deviation": None,
|
||||
"mean_within_tolerance": False,
|
||||
"sd_within_tolerance": False,
|
||||
"warnings": ["No statistics available for validation"],
|
||||
"suggestions": ["Wait for more participants to complete sessions"],
|
||||
}
|
||||
|
||||
# Calculate deviations
|
||||
mean_deviation = abs(stats.rataan - target_mean)
|
||||
sd_deviation = abs(stats.sb - target_sd)
|
||||
|
||||
# Check tolerance
|
||||
mean_within_tolerance = mean_deviation <= mean_tolerance
|
||||
sd_within_tolerance = sd_deviation <= sd_tolerance
|
||||
|
||||
is_valid = mean_within_tolerance and sd_within_tolerance
|
||||
|
||||
# Generate warnings
|
||||
warnings = []
|
||||
suggestions = []
|
||||
|
||||
if not mean_within_tolerance:
|
||||
warnings.append(f"Mean deviation ({mean_deviation:.2f}) exceeds tolerance ({mean_tolerance})")
|
||||
if stats.rataan > target_mean:
|
||||
suggestions.append("Distribution may be right-skewed - consider checking question difficulty")
|
||||
else:
|
||||
suggestions.append("Distribution may be left-skewed - consider checking question difficulty")
|
||||
|
||||
if not sd_within_tolerance:
|
||||
warnings.append(f"SD deviation ({sd_deviation:.2f}) exceeds tolerance ({sd_tolerance})")
|
||||
if stats.sb < target_sd:
|
||||
suggestions.append("SD too low - scores may be too tightly clustered")
|
||||
else:
|
||||
suggestions.append("SD too high - scores may have too much variance")
|
||||
|
||||
# Check for skewness
|
||||
skewness = await calculate_skewness(db, website_id, tryout_id)
|
||||
if skewness is not None and abs(skewness) > 0.5:
|
||||
warnings.append(f"Distribution skewness ({skewness:.2f}) > 0.5 - distribution may be asymmetric")
|
||||
suggestions.append("Consider using static normalization if dynamic normalization is unstable")
|
||||
|
||||
# Check participant count
|
||||
if stats.participant_count < 100:
|
||||
suggestions.append(f"Participant count ({stats.participant_count}) below recommended minimum (100)")
|
||||
|
||||
return is_valid, {
|
||||
"participant_count": stats.participant_count,
|
||||
"current_rataan": stats.rataan,
|
||||
"current_sb": stats.sb,
|
||||
"mean_deviation": mean_deviation,
|
||||
"sd_deviation": sd_deviation,
|
||||
"mean_within_tolerance": mean_within_tolerance,
|
||||
"sd_within_tolerance": sd_within_tolerance,
|
||||
"warnings": warnings,
|
||||
"suggestions": suggestions,
|
||||
}
|
||||
1529
backend/app/services/reporting.py
Normal file
1529
backend/app/services/reporting.py
Normal file
File diff suppressed because it is too large
Load Diff
389
backend/app/services/tryout_json_import.py
Normal file
389
backend/app/services/tryout_json_import.py
Normal file
@@ -0,0 +1,389 @@
|
||||
"""
|
||||
Importer for Sejoli tryout JSON snapshot payloads.
|
||||
|
||||
This importer stores snapshots as read-only reference data. It does not create
|
||||
or overwrite operational items, because the exported JSON does not currently
|
||||
contain the full option text needed for the live item bank.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Item, Tryout, TryoutImportSnapshot, TryoutSnapshotQuestion, Website
|
||||
|
||||
SOURCE_FORMAT = "sejoli_json"
|
||||
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
|
||||
class TryoutImportError(ValueError):
|
||||
"""Raised when the incoming payload is structurally invalid."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuestionDiffSummary:
|
||||
total_questions: int
|
||||
new_questions: int
|
||||
updated_questions: int
|
||||
unchanged_questions: int
|
||||
removed_questions: int
|
||||
missing_option_labels: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class TryoutPreview:
|
||||
source_tryout_id: str
|
||||
source_key: str
|
||||
title: str
|
||||
permalink: str | None
|
||||
question_diff: QuestionDiffSummary
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
def _parse_datetime(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
return datetime.strptime(value, DATETIME_FORMAT).replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _sha256(value: Any) -> str:
|
||||
payload = json.dumps(value, sort_keys=True, ensure_ascii=False)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _validate_root(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise TryoutImportError("Payload must be a JSON object.")
|
||||
if "tryouts" not in payload or not isinstance(payload["tryouts"], dict) or not payload["tryouts"]:
|
||||
raise TryoutImportError("Payload must contain a non-empty 'tryouts' object.")
|
||||
return payload
|
||||
|
||||
|
||||
def _extract_tryout_previews(payload: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
|
||||
return list(payload["tryouts"].items())
|
||||
|
||||
|
||||
def _normalize_question(question: dict[str, Any]) -> dict[str, Any]:
|
||||
raw_options = question.get("options") or []
|
||||
has_option_labels = any(
|
||||
bool(((opt or {}).get("text") or (opt or {}).get("label") or "").strip())
|
||||
for opt in raw_options
|
||||
if isinstance(opt, dict)
|
||||
)
|
||||
normalized = {
|
||||
"source_question_id": str(question.get("id", "")),
|
||||
"title": str(question.get("title") or "").strip(),
|
||||
"question": str(question.get("question") or "").strip(),
|
||||
"explanation": str(question.get("explanation") or "").strip() or None,
|
||||
"correct_answer": str(question.get("answer") or "").strip().upper(),
|
||||
"category_id": question.get("category_id"),
|
||||
"category_name": str(question.get("category_name") or "").strip() or None,
|
||||
"category_code": str(question.get("category_code") or "").strip() or None,
|
||||
"raw_options": raw_options,
|
||||
"option_count": len(raw_options),
|
||||
"has_option_labels": has_option_labels,
|
||||
"raw_payload": question,
|
||||
}
|
||||
normalized["content_checksum"] = _sha256(
|
||||
{
|
||||
"title": normalized["title"],
|
||||
"question": normalized["question"],
|
||||
"explanation": normalized["explanation"],
|
||||
"correct_answer": normalized["correct_answer"],
|
||||
"category_id": normalized["category_id"],
|
||||
"category_name": normalized["category_name"],
|
||||
"category_code": normalized["category_code"],
|
||||
"raw_options": normalized["raw_options"],
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
async def ensure_website_exists(db: AsyncSession, website_id: int) -> Website:
|
||||
result = await db.execute(select(Website).where(Website.id == website_id))
|
||||
website = result.scalar_one_or_none()
|
||||
if website is None:
|
||||
raise TryoutImportError(
|
||||
f"Website {website_id} not found. Register the website in the backend first; this is not configured via .env."
|
||||
)
|
||||
return website
|
||||
|
||||
|
||||
async def preview_tryout_json_import(payload: dict[str, Any], website_id: int, db: AsyncSession) -> dict[str, Any]:
|
||||
_validate_root(payload)
|
||||
await ensure_website_exists(db, website_id)
|
||||
|
||||
tryout_previews: list[TryoutPreview] = []
|
||||
total_new = total_updated = total_unchanged = total_removed = total_missing_labels = 0
|
||||
|
||||
for source_key, tryout_payload in _extract_tryout_previews(payload):
|
||||
info = tryout_payload.get("info") or {}
|
||||
source_tryout_id = str(info.get("id") or source_key)
|
||||
title = str(info.get("title") or source_key)
|
||||
questions = tryout_payload.get("questions") or []
|
||||
normalized_questions = [_normalize_question(q) for q in questions]
|
||||
|
||||
existing_result = await db.execute(
|
||||
select(TryoutSnapshotQuestion).where(
|
||||
TryoutSnapshotQuestion.website_id == website_id,
|
||||
TryoutSnapshotQuestion.source_tryout_id == source_tryout_id,
|
||||
)
|
||||
)
|
||||
existing_questions = {
|
||||
row.source_question_id: row
|
||||
for row in existing_result.scalars().all()
|
||||
}
|
||||
|
||||
new_questions = updated_questions = unchanged_questions = 0
|
||||
missing_option_labels = 0
|
||||
incoming_ids: set[str] = set()
|
||||
|
||||
for question in normalized_questions:
|
||||
incoming_ids.add(question["source_question_id"])
|
||||
existing = existing_questions.get(question["source_question_id"])
|
||||
if question["has_option_labels"] is False:
|
||||
missing_option_labels += 1
|
||||
if existing is None:
|
||||
new_questions += 1
|
||||
elif existing.content_checksum != question["content_checksum"]:
|
||||
updated_questions += 1
|
||||
else:
|
||||
unchanged_questions += 1
|
||||
|
||||
removed_questions = sum(1 for question_id, row in existing_questions.items() if row.is_active and question_id not in incoming_ids)
|
||||
|
||||
warnings: list[str] = []
|
||||
if missing_option_labels:
|
||||
warnings.append(
|
||||
f"{missing_option_labels} question(s) have no exported option text in the JSON; import will store raw reference data only."
|
||||
)
|
||||
|
||||
summary = QuestionDiffSummary(
|
||||
total_questions=len(normalized_questions),
|
||||
new_questions=new_questions,
|
||||
updated_questions=updated_questions,
|
||||
unchanged_questions=unchanged_questions,
|
||||
removed_questions=removed_questions,
|
||||
missing_option_labels=missing_option_labels,
|
||||
)
|
||||
|
||||
total_new += new_questions
|
||||
total_updated += updated_questions
|
||||
total_unchanged += unchanged_questions
|
||||
total_removed += removed_questions
|
||||
total_missing_labels += missing_option_labels
|
||||
|
||||
tryout_previews.append(
|
||||
TryoutPreview(
|
||||
source_tryout_id=source_tryout_id,
|
||||
source_key=source_key,
|
||||
title=title,
|
||||
permalink=info.get("permalink"),
|
||||
question_diff=summary,
|
||||
warnings=warnings,
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"source_format": SOURCE_FORMAT,
|
||||
"tryout_count": len(tryout_previews),
|
||||
"totals": {
|
||||
"new_questions": total_new,
|
||||
"updated_questions": total_updated,
|
||||
"unchanged_questions": total_unchanged,
|
||||
"removed_questions": total_removed,
|
||||
"missing_option_labels": total_missing_labels,
|
||||
},
|
||||
"tryouts": [
|
||||
{
|
||||
"source_tryout_id": preview.source_tryout_id,
|
||||
"source_key": preview.source_key,
|
||||
"title": preview.title,
|
||||
"permalink": preview.permalink,
|
||||
"question_diff": preview.question_diff.__dict__,
|
||||
"warnings": preview.warnings,
|
||||
}
|
||||
for preview in tryout_previews
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def import_tryout_json_snapshot(payload: dict[str, Any], website_id: int, db: AsyncSession) -> dict[str, Any]:
|
||||
preview = await preview_tryout_json_import(payload, website_id, db)
|
||||
export_info = payload.get("export_info") or {}
|
||||
|
||||
imported_tryouts: list[dict[str, Any]] = []
|
||||
|
||||
for source_key, tryout_payload in _extract_tryout_previews(payload):
|
||||
info = tryout_payload.get("info") or {}
|
||||
source_tryout_id = str(info.get("id") or source_key)
|
||||
title = str(info.get("title") or source_key)
|
||||
questions = tryout_payload.get("questions") or []
|
||||
results = tryout_payload.get("results") or []
|
||||
normalized_questions = [_normalize_question(q) for q in questions]
|
||||
|
||||
snapshot = TryoutImportSnapshot(
|
||||
website_id=website_id,
|
||||
source_tryout_id=source_tryout_id,
|
||||
source_key=source_key,
|
||||
title=title,
|
||||
source_permalink=info.get("permalink"),
|
||||
source_status=info.get("status"),
|
||||
exported_at=_parse_datetime(export_info.get("exported_at")),
|
||||
source_created_at=_parse_datetime(info.get("created_date")),
|
||||
source_modified_at=_parse_datetime(info.get("modified_date")),
|
||||
exported_by=export_info.get("exported_by"),
|
||||
question_count=len(questions),
|
||||
result_count=len(results),
|
||||
payload_checksum=_sha256(tryout_payload),
|
||||
raw_payload=tryout_payload,
|
||||
)
|
||||
db.add(snapshot)
|
||||
await db.flush()
|
||||
|
||||
# Ensure operational tryout exists
|
||||
result_tryout = await db.execute(
|
||||
select(Tryout).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == source_tryout_id,
|
||||
)
|
||||
)
|
||||
tryout = result_tryout.scalar_one_or_none()
|
||||
if not tryout:
|
||||
tryout = Tryout(
|
||||
website_id=website_id,
|
||||
tryout_id=source_tryout_id,
|
||||
name=title,
|
||||
description=f"Operational tryout basis created from imported snapshot #{snapshot.id}.",
|
||||
scoring_mode="ctt",
|
||||
selection_mode="fixed",
|
||||
normalization_mode="static",
|
||||
ai_generation_enabled=True,
|
||||
)
|
||||
db.add(tryout)
|
||||
await db.flush()
|
||||
|
||||
existing_result = await db.execute(
|
||||
select(TryoutSnapshotQuestion).where(
|
||||
TryoutSnapshotQuestion.website_id == website_id,
|
||||
TryoutSnapshotQuestion.source_tryout_id == source_tryout_id,
|
||||
)
|
||||
)
|
||||
existing_questions = {
|
||||
row.source_question_id: row
|
||||
for row in existing_result.scalars().all()
|
||||
}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
incoming_ids: set[str] = set()
|
||||
new_questions = updated_questions = unchanged_questions = 0
|
||||
|
||||
for question in normalized_questions:
|
||||
source_question_id = question["source_question_id"]
|
||||
incoming_ids.add(source_question_id)
|
||||
existing = existing_questions.get(source_question_id)
|
||||
if existing is None:
|
||||
row = TryoutSnapshotQuestion(
|
||||
website_id=website_id,
|
||||
source_tryout_id=source_tryout_id,
|
||||
source_question_id=source_question_id,
|
||||
latest_snapshot_id=snapshot.id,
|
||||
question_title=question["title"] or question["question"],
|
||||
question_html=question["question"],
|
||||
explanation_html=question["explanation"],
|
||||
raw_options=question["raw_options"],
|
||||
correct_answer=question["correct_answer"],
|
||||
category_id=question["category_id"],
|
||||
category_name=question["category_name"],
|
||||
category_code=question["category_code"],
|
||||
option_count=question["option_count"],
|
||||
has_option_labels=question["has_option_labels"],
|
||||
is_active=True,
|
||||
content_checksum=question["content_checksum"],
|
||||
raw_payload=question["raw_payload"],
|
||||
last_seen_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
new_questions += 1
|
||||
continue
|
||||
|
||||
content_changed = existing.content_checksum != question["content_checksum"]
|
||||
if content_changed:
|
||||
existing.question_title = question["title"] or question["question"]
|
||||
existing.question_html = question["question"]
|
||||
existing.explanation_html = question["explanation"]
|
||||
existing.raw_options = question["raw_options"]
|
||||
existing.correct_answer = question["correct_answer"]
|
||||
existing.category_id = question["category_id"]
|
||||
existing.category_name = question["category_name"]
|
||||
existing.category_code = question["category_code"]
|
||||
existing.option_count = question["option_count"]
|
||||
existing.has_option_labels = question["has_option_labels"]
|
||||
existing.content_checksum = question["content_checksum"]
|
||||
existing.raw_payload = question["raw_payload"]
|
||||
updated_questions += 1
|
||||
else:
|
||||
unchanged_questions += 1
|
||||
|
||||
existing.latest_snapshot_id = snapshot.id
|
||||
existing.is_active = True
|
||||
existing.last_seen_at = now
|
||||
|
||||
# If source content changed, mark AI children derived from this source as stale.
|
||||
if content_changed:
|
||||
stale_variants_result = await db.execute(
|
||||
select(Item).where(
|
||||
Item.generated_by == "ai",
|
||||
Item.source_snapshot_question_id == existing.id,
|
||||
Item.variant_status.in_(["draft", "approved", "active"]),
|
||||
)
|
||||
)
|
||||
for variant in stale_variants_result.scalars().all():
|
||||
variant.variant_status = "stale"
|
||||
|
||||
removed_questions = 0
|
||||
for source_question_id, existing in existing_questions.items():
|
||||
if existing.is_active and source_question_id not in incoming_ids:
|
||||
existing.is_active = False
|
||||
existing.latest_snapshot_id = snapshot.id
|
||||
existing.last_seen_at = now
|
||||
removed_questions += 1
|
||||
stale_removed_result = await db.execute(
|
||||
select(Item).where(
|
||||
Item.generated_by == "ai",
|
||||
Item.source_snapshot_question_id == existing.id,
|
||||
Item.variant_status.in_(["draft", "approved", "active"]),
|
||||
)
|
||||
)
|
||||
for variant in stale_removed_result.scalars().all():
|
||||
variant.variant_status = "stale"
|
||||
|
||||
imported_tryouts.append(
|
||||
{
|
||||
"snapshot_id": snapshot.id,
|
||||
"source_tryout_id": source_tryout_id,
|
||||
"title": title,
|
||||
"new_questions": new_questions,
|
||||
"updated_questions": updated_questions,
|
||||
"unchanged_questions": unchanged_questions,
|
||||
"removed_questions": removed_questions,
|
||||
"question_count": len(normalized_questions),
|
||||
}
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"source_format": SOURCE_FORMAT,
|
||||
"website_id": website_id,
|
||||
"preview": preview,
|
||||
"imported_tryouts": imported_tryouts,
|
||||
"message": "Tryout JSON snapshot imported as read-only reference data.",
|
||||
}
|
||||
456
backend/app/services/wordpress_auth.py
Normal file
456
backend/app/services/wordpress_auth.py
Normal file
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
WordPress Authentication and User Synchronization Service.
|
||||
|
||||
Handles:
|
||||
- JWT token validation via WordPress REST API
|
||||
- User synchronization from WordPress to local database
|
||||
- Multi-site support via website_id isolation
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.user import User
|
||||
from app.models.website import Website
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
# Custom exceptions for WordPress integration
|
||||
class WordPressAuthError(Exception):
|
||||
"""Base exception for WordPress authentication errors."""
|
||||
pass
|
||||
|
||||
|
||||
class WordPressTokenInvalidError(WordPressAuthError):
|
||||
"""Raised when WordPress token is invalid or expired."""
|
||||
pass
|
||||
|
||||
|
||||
class WordPressAPIError(WordPressAuthError):
|
||||
"""Raised when WordPress API is unreachable or returns error."""
|
||||
pass
|
||||
|
||||
|
||||
class WordPressRateLimitError(WordPressAuthError):
|
||||
"""Raised when WordPress API rate limit is exceeded."""
|
||||
pass
|
||||
|
||||
|
||||
class WebsiteNotFoundError(WordPressAuthError):
|
||||
"""Raised when website_id is not found in local database."""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class WordPressUserInfo:
|
||||
"""Data class for WordPress user information."""
|
||||
wp_user_id: str
|
||||
username: str
|
||||
email: str
|
||||
display_name: str
|
||||
roles: list[str]
|
||||
raw_data: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncStats:
|
||||
"""Data class for user synchronization statistics."""
|
||||
inserted: int
|
||||
updated: int
|
||||
total: int
|
||||
errors: int
|
||||
|
||||
|
||||
async def get_wordpress_api_base(website: Website) -> str:
|
||||
"""
|
||||
Get WordPress API base URL for a website.
|
||||
|
||||
Args:
|
||||
website: Website model instance
|
||||
|
||||
Returns:
|
||||
WordPress REST API base URL
|
||||
"""
|
||||
# Use website's site_url if configured, otherwise use global config
|
||||
base_url = website.site_url.rstrip('/')
|
||||
return f"{base_url}/wp-json"
|
||||
|
||||
|
||||
async def verify_wordpress_token(
|
||||
token: str,
|
||||
website_id: int,
|
||||
wp_user_id: str,
|
||||
db: AsyncSession,
|
||||
) -> Optional[WordPressUserInfo]:
|
||||
"""
|
||||
Verify WordPress JWT token and validate user identity.
|
||||
|
||||
Calls WordPress REST API GET /wp/v2/users/me with Authorization header.
|
||||
Verifies response contains matching wp_user_id.
|
||||
Verifies website_id exists in local database.
|
||||
|
||||
Args:
|
||||
token: WordPress JWT authentication token
|
||||
website_id: Website identifier for multi-site isolation
|
||||
wp_user_id: Expected WordPress user ID to verify
|
||||
db: Async database session
|
||||
|
||||
Returns:
|
||||
WordPressUserInfo if valid, None if invalid
|
||||
|
||||
Raises:
|
||||
WebsiteNotFoundError: If website_id doesn't exist
|
||||
WordPressTokenInvalidError: If token is invalid
|
||||
WordPressAPIError: If API is unreachable
|
||||
WordPressRateLimitError: If rate limited
|
||||
"""
|
||||
# Verify website exists
|
||||
website_result = await db.execute(
|
||||
select(Website).where(Website.id == website_id)
|
||||
)
|
||||
website = website_result.scalar_one_or_none()
|
||||
|
||||
if website is None:
|
||||
raise WebsiteNotFoundError(f"Website {website_id} not found")
|
||||
|
||||
api_base = await get_wordpress_api_base(website)
|
||||
url = f"{api_base}/wp/v2/users/me"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
timeout = httpx.Timeout(10.0, connect=5.0)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise WordPressTokenInvalidError("Invalid or expired WordPress token")
|
||||
|
||||
if response.status_code == 429:
|
||||
raise WordPressRateLimitError("WordPress API rate limit exceeded")
|
||||
|
||||
if response.status_code == 503:
|
||||
raise WordPressAPIError("WordPress API service unavailable")
|
||||
|
||||
if response.status_code != 200:
|
||||
raise WordPressAPIError(
|
||||
f"WordPress API error: {response.status_code} - {response.text}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Verify user ID matches
|
||||
response_user_id = str(data.get("id", ""))
|
||||
if response_user_id != str(wp_user_id):
|
||||
logger.warning(
|
||||
f"User ID mismatch: expected {wp_user_id}, got {response_user_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Extract user info
|
||||
user_info = WordPressUserInfo(
|
||||
wp_user_id=response_user_id,
|
||||
username=data.get("username", ""),
|
||||
email=data.get("email", ""),
|
||||
display_name=data.get("name", ""),
|
||||
roles=data.get("roles", []),
|
||||
raw_data=data,
|
||||
)
|
||||
|
||||
return user_info
|
||||
|
||||
except httpx.TimeoutException:
|
||||
raise WordPressAPIError("WordPress API request timed out")
|
||||
except httpx.ConnectError:
|
||||
raise WordPressAPIError("Unable to connect to WordPress API")
|
||||
except httpx.HTTPError as e:
|
||||
raise WordPressAPIError(f"HTTP error communicating with WordPress: {str(e)}")
|
||||
|
||||
|
||||
async def fetch_wordpress_users(
|
||||
website: Website,
|
||||
admin_token: str,
|
||||
page: int = 1,
|
||||
per_page: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Fetch users from WordPress API (requires admin token).
|
||||
|
||||
Calls WordPress REST API GET /wp/v2/users with admin authorization.
|
||||
|
||||
Args:
|
||||
website: Website model instance
|
||||
admin_token: WordPress admin JWT token
|
||||
page: Page number for pagination
|
||||
per_page: Number of users per page (max 100)
|
||||
|
||||
Returns:
|
||||
List of WordPress user data dictionaries
|
||||
|
||||
Raises:
|
||||
WordPressTokenInvalidError: If admin token is invalid
|
||||
WordPressAPIError: If API is unreachable
|
||||
WordPressRateLimitError: If rate limited
|
||||
"""
|
||||
api_base = await get_wordpress_api_base(website)
|
||||
url = f"{api_base}/wp/v2/users"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {admin_token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
params = {
|
||||
"page": page,
|
||||
"per_page": min(per_page, 100),
|
||||
"context": "edit", # Get full user data
|
||||
}
|
||||
|
||||
timeout = httpx.Timeout(30.0, connect=10.0)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise WordPressTokenInvalidError("Invalid admin token for user sync")
|
||||
|
||||
if response.status_code == 403:
|
||||
raise WordPressTokenInvalidError(
|
||||
"Admin token lacks permission to list users"
|
||||
)
|
||||
|
||||
if response.status_code == 429:
|
||||
raise WordPressRateLimitError("WordPress API rate limit exceeded")
|
||||
|
||||
if response.status_code == 503:
|
||||
raise WordPressAPIError("WordPress API service unavailable")
|
||||
|
||||
if response.status_code != 200:
|
||||
raise WordPressAPIError(
|
||||
f"WordPress API error: {response.status_code} - {response.text}"
|
||||
)
|
||||
|
||||
return response.json()
|
||||
|
||||
except httpx.TimeoutException:
|
||||
raise WordPressAPIError("WordPress API request timed out")
|
||||
except httpx.ConnectError:
|
||||
raise WordPressAPIError("Unable to connect to WordPress API")
|
||||
except httpx.HTTPError as e:
|
||||
raise WordPressAPIError(f"HTTP error communicating with WordPress: {str(e)}")
|
||||
|
||||
|
||||
async def sync_wordpress_users(
|
||||
website_id: int,
|
||||
admin_token: str,
|
||||
db: AsyncSession,
|
||||
) -> SyncStats:
|
||||
"""
|
||||
Synchronize users from WordPress to local database.
|
||||
|
||||
Fetches all users from WordPress API and performs upsert:
|
||||
- Updates existing users
|
||||
- Inserts new users
|
||||
|
||||
Args:
|
||||
website_id: Website identifier for multi-site isolation
|
||||
admin_token: WordPress admin JWT token
|
||||
db: Async database session
|
||||
|
||||
Returns:
|
||||
SyncStats with insertion/update counts
|
||||
|
||||
Raises:
|
||||
WebsiteNotFoundError: If website_id doesn't exist
|
||||
WordPressTokenInvalidError: If admin token is invalid
|
||||
WordPressAPIError: If API is unreachable
|
||||
"""
|
||||
# Verify website exists
|
||||
website_result = await db.execute(
|
||||
select(Website).where(Website.id == website_id)
|
||||
)
|
||||
website = website_result.scalar_one_or_none()
|
||||
|
||||
if website is None:
|
||||
raise WebsiteNotFoundError(f"Website {website_id} not found")
|
||||
|
||||
# Fetch existing users from local database
|
||||
existing_users_result = await db.execute(
|
||||
select(User).where(User.website_id == website_id)
|
||||
)
|
||||
existing_users = {
|
||||
str(user.wp_user_id): user
|
||||
for user in existing_users_result.scalars().all()
|
||||
}
|
||||
|
||||
# Fetch users from WordPress (with pagination)
|
||||
all_wp_users = []
|
||||
page = 1
|
||||
per_page = 100
|
||||
|
||||
while True:
|
||||
wp_users = await fetch_wordpress_users(
|
||||
website, admin_token, page, per_page
|
||||
)
|
||||
|
||||
if not wp_users:
|
||||
break
|
||||
|
||||
all_wp_users.extend(wp_users)
|
||||
|
||||
# Check if more pages
|
||||
if len(wp_users) < per_page:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
# Sync users
|
||||
inserted = 0
|
||||
updated = 0
|
||||
errors = 0
|
||||
|
||||
for wp_user in all_wp_users:
|
||||
try:
|
||||
wp_user_id = str(wp_user.get("id", ""))
|
||||
|
||||
if not wp_user_id:
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
if wp_user_id in existing_users:
|
||||
# Update existing user (timestamp update)
|
||||
existing_user = existing_users[wp_user_id]
|
||||
existing_user.updated_at = datetime.now(timezone.utc)
|
||||
updated += 1
|
||||
else:
|
||||
# Insert new user
|
||||
new_user = User(
|
||||
wp_user_id=wp_user_id,
|
||||
website_id=website_id,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(new_user)
|
||||
inserted += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing user {wp_user.get('id')}: {e}")
|
||||
errors += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
total = inserted + updated
|
||||
|
||||
logger.info(
|
||||
f"WordPress user sync complete for website {website_id}: "
|
||||
f"{inserted} inserted, {updated} updated, {errors} errors"
|
||||
)
|
||||
|
||||
return SyncStats(
|
||||
inserted=inserted,
|
||||
updated=updated,
|
||||
total=total,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
async def get_wordpress_user(
|
||||
wp_user_id: str,
|
||||
website_id: int,
|
||||
db: AsyncSession,
|
||||
) -> Optional[User]:
|
||||
"""
|
||||
Get user from local database by WordPress user ID and website ID.
|
||||
|
||||
Args:
|
||||
wp_user_id: WordPress user ID
|
||||
website_id: Website identifier for multi-site isolation
|
||||
db: Async database session
|
||||
|
||||
Returns:
|
||||
User object if found, None otherwise
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.wp_user_id == wp_user_id,
|
||||
User.website_id == website_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def verify_website_exists(
|
||||
website_id: int,
|
||||
db: AsyncSession,
|
||||
) -> Website:
|
||||
"""
|
||||
Verify website exists in database.
|
||||
|
||||
Args:
|
||||
website_id: Website identifier
|
||||
db: Async database session
|
||||
|
||||
Returns:
|
||||
Website model instance
|
||||
|
||||
Raises:
|
||||
WebsiteNotFoundError: If website doesn't exist
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Website).where(Website.id == website_id)
|
||||
)
|
||||
website = result.scalar_one_or_none()
|
||||
|
||||
if website is None:
|
||||
raise WebsiteNotFoundError(f"Website {website_id} not found")
|
||||
|
||||
return website
|
||||
|
||||
|
||||
async def get_or_create_user(
|
||||
wp_user_id: str,
|
||||
website_id: int,
|
||||
db: AsyncSession,
|
||||
) -> User:
|
||||
"""
|
||||
Get existing user or create new one if not exists.
|
||||
|
||||
Args:
|
||||
wp_user_id: WordPress user ID
|
||||
website_id: Website identifier
|
||||
db: Async database session
|
||||
|
||||
Returns:
|
||||
User model instance
|
||||
"""
|
||||
existing = await get_wordpress_user(wp_user_id, website_id, db)
|
||||
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
# Create new user
|
||||
new_user = User(
|
||||
wp_user_id=wp_user_id,
|
||||
website_id=website_id,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(new_user)
|
||||
await db.commit()
|
||||
await db.refresh(new_user)
|
||||
|
||||
return new_user
|
||||
37
backend/docker-compose.dev.yml
Normal file
37
backend/docker-compose.dev.yml
Normal file
@@ -0,0 +1,37 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_USER: irt_user
|
||||
POSTGRES_PASSWORD: dev_password
|
||||
POSTGRES_DB: irt_bank_soal
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
user: "70:70" # postgres user
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://irt_user:dev_password@postgres:5432/irt_bank_soal
|
||||
REDIS_URL: redis://redis:6379
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
volumes:
|
||||
- .:/app
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
626
backend/docs/ALUR-APLIKASI-DAN-IRT.md
Normal file
626
backend/docs/ALUR-APLIKASI-DAN-IRT.md
Normal file
@@ -0,0 +1,626 @@
|
||||
# Alur Aplikasi IRT-Powered Question Bank
|
||||
|
||||
Dokumen ini menjelaskan alur lengkap aplikasi dari input data hingga menghasilkan next-question berbasis IRT.
|
||||
|
||||
---
|
||||
|
||||
## 1. Arsitektur Sistem
|
||||
|
||||
### 1.1 Teknologi Stack
|
||||
|
||||
```
|
||||
Framework: FastAPI >= 0.104.1
|
||||
Database: PostgreSQL + SQLAlchemy 2.0 (async)
|
||||
AI: OpenAI (OpenRouter API)
|
||||
Admin Panel: FastAPI-Admin
|
||||
Math: numpy, scipy
|
||||
Excel: openpyxl, pandas
|
||||
```
|
||||
|
||||
### 1.2 Entity Relationship
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
Website ||--o{ Tryout : "hosts"
|
||||
Website ||--o{ User : "contains"
|
||||
Website ||--o{ Session : "serves"
|
||||
Website ||--o{ Item : "contains"
|
||||
|
||||
Tryout ||--o{ Item : "contains"
|
||||
Tryout ||--o{ Session : "has"
|
||||
|
||||
Session ||--o{ UserAnswer : "contains"
|
||||
|
||||
Item ||--o{ Item : "has variants"
|
||||
Item ||--o{ UserAnswer : "answered by"
|
||||
|
||||
AIGenerationRun ||--o{ Item : "generates"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Konsep Inti
|
||||
|
||||
### 2.1 Tryout (Exam)
|
||||
|
||||
**Tryout** merepresentasikan 1 ujian lengkap dengan konfigurasi:
|
||||
|
||||
| Field | Opsi | Default | Deskripsi |
|
||||
|-------|------|---------|-----------|
|
||||
| `scoring_mode` | `ctt`, `irt`, `hybrid` | `ctt` | Metode kalkulasi score |
|
||||
| `selection_mode` | `fixed`, `adaptive`, `hybrid` | `fixed` | Strategi pemilihan soal |
|
||||
| `normalization_mode` | `static`, `dynamic`, `hybrid` | `static` | Metode normalisasi |
|
||||
|
||||
### 2.2 Item (Soal)
|
||||
|
||||
**Item** merepresentasikan 1 soal dengan parameter:
|
||||
|
||||
| Field | Deskripsi |
|
||||
|-------|-----------|
|
||||
| `stem` | Teks pertanyaan |
|
||||
| `options` | Pilihan jawaban (A/B/C/D/E) |
|
||||
| `correct_answer` | Kunci jawaban |
|
||||
| `slot` | Posisi nomor soal (1, 2, 3...) |
|
||||
| `level` | Kategori kesulitan (mudah/sedang/sulit) |
|
||||
| `parent_item_id` | ID soal original (jika ini variant) |
|
||||
| `calibrated` | Status IRT calibration |
|
||||
| `irt_b` | Item difficulty parameter |
|
||||
| `irt_se` | Standard error |
|
||||
| `ctt_p` | P-value (tingkat kesukaran CTT) |
|
||||
| `ctt_bobot` | Bobot soal = 1 - p |
|
||||
|
||||
### 2.3 Session (Percobaan Siswa)
|
||||
|
||||
**Session** melacak aktivitas siswa:
|
||||
|
||||
| Field | Deskripsi |
|
||||
|-------|-----------|
|
||||
| `session_id` | Identifier unik |
|
||||
| `wp_user_id` | ID user dari WordPress |
|
||||
| `tryout_id` | Tryout yang diambil |
|
||||
| `theta` | Kemampuan estimasi IRT |
|
||||
| `theta_se` | Standard error theta |
|
||||
| `NM` | Nilai Mentah (raw score) |
|
||||
| `NN` | Nilai Nasional (normalized) |
|
||||
| `is_completed` | Status selesai |
|
||||
|
||||
### 2.4 Website (Multi-Tenant)
|
||||
|
||||
Sistem mendukung multiple WordPress websites dari 1 backend:
|
||||
|
||||
- Isolasi data per website
|
||||
- Auth via `X-Website-ID` header
|
||||
- WordPress JWT tokens
|
||||
|
||||
---
|
||||
|
||||
## 3. Alur Input Data
|
||||
|
||||
### 3.1 Sumber Data Masuk
|
||||
|
||||
| Sumber | Format | Endpoint | Fungsi |
|
||||
|--------|--------|----------|--------|
|
||||
| Admin Import | Excel (.xlsx) | `POST /import/excel` | Bulk import dari file Excel |
|
||||
| JSON Import | JSON | `tryout_json_import.py` | Import dari JSON (LMS external) |
|
||||
| AI Generation | API Request | `POST /ai/generate` | Generate variant soal baru |
|
||||
|
||||
### 3.2 Flow Import JSON
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ADMIN: Import Tryout JSON │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. Upload JSON file │
|
||||
│ └─> File berisi 1 tryout lengkap (misal: "TO 2024") │
|
||||
│ └─> Terdiri dari N soal (slot 1, 2, 3, ...) │
|
||||
│ │
|
||||
│ 2. Parse JSON │
|
||||
│ └─> Extract setiap soal → Item record │
|
||||
│ └─> Generate unique item_id │
|
||||
│ │
|
||||
│ 3. Simpan ke Database │
|
||||
│ └─> Item.calibrated = False (belum ada IRT params) │
|
||||
│ └─> Item.ctt_p = NULL (belum ada response data) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.3 Flow AI Generate Variants
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ADMIN: Generate AI Variants │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. Pilih Item Original │
|
||||
│ └─> Ambil 1 soal dari imported tryout │
|
||||
│ │
|
||||
│ 2. Request ke OpenRouter API │
|
||||
│ └─> Kirim prompt dengan soal original │
|
||||
│ └─> Minta generate variant dengan level berbeda │
|
||||
│ │
|
||||
│ 3. Simpan Variant │
|
||||
│ └─> variant.item_id = unique_id │
|
||||
│ └─> variant.parent_item_id = original.id │
|
||||
│ └─> variant.slot = original.slot (nomor sama) │
|
||||
│ │
|
||||
│ 4. Result │
|
||||
│ └─> Slot 1: 1 original + 1 variant = 2 soal │
|
||||
│ └─> Slot 2: 1 original + 1 variant = 2 soal │
|
||||
│ └─> Total: 2N soal (N slot × 2 variant) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.4 Contoh Struktur Data Setelah Import + Generate
|
||||
|
||||
```
|
||||
Tryout: "TO-2024"
|
||||
├── Slot 1
|
||||
│ ├── Item #1 (original, calibrated=True, irt_b=0.5)
|
||||
│ └── Item #2 (variant, calibrated=True, irt_b=-0.3)
|
||||
├── Slot 2
|
||||
│ ├── Item #3 (original, calibrated=True, irt_b=0.8)
|
||||
│ └── Item #4 (variant, calibrated=True, irt_b=0.2)
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Pemrosesan Scoring
|
||||
|
||||
### 4.1 CTT (Classical Test Theory)
|
||||
|
||||
#### Step-by-Step Formula:
|
||||
|
||||
```python
|
||||
# STEP 1: Tingkat Kesukaran (p-value)
|
||||
p = Σ Benar / Total Peserta
|
||||
# Contoh: 70 siswa menjawab benar dari 100 siswa → p = 0.70
|
||||
|
||||
# STEP 2: Bobot (Weight)
|
||||
bobot = 1 - p
|
||||
# Contoh: bobot = 1 - 0.70 = 0.30
|
||||
|
||||
# STEP 3: Total Benar per Siswa
|
||||
total_benar = count(correct answers)
|
||||
|
||||
# STEP 4: Total Bobot Earned per Siswa
|
||||
total_bobot_siswa = Σ bobot for each correct answer
|
||||
# Contoh: Jawab benar 3 soal dengan bobot [0.3, 0.5, 0.2] = 1.0
|
||||
|
||||
# STEP 5: Nilai Mentah (Raw Score)
|
||||
NM = (Total_Bobot_Siswa / Total_Bobot_Max) × 1000
|
||||
# Contoh: NM = (1.0 / 2.5) × 1000 = 400
|
||||
|
||||
# STEP 6: Nilai Nasional (Normalized Score)
|
||||
NN = 500 + 100 × ((NM - Rataan) / SB)
|
||||
# Contoh: NN = 500 + 100 × ((400 - 450) / 80) = 437.5
|
||||
```
|
||||
|
||||
#### Kategori Kesulitan (CTT Standard):
|
||||
|
||||
| p-value | Kategori | Arti |
|
||||
|---------|----------|------|
|
||||
| p < 0.30 | Sulit | Hanya <30% siswa menjawab benar |
|
||||
| 0.30 ≤ p ≤ 0.70 | Sedang | 30-70% siswa menjawab benar |
|
||||
| p > 0.70 | Mudah | >70% siswa menjawab benar |
|
||||
|
||||
### 4.2 IRT (Item Response Theory) - 1PL Rasch Model
|
||||
|
||||
#### Formula Inti:
|
||||
|
||||
```python
|
||||
# Probability of correct response
|
||||
P(θ, b) = 1 / (1 + exp(-(θ - b)))
|
||||
|
||||
# Di mana:
|
||||
# - θ (theta) = kemampuan siswa [-3, +3]
|
||||
# - b = difficulty soal [-3, +3]
|
||||
|
||||
# Contoh:
|
||||
# - Siswa dengan θ = 0.5 menghadapi soal dengan b = 0.5
|
||||
# - P(0.5, 0.5) = 1 / (1 + exp(0)) = 0.5 (50% kemungkinan benar)
|
||||
```
|
||||
|
||||
#### Interpretasi Theta:
|
||||
|
||||
| Theta | Kemampuan | Persentase Benar (jika b=0) |
|
||||
|-------|-----------|------------------------------|
|
||||
| -3.0 | Sangat Lemah | ~5% |
|
||||
| -1.5 | Lemah | ~18% |
|
||||
| 0.0 | Rata-rata | ~50% |
|
||||
| +1.5 | Cerdas | ~82% |
|
||||
| +3.0 | Sangat Cerdas | ~95% |
|
||||
|
||||
#### Theta Estimation via MLE:
|
||||
|
||||
```python
|
||||
# Log-likelihood
|
||||
LL = Σ [u_i × log(P) + (1-u_i) × log(1-P)]
|
||||
# u_i = 1 jika benar, 0 jika salah
|
||||
|
||||
# Theta estimation = maximize LL
|
||||
θ_mle = argmax_θ LL(θ)
|
||||
```
|
||||
|
||||
### 4.3 Kombinasi Scoring Mode
|
||||
|
||||
| Konfigurasi | Arti |
|
||||
|-------------|------|
|
||||
| `scoring_mode="ctt"` | Score akhir = NM, NN |
|
||||
| `scoring_mode="irt"` | Score akhir = theta × 200 + 500 |
|
||||
| `scoring_mode="hybrid"` | CTT score + IRT theta keduanya di-track |
|
||||
|
||||
---
|
||||
|
||||
## 5. IRT Calibration
|
||||
|
||||
### 5.1 Apa Itu Calibration?
|
||||
|
||||
**IRT Calibration** adalah proses mengestimasi parameter `b` (difficulty) untuk setiap soal berdasarkan response data dari siswa.
|
||||
|
||||
### 5.2 Kapan Item Became Calibrated?
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SYARAT ITEM CALIBRATED │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. Minimum Response Sample │
|
||||
│ └─> Ada cukup response data (default: 100 siswa) │
|
||||
│ │
|
||||
│ 2. IRT b Parameter │
|
||||
│ └─> Sudah diestimasi via MLE │
|
||||
│ │
|
||||
│ 3. IRT SE (Standard Error) │
|
||||
│ └─> Sudah dihitung │
|
||||
│ │
|
||||
│ 4. Item.calibrated = True │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.3 Flow IRT Calibration
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Collect Response Data] --> B{Have Min Sample?}
|
||||
B -->|No| C[Wait for more students]
|
||||
C --> A
|
||||
B -->|Yes| D[For each Item]
|
||||
D --> E[Build Response Matrix]
|
||||
E --> F[Estimate b via MLE]
|
||||
F --> G[Calculate Standard Error]
|
||||
G --> H[Update Item.irt_b]
|
||||
H --> I[Item.calibrated = True]
|
||||
I --> D
|
||||
D --> J[Calibration Complete]
|
||||
```
|
||||
|
||||
### 5.4 Trigger Calibration
|
||||
|
||||
Calibration bisa dipicu via:
|
||||
|
||||
1. **API Endpoint:**
|
||||
```
|
||||
POST /tryout/{tryout_id}/calibrate
|
||||
```
|
||||
|
||||
2. **Admin Panel:**
|
||||
- Buka `/admin` → Tryouts → Pilih tryout → Trigger calibration
|
||||
|
||||
3. **Background Job (jika configured):**
|
||||
- Setelah enough responses terkumpul
|
||||
|
||||
---
|
||||
|
||||
## 6. Item Selection Modes
|
||||
|
||||
### 6.1 Fixed Selection
|
||||
|
||||
**Fixed** = Soal disajikan berurutan berdasarkan slot.
|
||||
|
||||
```python
|
||||
# Flow:
|
||||
1. Siswa mulai session
|
||||
2. Ambil item dengan slot=1 (urutan terendah)
|
||||
3. Setelah dijawab, ambil slot=2
|
||||
4. Lanjutkan sampai selesai
|
||||
```
|
||||
|
||||
**Karakteristik:**
|
||||
- Predictable, urutan soal tetap
|
||||
- Tidak butuh IRT calibration
|
||||
- Semua siswa dapat soal sama di posisi sama
|
||||
|
||||
### 6.2 Adaptive Selection (CAT)
|
||||
|
||||
**Adaptive** = Soal dipilih berdasarkan kemampuan siswa saat ini (theta).
|
||||
|
||||
```python
|
||||
# Flow:
|
||||
1. Siswa mulai session (θ = 0.0, default)
|
||||
2. Pilih item dengan b ≈ θ
|
||||
3. Siswa jawab → update θ
|
||||
4. Pilih item baru dengan b ≈ θ baru
|
||||
5. Ulangi sampai terminate condition
|
||||
```
|
||||
|
||||
**Karakteristik:**
|
||||
- Personalized, setiap siswa beda soal
|
||||
- Butuh item calibrated
|
||||
- Item selection pakai Fisher Information
|
||||
|
||||
#### Fisher Information Formula:
|
||||
|
||||
```python
|
||||
# Information at current theta
|
||||
I(θ) = P(θ) × (1 - P(θ))
|
||||
|
||||
# Di mana P(θ) = 1 / (1 + exp(-(θ - b)))
|
||||
|
||||
# Item dengan MAX information dipilih
|
||||
# Maximum information = item paling informatif untuk theta saat ini
|
||||
```
|
||||
|
||||
### 6.3 Hybrid Selection
|
||||
|
||||
**Hybrid** = Gabungan fixed + adaptive.
|
||||
|
||||
```python
|
||||
# Flow:
|
||||
1. Slot 1-N: Fixed selection (sequential)
|
||||
2. Setelah slot N: Switch ke adaptive selection
|
||||
3. Theta sudah ter-update dari fixed portion
|
||||
4. Adaptive portion pakai theta untuk pilih soal
|
||||
```
|
||||
|
||||
**Parameter:**
|
||||
- `hybrid_transition_slot` = Slot dimana switch ke adaptive
|
||||
|
||||
### 6.4 Perbandingan Selection Modes
|
||||
|
||||
| Mode | Butuh Calibration | Personalisasi | Predictable |
|
||||
|------|-------------------|---------------|-------------|
|
||||
| Fixed | Tidak | Tidak | Ya |
|
||||
| Adaptive | Ya | Ya | Tidak |
|
||||
| Hybrid | Parsial | Parsial | Parsial |
|
||||
|
||||
---
|
||||
|
||||
## 7. Student Session Flow
|
||||
|
||||
### 7.1 Full Student Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as Student
|
||||
participant API as FastAPI
|
||||
participant DB as Database
|
||||
|
||||
S->>API: POST /session/ (start session)
|
||||
API->>DB: Create session, θ=0.0
|
||||
DB-->>API: session_id
|
||||
API-->>S: session_id
|
||||
|
||||
loop For each question (adaptive/fixed/hybrid)
|
||||
S->>API: GET /session/{id}/next-item
|
||||
API->>DB: Query next item based on selection_mode
|
||||
DB-->>API: Item data
|
||||
API-->>S: Question
|
||||
|
||||
S->>API: POST /session/{id}/answer
|
||||
API->>API: Update θ (if adaptive)
|
||||
API->>DB: Save UserAnswer
|
||||
DB-->>API: Saved
|
||||
API-->>S: Ack + next question
|
||||
end
|
||||
|
||||
S->>API: POST /session/{id}/complete
|
||||
API->>API: Calculate NM, NN, final theta
|
||||
API->>DB: Update session
|
||||
DB-->>API: Updated
|
||||
API-->>S: Final scores
|
||||
```
|
||||
|
||||
### 7.2 Next-Item Selection Berdasarkan Mode
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SELECTION MODE = FIXED │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ SELECT * FROM items │
|
||||
│ WHERE tryout_id = ? │
|
||||
│ AND item.id NOT IN (answered_items) │
|
||||
│ ORDER BY slot ASC │
|
||||
│ LIMIT 1 │
|
||||
│ │
|
||||
│ Result: Item dengan slot terkecil yang belum dijawab │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SELECTION MODE = ADAPTIVE │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ current_theta = session.theta -- e.g., 0.5 │
|
||||
│ │
|
||||
│ SELECT * FROM items │
|
||||
│ WHERE tryout_id = ? │
|
||||
│ AND calibrated = TRUE │
|
||||
│ AND item.id NOT IN (answered_items) │
|
||||
│ ORDER BY ABS(irt_b - current_theta) ASC -- terdekat │
|
||||
│ LIMIT 1 │
|
||||
│ │
|
||||
│ Result: Item dengan b ≈ θ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Konfigurasi Tryout
|
||||
|
||||
### 8.1 Semua Opsi Konfigurasi
|
||||
|
||||
```python
|
||||
# Scoring
|
||||
scoring_mode = "ctt" # ctt, irt, hybrid
|
||||
scoring_mode = "irt" #
|
||||
scoring_mode = "hybrid" #
|
||||
|
||||
# Selection
|
||||
selection_mode = "fixed" # Sequential
|
||||
selection_mode = "adaptive" # CAT based on theta
|
||||
selection_mode = "hybrid" # Fixed until transition slot
|
||||
|
||||
# Normalization
|
||||
normalization_mode = "static" # Use static_rataan, static_sb
|
||||
normalization_mode = "dynamic" # Calculate from participant data
|
||||
normalization_mode = "hybrid" # Dynamic when min_sample reached
|
||||
|
||||
# IRT Settings
|
||||
min_calibration_sample = 100 # Min responses for calibration
|
||||
theta_estimation_method = "mle" # mle, map, eap
|
||||
fallback_to_ctt_on_error = True # Fallback if IRT fails
|
||||
|
||||
# Hybrid Settings
|
||||
hybrid_transition_slot = 10 # Switch to adaptive at slot 10
|
||||
|
||||
# AI Settings
|
||||
ai_generation_enabled = True # Allow AI generated items
|
||||
```
|
||||
|
||||
### 8.2 Cara Mengubah Konfigurasi
|
||||
|
||||
#### Via Database:
|
||||
```sql
|
||||
UPDATE tryouts
|
||||
SET
|
||||
scoring_mode = 'hybrid',
|
||||
selection_mode = 'adaptive',
|
||||
normalization_mode = 'dynamic'
|
||||
WHERE tryout_id = 'your-tryout-id';
|
||||
```
|
||||
|
||||
#### Via Admin Panel:
|
||||
1. Buka `/admin`
|
||||
2. Pilih menu **Tryouts**
|
||||
3. Edit tryout yang diinginkan
|
||||
4. Ubah field-field sesuai kebutuhan
|
||||
5. Save
|
||||
|
||||
---
|
||||
|
||||
## 9. Ringkasan Alur End-to-End
|
||||
|
||||
### 9.1 Admin Flow (Sekali / Periodik)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. IMPORT TRYOUT JSON │
|
||||
│ Input: File JSON (1 tryout = 1 exam) │
|
||||
│ Output: N items dalam database │
|
||||
│ │
|
||||
│ 2. AI GENERATE VARIANTS │
|
||||
│ Input: Item original │
|
||||
│ Output: Item variant (same slot, different content) │
|
||||
│ Result: 2N items (N slot × 2 variant) │
|
||||
│ │
|
||||
│ 3. COLLECT RESPONSE DATA │
|
||||
│ Input: Student answers │
|
||||
│ Output: UserAnswer records │
|
||||
│ │
|
||||
│ 4. IRT CALIBRATION │
|
||||
│ Input: Response data (min 100 students) │
|
||||
│ Output: Item.irt_b, Item.irt_se, Item.calibrated=True │
|
||||
│ │
|
||||
│ 5. CONFIGURE TRYOUT │
|
||||
│ Input: Set selection_mode = 'adaptive' │
|
||||
│ Output: Tryout siap untuk adaptive testing │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 9.2 Student Flow (Setiap Ujian)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. START SESSION │
|
||||
│ Input: tryout_id │
|
||||
│ Output: session_id, theta=0.0 │
|
||||
│ │
|
||||
│ 2. ANSWER LOOP │
|
||||
│ For each question: │
|
||||
│ - Get next item (based on selection_mode) │
|
||||
│ - Submit answer │
|
||||
│ - If adaptive: update theta │
|
||||
│ │
|
||||
│ 3. COMPLETE SESSION │
|
||||
│ Input: All answers │
|
||||
│ Output: NM, NN, theta, completion status │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 9.3 Konsep Kunci
|
||||
|
||||
| Konsep | Penjelasan |
|
||||
|--------|------------|
|
||||
| **Tryout** | 1 exam yang di-import dari JSON |
|
||||
| **Item** | 1 soal (original atau variant) |
|
||||
| **Slot** | Posisi nomor soal (1, 2, 3...) |
|
||||
| **Variant** | Soal berbeda di slot yang sama |
|
||||
| **Calibrated** | Item sudah punya irt_b (siap untuk adaptive) |
|
||||
| **Theta** | Estimasi kemampuan siswa dalam IRT scale |
|
||||
|
||||
---
|
||||
|
||||
## 10. FAQ
|
||||
|
||||
### Q: Kenapa default scoring_mode = "ctt"?
|
||||
A: CTT lebih simpel, tidak butuh IRT calibration. Cocok untuk awal sebelum cukup data.
|
||||
|
||||
### Q: Kenapa default selection_mode = "fixed"?
|
||||
A: Fixed selection tidak butuh item calibrated. Bisa jalan langsung setelah import.
|
||||
|
||||
### Q: Bagaimana switch ke adaptive?
|
||||
A:
|
||||
1. Pastikan item sudah calibrated (`calibrated = True`)
|
||||
2. Ubah `selection_mode = 'adaptive'` di tryout
|
||||
3. Student baru akan dapat adaptive selection
|
||||
|
||||
### Q: Adaptive butuh berapa banyak data?
|
||||
A: Default `min_calibration_sample = 100`. Artinya minimal 100 siswa harus sudah menjawab sebelum calibration bisa jalan.
|
||||
|
||||
### Q: CTT dan Fixed itu sama?
|
||||
A: Tidak. Mereka orthogonal:
|
||||
- **scoring_mode** = bagaimana menghitung score akhir
|
||||
- **selection_mode** = bagaimana memilih soal berikutnya
|
||||
|
||||
### Q: Aplikasi ini membuat exam?
|
||||
A: Tidak. Aplikasi ini adalah **question bank**. Exam sudah di-import dari JSON. Aplikasi "mengembangbiakkan" soal dengan membuat variants.
|
||||
|
||||
---
|
||||
|
||||
## 11. Referensi Code
|
||||
|
||||
| File | Fungsi |
|
||||
|------|--------|
|
||||
| `app/services/ctt_scoring.py` | CTT scoring calculations |
|
||||
| `app/services/irt_calibration.py` | IRT calibration, theta estimation |
|
||||
| `app/services/cat_selection.py` | Item selection (fixed/adaptive/hybrid) |
|
||||
| `app/services/ai_generation.py` | OpenRouter AI integration |
|
||||
| `app/services/excel_import.py` | Excel import/export |
|
||||
| `app/routers/sessions.py` | Session management API |
|
||||
| `app/models/tryout.py` | Tryout model definition |
|
||||
| `app/models/item.py` | Item model definition |
|
||||
| `app/models/session.py` | Session model definition |
|
||||
|
||||
---
|
||||
|
||||
*Document version: 1.0*
|
||||
*Last updated: 2026-06-15*
|
||||
0
backend/error.html
Normal file
0
backend/error.html
Normal file
135
backend/irt_1pl_mle.py
Normal file
135
backend/irt_1pl_mle.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
IRT 1PL (Rasch Model) Maximum Likelihood Estimation
|
||||
"""
|
||||
import numpy as np
|
||||
from scipy.optimize import minimize_scalar, minimize
|
||||
|
||||
|
||||
def estimate_theta(responses, b_params):
|
||||
"""
|
||||
Estimate student ability theta using MLE for 1PL IRT model.
|
||||
|
||||
Parameters:
|
||||
-----------
|
||||
responses : list or array
|
||||
Binary responses [0, 1, 1, 0, ...]
|
||||
b_params : list or array
|
||||
Item difficulty parameters [b1, b2, b3, ...]
|
||||
|
||||
Returns:
|
||||
--------
|
||||
float
|
||||
Estimated theta (ability), or None if estimation fails
|
||||
"""
|
||||
responses = np.asarray(responses, dtype=float)
|
||||
b_params = np.asarray(b_params, dtype=float)
|
||||
|
||||
# Edge case: empty or mismatched inputs
|
||||
if len(responses) == 0 or len(b_params) == 0:
|
||||
return 0.0
|
||||
if len(responses) != len(b_params):
|
||||
raise ValueError("responses and b_params must have same length")
|
||||
|
||||
n = len(responses)
|
||||
sum_resp = np.sum(responses)
|
||||
|
||||
# Edge case: all correct - return high theta
|
||||
if sum_resp == n:
|
||||
return 4.0
|
||||
|
||||
# Edge case: all incorrect - return low theta
|
||||
if sum_resp == 0:
|
||||
return -4.0
|
||||
|
||||
def neg_log_likelihood(theta):
|
||||
"""Negative log-likelihood for minimization."""
|
||||
exponent = theta - b_params
|
||||
# Numerical stability: clip exponent
|
||||
exponent = np.clip(exponent, -30, 30)
|
||||
p = 1.0 / (1.0 + np.exp(-exponent))
|
||||
# Avoid log(0)
|
||||
p = np.clip(p, 1e-10, 1 - 1e-10)
|
||||
ll = np.sum(responses * np.log(p) + (1 - responses) * np.log(1 - p))
|
||||
return -ll
|
||||
|
||||
result = minimize_scalar(neg_log_likelihood, bounds=(-6, 6), method='bounded')
|
||||
|
||||
if result.success:
|
||||
return float(result.x)
|
||||
return 0.0
|
||||
|
||||
|
||||
def estimate_b(responses_matrix):
|
||||
"""
|
||||
Estimate item difficulty parameters using joint MLE for 1PL IRT model.
|
||||
|
||||
Parameters:
|
||||
-----------
|
||||
responses_matrix : 2D array
|
||||
Response matrix where rows=students, cols=items
|
||||
entries are 0 or 1
|
||||
|
||||
Returns:
|
||||
--------
|
||||
numpy.ndarray
|
||||
Estimated b parameters for each item, or None if estimation fails
|
||||
"""
|
||||
responses_matrix = np.asarray(responses_matrix, dtype=float)
|
||||
|
||||
# Edge case: empty matrix
|
||||
if responses_matrix.size == 0:
|
||||
return np.array([])
|
||||
|
||||
if responses_matrix.ndim != 2:
|
||||
raise ValueError("responses_matrix must be 2-dimensional")
|
||||
|
||||
n_students, n_items = responses_matrix.shape
|
||||
|
||||
if n_students == 0 or n_items == 0:
|
||||
return np.zeros(n_items)
|
||||
|
||||
# Initialize theta and b
|
||||
theta = np.zeros(n_students)
|
||||
b = np.zeros(n_items)
|
||||
|
||||
# Check for items with all same responses
|
||||
item_sums = np.sum(responses_matrix, axis=0)
|
||||
|
||||
for iteration in range(20): # EM iterations
|
||||
# Update theta for each student
|
||||
for i in range(n_students):
|
||||
resp_i = responses_matrix[i, :]
|
||||
sum_resp = np.sum(resp_i)
|
||||
|
||||
if sum_resp == n_items:
|
||||
theta[i] = 4.0
|
||||
elif sum_resp == 0:
|
||||
theta[i] = -4.0
|
||||
else:
|
||||
def neg_ll_student(t):
|
||||
exponent = np.clip(t - b, -30, 30)
|
||||
p = np.clip(1.0 / (1.0 + np.exp(-exponent)), 1e-10, 1 - 1e-10)
|
||||
return -np.sum(resp_i * np.log(p) + (1 - resp_i) * np.log(1 - p))
|
||||
|
||||
res = minimize_scalar(neg_ll_student, bounds=(-6, 6), method='bounded')
|
||||
theta[i] = res.x if res.success else 0.0
|
||||
|
||||
# Update b for each item
|
||||
for j in range(n_items):
|
||||
resp_j = responses_matrix[:, j]
|
||||
sum_resp = np.sum(resp_j)
|
||||
|
||||
if sum_resp == n_students:
|
||||
b[j] = -4.0 # Easy item
|
||||
elif sum_resp == 0:
|
||||
b[j] = 4.0 # Hard item
|
||||
else:
|
||||
def neg_ll_item(bj):
|
||||
exponent = np.clip(theta - bj, -30, 30)
|
||||
p = np.clip(1.0 / (1.0 + np.exp(-exponent)), 1e-10, 1 - 1e-10)
|
||||
return -np.sum(resp_j * np.log(p) + (1 - resp_j) * np.log(1 - p))
|
||||
|
||||
res = minimize_scalar(neg_ll_item, bounds=(-6, 6), method='bounded')
|
||||
b[j] = res.x if res.success else 0.0
|
||||
|
||||
return b
|
||||
19
backend/patch_css.py
Normal file
19
backend/patch_css.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import re
|
||||
|
||||
with open("app/admin_web.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# Fix activity feed CSS
|
||||
content = content.replace(
|
||||
".activity-feed li:last-child {{ border-bottom: none; }}",
|
||||
".activity-feed li:last-child {{ border-bottom: none; }}\n .activity-feed li svg, .activity-feed li svg.nav-icon, .activity-feed li svg.huge-icon {{ width: 20px; height: 20px; flex-shrink: 0; }}"
|
||||
)
|
||||
|
||||
# Fix alert CSS
|
||||
content = content.replace(
|
||||
".alert-warning {{ background: #fef3c7; border: 1px solid #f59e0b; color: #92400e; }}",
|
||||
".alert svg, .alert svg.huge-icon, .alert svg.page-icon {{ width: 24px; height: 24px; flex-shrink: 0; margin-right: 4px; margin-bottom: -4px; }}\n .alert-warning {{ background: #fef3c7; border: 1px solid #f59e0b; color: #92400e; }}"
|
||||
)
|
||||
|
||||
with open("app/admin_web.py", "w") as f:
|
||||
f.write(content)
|
||||
17
backend/patch_icons.py
Normal file
17
backend/patch_icons.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import re
|
||||
|
||||
with open("app/admin_web_icons.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
new_mappings = """ "📈": ICON_TREND_UP,
|
||||
"📉": ICON_TREND_DOWN,
|
||||
"💡": ICON_LIGHTBULB,
|
||||
"👋": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="display:inline;width:28px;height:28px;margin-bottom:-4px;"><path stroke-linecap="round" stroke-linejoin="round" d="M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672ZM12 2.25V4.5m5.834.166-1.591 1.591M20.25 10.5H18M7.757 14.743l-1.59 1.59M6 10.5H3.75m4.007-4.243-1.59-1.59" /></svg>',
|
||||
"📊": ICON_REPORTS,
|
||||
"🚀": ICON_HUGE_ROCKET,
|
||||
"📈": ICON_TREND_UP,"""
|
||||
|
||||
content = content.replace(' "📈": ICON_TREND_UP,\n "📉": ICON_TREND_DOWN,', new_mappings)
|
||||
|
||||
with open("app/admin_web_icons.py", "w") as f:
|
||||
f.write(content)
|
||||
43
backend/requirements.txt
Normal file
43
backend/requirements.txt
Normal file
@@ -0,0 +1,43 @@
|
||||
# FastAPI and Server
|
||||
fastapi>=0.104.1
|
||||
uvicorn[standard]>=0.24.0
|
||||
python-multipart>=0.0.6
|
||||
|
||||
# Database
|
||||
sqlalchemy>=2.0.23
|
||||
asyncpg>=0.29.0
|
||||
alembic>=1.13.0
|
||||
|
||||
# Data & Validation
|
||||
pydantic>=2.5.0
|
||||
pydantic-settings>=2.1.0
|
||||
|
||||
# Excel Processing
|
||||
openpyxl>=3.1.2
|
||||
pandas>=2.1.4
|
||||
|
||||
# Math & Science
|
||||
numpy>=1.26.2
|
||||
scipy>=1.11.4
|
||||
|
||||
# AI Integration
|
||||
openai>=1.6.1
|
||||
httpx>=0.26.0
|
||||
|
||||
# Task Queue (for async jobs)
|
||||
celery>=5.3.6
|
||||
redis>=5.0.1
|
||||
|
||||
# Testing
|
||||
pytest>=7.4.3
|
||||
pytest-asyncio>=0.21.1
|
||||
httpx>=0.26.0
|
||||
|
||||
# Admin Panel
|
||||
fastapi-admin>=1.0.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Async support
|
||||
greenlet>=2.0.0
|
||||
66
backend/run_local.sh
Executable file
66
backend/run_local.sh
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
# Run local development server
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting IRT Bank Soal Local Dev Server"
|
||||
echo "=========================================="
|
||||
|
||||
# Check if Docker is available
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "❌ Docker not found. Please install Docker first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if docker-compose is available
|
||||
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
|
||||
echo "❌ Docker Compose not found. Please install Docker Compose first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use docker compose command (Docker Desktop includes it as a plugin)
|
||||
DOCKER_COMPOSE="docker compose"
|
||||
|
||||
# Start databases
|
||||
echo "📦 Starting PostgreSQL and Redis..."
|
||||
$DOCKER_COMPOSE -f docker-compose.dev.yml up -d postgres redis
|
||||
|
||||
# Wait for PostgreSQL to be ready
|
||||
echo "⏳ Waiting for PostgreSQL..."
|
||||
for i in {1..60}; do
|
||||
if docker exec yellow-bank-soal-postgres-1 pg_isready -U irt_user -d irt_bank_soal &> /dev/null 2>&1; then
|
||||
echo "✅ PostgreSQL is ready!"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 60 ]; then
|
||||
echo "❌ PostgreSQL failed to start"
|
||||
docker logs yellow-bank-soal-postgres-1
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Check if venv exists, create if not
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "📦 Creating Python virtual environment..."
|
||||
python3 -m venv venv
|
||||
fi
|
||||
|
||||
# Activate venv and install dependencies
|
||||
echo "📦 Installing dependencies..."
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt -q
|
||||
|
||||
# Run migrations
|
||||
echo "🔄 Running database migrations..."
|
||||
alembic upgrade head
|
||||
|
||||
# Start the dev server
|
||||
echo ""
|
||||
echo "🎉 Starting FastAPI dev server..."
|
||||
echo " Admin UI: http://localhost:8000/admin"
|
||||
echo " API Docs: http://localhost:8000/docs"
|
||||
echo " Login: admin / admin123"
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
275
backend/test_all_post_endpoints.py
Normal file
275
backend/test_all_post_endpoints.py
Normal file
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Comprehensive test of all form POST endpoints with proper authentication.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
|
||||
def login(client: httpx.Client) -> bool:
|
||||
"""Login and maintain session."""
|
||||
response = client.get("/admin/login")
|
||||
if response.status_code != 200:
|
||||
return False
|
||||
|
||||
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||
csrf_token = match.group(1) if match else ""
|
||||
|
||||
if not csrf_token:
|
||||
return False
|
||||
|
||||
response = client.post(
|
||||
"/admin/login",
|
||||
data={
|
||||
"username": "admin",
|
||||
"password": "admin123",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
return response.status_code == 200 and "/admin/dashboard" in str(response.url)
|
||||
|
||||
|
||||
def get_csrf_token(client: httpx.Client, page_url: str) -> str:
|
||||
"""Extract CSRF token from a page."""
|
||||
response = client.get(page_url)
|
||||
if response.status_code == 200:
|
||||
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
def test_endpoint(client: httpx.Client, name: str, url: str, data: dict) -> dict:
|
||||
"""Test a single POST endpoint."""
|
||||
csrf_token = get_csrf_token(client, url)
|
||||
|
||||
# Get the base URL (strip query params) for CSRF token extraction
|
||||
base_url = url.split("?")[0] if "?" in url else url
|
||||
|
||||
# If we're on a different page, get CSRF token from there
|
||||
if not csrf_token:
|
||||
# Try to get CSRF from dashboard if it's a subpage
|
||||
csrf_token = get_csrf_token(client, "/admin/dashboard")
|
||||
|
||||
if not csrf_token:
|
||||
return {
|
||||
"name": name,
|
||||
"status_code": None,
|
||||
"has_ise": False,
|
||||
"has_traceback": False,
|
||||
"error": "Could not get CSRF token",
|
||||
"response_preview": "",
|
||||
}
|
||||
|
||||
# Add CSRF token to data
|
||||
test_data = data.copy()
|
||||
test_data["csrf_token"] = csrf_token
|
||||
|
||||
response = client.post(
|
||||
url,
|
||||
data=test_data,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
has_ise = response.status_code == 500 or "Internal Server Error" in response.text
|
||||
has_traceback = "Traceback" in response.text
|
||||
|
||||
if has_traceback:
|
||||
idx = response.text.find("Traceback")
|
||||
traceback_text = response.text[idx : idx + 2000]
|
||||
print(f"\n ⚠️ TRACEBACK on {name}:")
|
||||
print(f" {traceback_text[:500]}...")
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"status_code": response.status_code,
|
||||
"has_ise": has_ise,
|
||||
"has_traceback": has_traceback,
|
||||
"error": None,
|
||||
"response_preview": response.text[:500],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 80)
|
||||
print("Testing All Form POST Endpoints for Internal Server Errors")
|
||||
print("=" * 80)
|
||||
|
||||
results = []
|
||||
|
||||
with httpx.Client(base_url=BASE_URL, timeout=60.0) as client:
|
||||
print("\nStep 1: Logging in...")
|
||||
if not login(client):
|
||||
print("❌ Login failed")
|
||||
return 1
|
||||
print("✅ Login successful")
|
||||
|
||||
# Test 1: Variant approval (with item ID 4)
|
||||
print("\nStep 2: Testing variant approval...")
|
||||
result = test_endpoint(
|
||||
client,
|
||||
"Variant approval (/admin/questions/4/generate/review-bulk)",
|
||||
"/admin/questions/4/generate?tab=review",
|
||||
{"item_ids": "4", "action": "approved", "tab": "review"},
|
||||
)
|
||||
results.append(result)
|
||||
print(
|
||||
f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}"
|
||||
)
|
||||
|
||||
# Test 2: Basis item review
|
||||
print("\nStep 3: Testing basis item review...")
|
||||
result = test_endpoint(
|
||||
client,
|
||||
"Basis item review (/admin/basis-items/4/review-bulk)",
|
||||
"/admin/basis-items/4",
|
||||
{"item_ids": "4", "action": "approved"},
|
||||
)
|
||||
results.append(result)
|
||||
print(
|
||||
f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}"
|
||||
)
|
||||
|
||||
# Test 3: Generate variants for question
|
||||
print("\nStep 4: Testing generate variants...")
|
||||
result = test_endpoint(
|
||||
client,
|
||||
"Generate variants (/admin/questions/4/generate)",
|
||||
"/admin/questions/4/generate?tab=generate",
|
||||
{
|
||||
"target_level": "mudah",
|
||||
"ai_model": "meta-llama/llama-4-maverick:free",
|
||||
"generation_count": "1",
|
||||
"operator_notes": "",
|
||||
"include_note_for_admin": "on",
|
||||
"include_note_in_prompt": "",
|
||||
},
|
||||
)
|
||||
results.append(result)
|
||||
print(
|
||||
f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}"
|
||||
)
|
||||
|
||||
# Test 5: Website creation
|
||||
print("\nStep 5: Testing website creation...")
|
||||
result = test_endpoint(
|
||||
client,
|
||||
"Website creation (/admin/websites)",
|
||||
"/admin/websites",
|
||||
{"site_name": "Test Site API", "site_url": "https://test-api.example.com"},
|
||||
)
|
||||
results.append(result)
|
||||
print(
|
||||
f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}"
|
||||
)
|
||||
|
||||
# Test 6: Website deletion (with test ID)
|
||||
print("\nStep 6: Testing website deletion...")
|
||||
# First create a website
|
||||
result_create = test_endpoint(
|
||||
client,
|
||||
"Create test website",
|
||||
"/admin/websites",
|
||||
{
|
||||
"site_name": "Delete Test Site",
|
||||
"site_url": "https://delete-test.example.com",
|
||||
},
|
||||
)
|
||||
|
||||
# Now delete it (using website ID 2 if exists)
|
||||
result = test_endpoint(
|
||||
client,
|
||||
"Website deletion (/admin/websites/2/delete)",
|
||||
"/admin/websites/2/delete",
|
||||
{},
|
||||
)
|
||||
results.append(result)
|
||||
print(
|
||||
f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}"
|
||||
)
|
||||
|
||||
# Test 7: Tryout import preview (without file - should get validation error not server error)
|
||||
print("\nStep 7: Testing tryout import preview...")
|
||||
result = test_endpoint(
|
||||
client,
|
||||
"Tryout import preview (/admin/tryout-import/preview)",
|
||||
"/admin/tryout-import",
|
||||
{"website_id": "1"},
|
||||
)
|
||||
results.append(result)
|
||||
print(f" Status: {result['status_code']} (validation error expected: 422)")
|
||||
|
||||
# Test 8: Snapshot promote bulk
|
||||
print("\nStep 8: Testing snapshot promote bulk...")
|
||||
result = test_endpoint(
|
||||
client,
|
||||
"Snapshot promote (/admin/snapshot-questions/promote-bulk)",
|
||||
"/admin/snapshot-questions",
|
||||
{"snapshot_id": "1", "snapshot_question_ids": ""},
|
||||
)
|
||||
results.append(result)
|
||||
print(
|
||||
f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}"
|
||||
)
|
||||
|
||||
# Test 9: AI generation basis item
|
||||
print("\nStep 9: Testing AI generation for basis item...")
|
||||
result = test_endpoint(
|
||||
client,
|
||||
"Basis item generate (/admin/basis-items/4/generate)",
|
||||
"/admin/basis-items/4",
|
||||
{
|
||||
"target_level": "mudah",
|
||||
"ai_model": "",
|
||||
"generation_count": "1",
|
||||
"operator_notes": "",
|
||||
},
|
||||
)
|
||||
results.append(result)
|
||||
print(
|
||||
f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}"
|
||||
)
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 80)
|
||||
print("RESULTS SUMMARY")
|
||||
print("=" * 80)
|
||||
|
||||
errors = []
|
||||
for result in results:
|
||||
if result.get("has_traceback"):
|
||||
errors.append(f"❌ {result['name']}: TRACEBACK")
|
||||
print(f"❌ {result['name']}: TRACEBACK")
|
||||
elif result.get("has_ise"):
|
||||
errors.append(f"❌ {result['name']}: INTERNAL SERVER ERROR")
|
||||
print(f"❌ {result['name']}: INTERNAL SERVER ERROR")
|
||||
elif result.get("error"):
|
||||
print(f"⚠️ {result['name']}: {result['error']}")
|
||||
elif result["status_code"] in [200, 303]:
|
||||
print(f"✅ {result['name']}: OK ({result['status_code']})")
|
||||
elif result["status_code"] == 422:
|
||||
print(f"✅ {result['name']}: Validation Error (expected)")
|
||||
else:
|
||||
print(f"⚠️ {result['name']}: Status {result['status_code']}")
|
||||
|
||||
print()
|
||||
if errors:
|
||||
print("❌ Some endpoints have INTERNAL SERVER ERRORS:")
|
||||
for error in errors:
|
||||
print(f" {error}")
|
||||
return 1
|
||||
else:
|
||||
print("✅ All form POST endpoints tested successfully!")
|
||||
print(" No Internal Server Errors detected.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
346
backend/test_all_routes.py
Normal file
346
backend/test_all_routes.py
Normal file
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test all routes in the IRT Bank Soal application.
|
||||
Tests each endpoint and checks for Internal Server Errors.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
# All routes from OpenAPI spec
|
||||
API_ROUTES = [
|
||||
# Root endpoints
|
||||
("GET", "/"),
|
||||
("GET", "/health"),
|
||||
# Session endpoints
|
||||
("POST", "/api/v1/session/"),
|
||||
("GET", "/api/v1/session/{session_id}"),
|
||||
("POST", "/api/v1/session/{session_id}/complete"),
|
||||
("GET", "/api/v1/session/{session_id}/next_item"),
|
||||
("POST", "/api/v1/session/{session_id}/submit_answer"),
|
||||
# Tryout endpoints
|
||||
("GET", "/api/v1/tryout/"),
|
||||
("GET", "/api/v1/tryout/{tryout_id}/config"),
|
||||
("PUT", "/api/v1/tryout/{tryout_id}/normalization"),
|
||||
("GET", "/api/v1/tryout/{tryout_id}/calibration-status"),
|
||||
("POST", "/api/v1/tryout/{tryout_id}/calibrate"),
|
||||
("POST", "/api/v1/tryout/{tryout_id}/calibrate/{item_id}"),
|
||||
# WordPress endpoints
|
||||
("POST", "/api/v1/wordpress/sync_users"),
|
||||
("POST", "/api/v1/wordpress/verify_session"),
|
||||
("GET", "/api/v1/wordpress/website/{website_id}/users"),
|
||||
("GET", "/api/v1/wordpress/website/{website_id}/user/{wp_user_id}"),
|
||||
# Reports endpoints
|
||||
("POST", "/api/v1/reports/schedule"),
|
||||
("GET", "/api/v1/reports/schedule/{schedule_id}"),
|
||||
("GET", "/api/v1/reports/schedules"),
|
||||
("DELETE", "/api/v1/reports/schedule/{schedule_id}"),
|
||||
("POST", "/api/v1/reports/schedule/{schedule_id}/export"),
|
||||
("GET", "/api/v1/reports/student/performance"),
|
||||
("GET", "/api/v1/reports/student/performance/export/{format}"),
|
||||
("GET", "/api/v1/reports/items/analysis"),
|
||||
("GET", "/api/v1/reports/items/analysis/export/{format}"),
|
||||
("GET", "/api/v1/reports/calibration/status"),
|
||||
("GET", "/api/v1/reports/calibration/status/export/{format}"),
|
||||
("GET", "/api/v1/reports/tryout/comparison"),
|
||||
("GET", "/api/v1/reports/tryout/comparison/export/{format}"),
|
||||
("GET", "/api/v1/reports/export/{schedule_id}/{format}"),
|
||||
# Import/Export endpoints
|
||||
("POST", "/api/v1/import-export/preview"),
|
||||
("POST", "/api/v1/import-export/questions"),
|
||||
("GET", "/api/v1/import-export/export/questions"),
|
||||
("POST", "/api/v1/import-export/tryout-json/preview"),
|
||||
("POST", "/api/v1/import-export/tryout-json"),
|
||||
# Admin AI endpoints
|
||||
("POST", "/api/v1/admin/ai/generate-preview"),
|
||||
("POST", "/api/v1/admin/ai/generate-save"),
|
||||
("GET", "/api/v1/admin/ai/stats"),
|
||||
("GET", "/api/v1/admin/ai/models"),
|
||||
# Admin endpoints
|
||||
("POST", "/api/v1/admin/{tryout_id}/calibrate"),
|
||||
("POST", "/api/v1/admin/{tryout_id}/toggle-ai-generation"),
|
||||
("POST", "/api/v1/admin/{tryout_id}/reset-normalization"),
|
||||
# Admin CAT endpoints
|
||||
("POST", "/api/v1/admin/cat/test"),
|
||||
("GET", "/api/v1/admin/session/{session_id}/status"),
|
||||
# Admin web routes (HTML pages)
|
||||
("GET", "/admin"),
|
||||
("GET", "/admin/login"),
|
||||
("POST", "/admin/login"),
|
||||
("POST", "/admin/logout"),
|
||||
("GET", "/admin/password"),
|
||||
("POST", "/admin/password"),
|
||||
("GET", "/admin/dashboard"),
|
||||
("GET", "/admin/questions"),
|
||||
("GET", "/admin/questions/{item_id}"),
|
||||
("GET", "/admin/questions/{item_id}/quality"),
|
||||
("GET", "/admin/exams"),
|
||||
("GET", "/admin/exams/{tryout_id}"),
|
||||
("GET", "/admin/reports"),
|
||||
("GET", "/admin/settings"),
|
||||
("GET", "/admin/hierarchy"),
|
||||
("GET", "/admin/websites"),
|
||||
("POST", "/admin/websites"),
|
||||
("GET", "/admin/websites/new"),
|
||||
("GET", "/admin/websites/{website_id}"),
|
||||
("POST", "/admin/websites/{website_id}"),
|
||||
("POST", "/admin/websites/{website_id}/delete"),
|
||||
("GET", "/admin/tryout-import"),
|
||||
("GET", "/admin/tryout-import/preview"),
|
||||
("POST", "/admin/tryout-import"),
|
||||
("GET", "/admin/snapshot-questions"),
|
||||
("POST", "/admin/snapshot-questions/promote-bulk"),
|
||||
("GET", "/admin/calibration-status"),
|
||||
("GET", "/admin/item-statistics"),
|
||||
("GET", "/admin/sessions"),
|
||||
("GET", "/admin/basis-items"),
|
||||
("GET", "/admin/basis-items/{item_id}"),
|
||||
("POST", "/admin/basis-items/{item_id}/generate"),
|
||||
("POST", "/admin/basis-items/{item_id}/generate/review-bulk"),
|
||||
("GET", "/admin/basis-items/{item_id}/generate/variants/{variant_id}"),
|
||||
]
|
||||
|
||||
# Placeholder values for path parameters
|
||||
PLACEHOLDERS = {
|
||||
"{session_id}": "test-session-123",
|
||||
"{tryout_id}": "test-tryout-123",
|
||||
"{item_id}": "1",
|
||||
"{website_id}": "1",
|
||||
"{wp_user_id}": "123",
|
||||
"{schedule_id}": "test-schedule-123",
|
||||
"{format}": "xlsx",
|
||||
"{variant_id}": "test-variant-123",
|
||||
}
|
||||
|
||||
# Minimal request bodies for POST endpoints
|
||||
REQUEST_BODIES = {
|
||||
"/api/v1/session/": {
|
||||
"session_id": "test",
|
||||
"tryout_id": "test",
|
||||
"wp_user_id": "123",
|
||||
"website_id": 1,
|
||||
"scoring_mode": "ctt",
|
||||
},
|
||||
"/api/v1/session/{session_id}/complete": {
|
||||
"end_time": "2024-01-01T00:00:00Z",
|
||||
"user_answers": [],
|
||||
},
|
||||
"/api/v1/session/{session_id}/submit_answer": {
|
||||
"item_id": 1,
|
||||
"response": "A",
|
||||
"time_spent": 10,
|
||||
},
|
||||
"/api/v1/tryout/{tryout_id}/normalization": {
|
||||
"normalization_mode": "static",
|
||||
"static_rataan": 500,
|
||||
"static_sb": 100,
|
||||
},
|
||||
"/api/v1/wordpress/sync_users": {}, # Requires proper auth header
|
||||
"/api/v1/wordpress/verify_session": {
|
||||
"website_id": 1,
|
||||
"wp_user_id": "123",
|
||||
"token": "test",
|
||||
},
|
||||
"/api/v1/reports/schedule": {
|
||||
"tryout_id": "test",
|
||||
"report_type": "student_performance",
|
||||
},
|
||||
"/api/v1/admin/ai/generate-preview": {
|
||||
"basis_item_id": 1,
|
||||
"target_level": "sulit",
|
||||
"ai_model": "qwen/qwen2.5-32b-instruct",
|
||||
},
|
||||
"/api/v1/admin/ai/generate-save": {
|
||||
"stem": "Test?",
|
||||
"options": {"A": "a", "B": "b", "C": "c", "D": "d"},
|
||||
"correct": "A",
|
||||
"tryout_id": "test",
|
||||
"website_id": 1,
|
||||
"basis_item_id": 1,
|
||||
"slot": 1,
|
||||
"level": "sulit",
|
||||
"ai_model": "qwen/qwen2.5-32b-instruct",
|
||||
},
|
||||
"/api/v1/admin/cat/test": {"tryout_id": "test", "website_id": 1},
|
||||
"/api/v1/admin/{tryout_id}/calibrate": {},
|
||||
"/api/v1/admin/{tryout_id}/toggle-ai-generation": {},
|
||||
"/api/v1/admin/{tryout_id}/reset-normalization": {},
|
||||
"/api/v1/import-export/preview": None, # Requires file upload
|
||||
"/api/v1/import-export/questions": None, # Requires file upload
|
||||
"/api/v1/import-export/tryout-json/preview": None, # Requires file upload
|
||||
"/api/v1/import-export/tryout-json": None, # Requires file upload
|
||||
}
|
||||
|
||||
|
||||
def expand_route(method: str, route: str) -> list:
|
||||
"""Expand route with placeholders."""
|
||||
expanded = []
|
||||
test_route = route
|
||||
for placeholder, value in PLACEHOLDERS.items():
|
||||
if placeholder in test_route:
|
||||
test_route = test_route.replace(placeholder, value)
|
||||
expanded.append((method, test_route))
|
||||
return expanded
|
||||
|
||||
|
||||
def test_route(client: httpx.Client, method: str, route: str) -> dict:
|
||||
"""Test a single route."""
|
||||
# Expand placeholders
|
||||
expanded = expand_route(method, route)
|
||||
if not expanded:
|
||||
return {
|
||||
"route": route,
|
||||
"method": method,
|
||||
"error": "Could not expand route",
|
||||
"status_code": None,
|
||||
}
|
||||
|
||||
method, test_route = expanded[0]
|
||||
|
||||
# Determine request body
|
||||
body = None
|
||||
request_body = REQUEST_BODIES.get(route, REQUEST_BODIES.get(test_route, {}))
|
||||
if request_body is not None:
|
||||
body = request_body
|
||||
|
||||
# Determine query params
|
||||
params = {}
|
||||
if "export/questions" in route:
|
||||
params = {"tryout_id": "test"}
|
||||
|
||||
headers = {"X-Website-ID": "1"}
|
||||
|
||||
try:
|
||||
response = client.request(
|
||||
method=method,
|
||||
url=BASE_URL + test_route,
|
||||
json=body if body and method in ["POST", "PUT", "PATCH"] else None,
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=10.0,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
is_500 = response.status_code == 500
|
||||
is_ise = "Internal Server Error" in response.text
|
||||
|
||||
return {
|
||||
"route": route,
|
||||
"method": method,
|
||||
"expanded_route": test_route,
|
||||
"status_code": response.status_code,
|
||||
"has_500": is_500,
|
||||
"has_ise": is_ise,
|
||||
"response_preview": response.text[:200] if response.text else "",
|
||||
"error": None,
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
return {
|
||||
"route": route,
|
||||
"method": method,
|
||||
"expanded_route": test_route,
|
||||
"status_code": None,
|
||||
"has_500": False,
|
||||
"has_ise": False,
|
||||
"response_preview": "",
|
||||
"error": "Timeout",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"route": route,
|
||||
"method": method,
|
||||
"expanded_route": test_route,
|
||||
"status_code": None,
|
||||
"has_500": False,
|
||||
"has_ise": False,
|
||||
"response_preview": "",
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 80)
|
||||
print("Testing all IRT Bank Soal routes for Internal Server Errors")
|
||||
print("=" * 80)
|
||||
print()
|
||||
|
||||
results = []
|
||||
has_errors = False
|
||||
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
for method, route in API_ROUTES:
|
||||
result = test_route(client, method, route)
|
||||
results.append(result)
|
||||
|
||||
status = result["status_code"]
|
||||
error_marker = ""
|
||||
|
||||
if result["error"]:
|
||||
error_marker = f" [ERROR: {result['error']}]"
|
||||
has_errors = True
|
||||
elif status and status >= 500:
|
||||
error_marker = f" [INTERNAL SERVER ERROR!]"
|
||||
has_errors = True
|
||||
elif status and status == 500:
|
||||
error_marker = f" [500 - INTERNAL SERVER ERROR!]"
|
||||
has_errors = True
|
||||
elif "Internal Server Error" in str(result.get("response_preview", "")):
|
||||
error_marker = " [500 - INTERNAL SERVER ERROR!]"
|
||||
has_errors = True
|
||||
|
||||
status_str = str(status) if status else "N/A"
|
||||
print(f"{method:6} {route:<60} -> {status_str}{error_marker}")
|
||||
|
||||
print()
|
||||
print("=" * 80)
|
||||
print("SUMMARY")
|
||||
print("=" * 80)
|
||||
|
||||
total = len(results)
|
||||
successful = sum(1 for r in results if r["status_code"] and r["status_code"] < 500)
|
||||
client_errors = sum(
|
||||
1 for r in results if r["status_code"] and 400 <= r["status_code"] < 500
|
||||
)
|
||||
server_errors = sum(
|
||||
1 for r in results if r["status_code"] and r["status_code"] >= 500
|
||||
)
|
||||
timeouts = sum(1 for r in results if r["error"] == "Timeout")
|
||||
exceptions = sum(1 for r in results if r["error"] and r["error"] != "Timeout")
|
||||
ise_errors = sum(1 for r in results if r.get("has_ise") or r.get("has_500"))
|
||||
|
||||
print(f"Total routes tested: {total}")
|
||||
print(f"Successful (2xx): {successful}")
|
||||
print(f"Client errors (4xx): {client_errors}")
|
||||
print(f"Server errors (5xx): {server_errors}")
|
||||
print(f"Timeouts: {timeouts}")
|
||||
print(f"Exceptions: {exceptions}")
|
||||
print(f"Internal Server Errors: {ise_errors}")
|
||||
print()
|
||||
|
||||
if has_errors:
|
||||
print("Routes with issues:")
|
||||
for r in results:
|
||||
if r["status_code"] and r["status_code"] >= 500:
|
||||
print(f" - {r['method']} {r['route']} -> {r['status_code']}")
|
||||
elif r["error"]:
|
||||
print(f" - {r['method']} {r['route']} -> ERROR: {r['error']}")
|
||||
elif r.get("has_ise"):
|
||||
print(f" - {r['method']} {r['route']} -> Internal Server Error")
|
||||
|
||||
print()
|
||||
if ise_errors == 0 and exceptions == 0:
|
||||
print("✅ All routes passed! No Internal Server Errors detected.")
|
||||
return 0
|
||||
else:
|
||||
print("❌ Some routes have issues. Please review the output above.")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
50
backend/test_debug_login.py
Normal file
50
backend/test_debug_login.py
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Debug login issue.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
|
||||
def main():
|
||||
print("Debugging login issue...")
|
||||
|
||||
with httpx.Client(base_url=BASE_URL, timeout=30.0) as client:
|
||||
# Get login page
|
||||
response = client.get("/admin/login")
|
||||
print(f"Login page status: {response.status_code}")
|
||||
|
||||
# Extract CSRF token
|
||||
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||
csrf_token = match.group(1) if match else ""
|
||||
print(f"CSRF token: {csrf_token[:30]}...")
|
||||
|
||||
# Look for any error messages in the page
|
||||
if "error" in response.text.lower():
|
||||
print("\n=== Error messages in login page ===")
|
||||
# Extract error div content
|
||||
error_match = re.search(
|
||||
r'<div class="error">(.*?)</div>', response.text, re.DOTALL
|
||||
)
|
||||
if error_match:
|
||||
print(error_match.group(1))
|
||||
else:
|
||||
# Print a portion of the page around "error"
|
||||
idx = response.text.lower().find("error")
|
||||
print(response.text[max(0, idx - 50) : idx + 200])
|
||||
|
||||
# Try to check if Redis is accessible via the health endpoint
|
||||
health = client.get("/health")
|
||||
print(f"\nHealth check: {health.text}")
|
||||
|
||||
# Print login page content for inspection
|
||||
print("\n=== Login page content (first 2000 chars) ===")
|
||||
print(response.text[:2000])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
73
backend/test_debug_login2.py
Normal file
73
backend/test_debug_login2.py
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Debug login issue - check Redis.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
|
||||
def main():
|
||||
print("Debugging login issue - detailed...")
|
||||
|
||||
with httpx.Client(base_url=BASE_URL, timeout=30.0) as client:
|
||||
# Get login page
|
||||
response = client.get("/admin/login")
|
||||
print(f"Login page status: {response.status_code}")
|
||||
|
||||
# Extract CSRF token
|
||||
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||
csrf_token = match.group(1) if match else ""
|
||||
print(f"CSRF token: {csrf_token}")
|
||||
|
||||
# Print ALL cookies
|
||||
print(f"\nCookies before login: {dict(client.cookies)}")
|
||||
|
||||
# Submit login
|
||||
response = client.post(
|
||||
"/admin/login",
|
||||
data={
|
||||
"username": "admin",
|
||||
"password": "admin123",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=False, # Don't follow redirect to see the response
|
||||
)
|
||||
|
||||
print(f"\nLogin response status: {response.status_code}")
|
||||
print(f"Login response headers: {dict(response.headers)}")
|
||||
print(f"Cookies after login: {dict(client.cookies)}")
|
||||
|
||||
# Check if response has any content
|
||||
print(f"\nLogin response content (first 1000 chars):")
|
||||
print(response.text[:1000])
|
||||
|
||||
# Now try with a redirect follow
|
||||
print("\n\n=== Trying with redirect follow ===")
|
||||
client2 = httpx.Client(base_url=BASE_URL, timeout=30.0)
|
||||
|
||||
response = client2.get("/admin/login")
|
||||
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||
csrf_token = match.group(1) if match else ""
|
||||
|
||||
response = client2.post(
|
||||
"/admin/login",
|
||||
data={
|
||||
"username": "admin",
|
||||
"password": "admin123",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
print(f"Final status after redirect: {response.status_code}")
|
||||
print(f"Final URL: {response.url}")
|
||||
print(f"Final cookies: {dict(client2.cookies)}")
|
||||
print(f"Final content (first 500 chars): {response.text[:500]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
142
backend/test_debug_traceback.py
Normal file
142
backend/test_debug_traceback.py
Normal file
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Debug the 500 Internal Server Error on variant approval - fixed CSRF.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
|
||||
def login(client: httpx.Client) -> bool:
|
||||
"""Login and maintain session."""
|
||||
response = client.get("/admin/login")
|
||||
if response.status_code != 200:
|
||||
return False
|
||||
|
||||
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||
csrf_token = match.group(1) if match else ""
|
||||
|
||||
if not csrf_token:
|
||||
return False
|
||||
|
||||
response = client.post(
|
||||
"/admin/login",
|
||||
data={
|
||||
"username": "admin",
|
||||
"password": "admin123",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
return response.status_code == 200 and "/admin/dashboard" in str(response.url)
|
||||
|
||||
|
||||
def get_csrf_from_page(client: httpx.Client, page_url: str) -> tuple:
|
||||
"""Get CSRF token from a specific page and return both token and response."""
|
||||
response = client.get(page_url, follow_redirects=True)
|
||||
if response.status_code == 200:
|
||||
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||
if match:
|
||||
return match.group(1), response
|
||||
return "", response
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 80)
|
||||
print("Debugging 500 Internal Server Error on Variant Approval")
|
||||
print("=" * 80)
|
||||
|
||||
with httpx.Client(base_url=BASE_URL, timeout=60.0) as client:
|
||||
print("\n1. Logging in...")
|
||||
if not login(client):
|
||||
print(" ❌ Login failed")
|
||||
return
|
||||
print(" ✅ Login successful")
|
||||
|
||||
# Test 1: Variant approval - get CSRF from the actual review page
|
||||
print("\n2. Testing variant approval...")
|
||||
|
||||
# First access the review page to get the CSRF token
|
||||
csrf_token, page_response = get_csrf_from_page(
|
||||
client, "/admin/questions/4/generate?tab=review"
|
||||
)
|
||||
print(f" Page URL: {page_response.url}")
|
||||
print(f" Page status: {page_response.status_code}")
|
||||
print(f" CSRF token: {csrf_token[:30] if csrf_token else 'None'}...")
|
||||
|
||||
# If we got redirected, we can't test this endpoint
|
||||
if "/generate" not in str(page_response.url):
|
||||
print(
|
||||
" ⚠️ Redirected away from AI playground - item may not exist or not be AI-generated"
|
||||
)
|
||||
print(" Skipping this test...")
|
||||
else:
|
||||
# Submit the form
|
||||
response = client.post(
|
||||
"/admin/questions/4/generate/review-bulk",
|
||||
data={
|
||||
"item_ids": "4",
|
||||
"action": "approved",
|
||||
"tab": "review",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
print(f" Response status: {response.status_code}")
|
||||
|
||||
# Extract and print the full traceback
|
||||
if "Traceback" in response.text:
|
||||
idx = response.text.find("Traceback")
|
||||
print("\n" + "=" * 80)
|
||||
print("FULL TRACEBACK:")
|
||||
print("=" * 80)
|
||||
print(response.text[idx:])
|
||||
print("=" * 80)
|
||||
elif response.status_code == 500:
|
||||
print("\n ⚠️ Got 500 error but no traceback in response")
|
||||
print(f" Response preview: {response.text[:500]}")
|
||||
else:
|
||||
print(f" Response preview: {response.text[:500]}")
|
||||
|
||||
# Test 2: Generate variants
|
||||
print("\n3. Testing generate variants...")
|
||||
|
||||
csrf_token, page_response = get_csrf_from_page(
|
||||
client, "/admin/questions/4/generate?tab=generate"
|
||||
)
|
||||
print(f" Page URL: {page_response.url}")
|
||||
print(f" Page status: {page_response.status_code}")
|
||||
|
||||
if "/generate" not in str(page_response.url):
|
||||
print(" ⚠️ Redirected away from AI playground")
|
||||
else:
|
||||
response = client.post(
|
||||
"/admin/questions/4/generate",
|
||||
data={
|
||||
"target_level": "mudah",
|
||||
"ai_model": "meta-llama/llama-4-maverick:free",
|
||||
"generation_count": "1",
|
||||
"operator_notes": "",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
print(f" Response status: {response.status_code}")
|
||||
|
||||
if "Traceback" in response.text:
|
||||
idx = response.text.find("Traceback")
|
||||
print("\n" + "=" * 80)
|
||||
print("FULL TRACEBACK:")
|
||||
print("=" * 80)
|
||||
print(response.text[idx:])
|
||||
print("=" * 80)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
8
backend/test_error.py
Normal file
8
backend/test_error.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import asyncio
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/admin/hierarchy")
|
||||
print(response.status_code)
|
||||
print(response.text)
|
||||
9
backend/test_fetch.py
Normal file
9
backend/test_fetch.py
Normal file
@@ -0,0 +1,9 @@
|
||||
import asyncio
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
client.post("/admin/login", data={"username": "admin", "password": "password"})
|
||||
response = client.get("/admin/hierarchy")
|
||||
print(response.status_code)
|
||||
print(response.text)
|
||||
404
backend/test_form_posts.py
Normal file
404
backend/test_form_posts.py
Normal file
@@ -0,0 +1,404 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test all form POST endpoints for Internal Server Errors.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
# All form POST endpoints from admin_web.py
|
||||
FORM_POST_ENDPOINTS = [
|
||||
# (endpoint, method, form_data, description)
|
||||
(
|
||||
"/admin/login",
|
||||
"POST",
|
||||
{"username": "admin", "password": "admin123"},
|
||||
"Admin login",
|
||||
),
|
||||
(
|
||||
"/admin/password",
|
||||
"POST",
|
||||
{
|
||||
"old_password": "admin123",
|
||||
"new_password": "admin123",
|
||||
"re_new_password": "admin123",
|
||||
},
|
||||
"Change password",
|
||||
),
|
||||
(
|
||||
"/admin/websites",
|
||||
"POST",
|
||||
{
|
||||
"site_name": "Test Site",
|
||||
"site_url": "https://test.example.com",
|
||||
},
|
||||
"Create website",
|
||||
),
|
||||
(
|
||||
"/admin/websites/1/edit",
|
||||
"POST",
|
||||
{
|
||||
"site_name": "Updated Test Site",
|
||||
"site_url": "https://updated.example.com",
|
||||
},
|
||||
"Edit website",
|
||||
),
|
||||
("/admin/websites/1/delete", "POST", {}, "Delete website"),
|
||||
(
|
||||
"/admin/tryout-import/preview",
|
||||
"POST",
|
||||
{
|
||||
"website_id": "1",
|
||||
},
|
||||
"Tryout import preview (no file)",
|
||||
),
|
||||
(
|
||||
"/admin/tryout-import",
|
||||
"POST",
|
||||
{
|
||||
"website_id": "1",
|
||||
"preview_token": "invalid-token",
|
||||
},
|
||||
"Tryout import submit",
|
||||
),
|
||||
(
|
||||
"/admin/snapshot-questions/promote-bulk",
|
||||
"POST",
|
||||
{
|
||||
"snapshot_id": "1",
|
||||
"snapshot_question_ids": [],
|
||||
},
|
||||
"Promote snapshot questions bulk",
|
||||
),
|
||||
(
|
||||
"/admin/basis-items/1/generate",
|
||||
"POST",
|
||||
{
|
||||
"target_level": "mudah",
|
||||
"ai_model": "",
|
||||
"generation_count": "1",
|
||||
"operator_notes": "",
|
||||
},
|
||||
"Generate variants for basis item",
|
||||
),
|
||||
(
|
||||
"/admin/basis-items/1/review-bulk",
|
||||
"POST",
|
||||
{
|
||||
"item_ids": ["1"],
|
||||
"action": "approved",
|
||||
},
|
||||
"Review bulk variants",
|
||||
),
|
||||
(
|
||||
"/admin/questions/1/generate",
|
||||
"POST",
|
||||
{
|
||||
"target_level": "mudah",
|
||||
"ai_model": "meta-llama/llama-4-maverick:free",
|
||||
"generation_count": "1",
|
||||
"operator_notes": "",
|
||||
"include_note_for_admin": True,
|
||||
"include_note_in_prompt": False,
|
||||
},
|
||||
"Generate question variants",
|
||||
),
|
||||
(
|
||||
"/admin/questions/1/generate/review-bulk",
|
||||
"POST",
|
||||
{
|
||||
"item_ids": ["1"],
|
||||
"action": "approved",
|
||||
"tab": "review",
|
||||
},
|
||||
"Review question variants bulk",
|
||||
),
|
||||
]
|
||||
|
||||
# API POST endpoints
|
||||
API_POST_ENDPOINTS = [
|
||||
(
|
||||
"/api/v1/session/",
|
||||
{
|
||||
"session_id": "test-session-123",
|
||||
"tryout_id": "test",
|
||||
"wp_user_id": "123",
|
||||
"website_id": 1,
|
||||
"scoring_mode": "ctt",
|
||||
},
|
||||
"Create session",
|
||||
),
|
||||
(
|
||||
"/api/v1/session/test-session-123/complete",
|
||||
{
|
||||
"end_time": "2024-01-01T00:00:00Z",
|
||||
"user_answers": [],
|
||||
},
|
||||
"Complete session",
|
||||
),
|
||||
(
|
||||
"/api/v1/session/test-session-123/submit_answer",
|
||||
{
|
||||
"item_id": 1,
|
||||
"response": "A",
|
||||
"time_spent": 10,
|
||||
},
|
||||
"Submit answer",
|
||||
),
|
||||
(
|
||||
"/api/v1/wordpress/verify_session",
|
||||
{
|
||||
"website_id": 1,
|
||||
"wp_user_id": "123",
|
||||
"token": "test",
|
||||
},
|
||||
"Verify WordPress session",
|
||||
),
|
||||
(
|
||||
"/api/v1/reports/schedule",
|
||||
{
|
||||
"tryout_id": "test",
|
||||
"report_type": "student_performance",
|
||||
},
|
||||
"Schedule report",
|
||||
),
|
||||
(
|
||||
"/api/v1/admin/cat/test",
|
||||
{
|
||||
"tryout_id": "test",
|
||||
"website_id": 1,
|
||||
},
|
||||
"Test CAT algorithm",
|
||||
),
|
||||
("/api/v1/admin/1/calibrate", {}, "Calibrate tryout"),
|
||||
("/api/v1/admin/1/toggle-ai-generation", {}, "Toggle AI generation"),
|
||||
("/api/v1/admin/1/reset-normalization", {}, "Reset normalization"),
|
||||
]
|
||||
|
||||
|
||||
def get_admin_session():
|
||||
"""Login and get session cookies for admin access."""
|
||||
with httpx.Client(base_url=BASE_URL, follow_redirects=True, timeout=30.0) as client:
|
||||
# Try to login
|
||||
response = client.post(
|
||||
"/admin/login",
|
||||
data={
|
||||
"username": "admin",
|
||||
"password": "admin123",
|
||||
},
|
||||
)
|
||||
print(f"Login response: {response.status_code}")
|
||||
|
||||
# Check if we have admin access
|
||||
response = client.get("/admin")
|
||||
print(f"Admin page response: {response.status_code}")
|
||||
|
||||
# Return cookies
|
||||
return client.cookies
|
||||
|
||||
|
||||
def test_endpoint(
|
||||
client: httpx.Client, endpoint: str, method: str, data: dict, cookies: dict = None
|
||||
) -> dict:
|
||||
"""Test a single endpoint."""
|
||||
headers = {"X-Website-ID": "1"}
|
||||
|
||||
try:
|
||||
if method == "POST":
|
||||
# Check if this looks like form data or JSON
|
||||
if isinstance(data, dict) and all(
|
||||
isinstance(v, str) or v is None for v in data.values()
|
||||
):
|
||||
# Form data
|
||||
response = client.post(
|
||||
endpoint,
|
||||
data=data,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
timeout=30.0,
|
||||
follow_redirects=True,
|
||||
)
|
||||
else:
|
||||
# JSON data
|
||||
response = client.post(
|
||||
endpoint,
|
||||
json=data,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
timeout=30.0,
|
||||
follow_redirects=True,
|
||||
)
|
||||
else:
|
||||
response = client.request(
|
||||
method,
|
||||
endpoint,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
timeout=30.0,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
# Check for internal server error
|
||||
has_ise = (
|
||||
response.status_code == 500
|
||||
or "Internal Server Error" in response.text
|
||||
or "500 Internal Server Error" in response.text
|
||||
)
|
||||
|
||||
# Check for traceback
|
||||
has_traceback = "Traceback" in response.text
|
||||
|
||||
return {
|
||||
"endpoint": endpoint,
|
||||
"method": method,
|
||||
"status_code": response.status_code,
|
||||
"has_ise": has_ise,
|
||||
"has_traceback": has_traceback,
|
||||
"response_preview": response.text[:500] if response.text else "",
|
||||
"redirect_location": response.headers.get("location", ""),
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
return {
|
||||
"endpoint": endpoint,
|
||||
"method": method,
|
||||
"status_code": None,
|
||||
"has_ise": False,
|
||||
"has_traceback": False,
|
||||
"response_preview": "",
|
||||
"error": "Timeout",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"endpoint": endpoint,
|
||||
"method": method,
|
||||
"status_code": None,
|
||||
"has_ise": False,
|
||||
"has_traceback": False,
|
||||
"response_preview": "",
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 80)
|
||||
print("Testing all Form POST endpoints for Internal Server Errors")
|
||||
print("=" * 80)
|
||||
print()
|
||||
|
||||
# Get admin session
|
||||
print("Getting admin session...")
|
||||
cookies = get_admin_session()
|
||||
print()
|
||||
|
||||
results = []
|
||||
has_errors = False
|
||||
|
||||
with httpx.Client(base_url=BASE_URL, follow_redirects=True, timeout=30.0) as client:
|
||||
# Test admin form POST endpoints
|
||||
print("-" * 80)
|
||||
print("ADMIN FORM POST ENDPOINTS")
|
||||
print("-" * 80)
|
||||
|
||||
for endpoint, method, data, description in FORM_POST_ENDPOINTS:
|
||||
print(f"\nTesting: {description}")
|
||||
print(f" Endpoint: {endpoint}")
|
||||
|
||||
result = test_endpoint(client, endpoint, method, data, cookies)
|
||||
results.append((description, result))
|
||||
|
||||
status = result["status_code"]
|
||||
error_details = ""
|
||||
|
||||
if result.get("error"):
|
||||
error_details = f" [ERROR: {result['error']}]"
|
||||
has_errors = True
|
||||
elif result.get("has_traceback"):
|
||||
error_details = f" [TRACEBACK!]"
|
||||
has_errors = True
|
||||
print(f" Response: {result['response_preview'][:1000]}")
|
||||
elif result.get("has_ise"):
|
||||
error_details = f" [INTERNAL SERVER ERROR!]"
|
||||
has_errors = True
|
||||
print(f" Response: {result['response_preview'][:1000]}")
|
||||
|
||||
status_str = str(status) if status else "N/A"
|
||||
print(f" Status: {status_str}{error_details}")
|
||||
|
||||
if result.get("redirect_location"):
|
||||
print(f" Redirect: {result['redirect_location']}")
|
||||
|
||||
# Test API POST endpoints
|
||||
print()
|
||||
print("-" * 80)
|
||||
print("API POST ENDPOINTS")
|
||||
print("-" * 80)
|
||||
|
||||
for endpoint, data, description in API_POST_ENDPOINTS:
|
||||
print(f"\nTesting: {description}")
|
||||
print(f" Endpoint: {endpoint}")
|
||||
|
||||
result = test_endpoint(client, endpoint, "POST", data, cookies)
|
||||
results.append((description, result))
|
||||
|
||||
status = result["status_code"]
|
||||
error_details = ""
|
||||
|
||||
if result.get("error"):
|
||||
error_details = f" [ERROR: {result['error']}]"
|
||||
has_errors = True
|
||||
elif result.get("has_traceback"):
|
||||
error_details = f" [TRACEBACK!]"
|
||||
has_errors = True
|
||||
print(f" Response: {result['response_preview'][:1000]}")
|
||||
elif result.get("has_ise"):
|
||||
error_details = f" [INTERNAL SERVER ERROR!]"
|
||||
has_errors = True
|
||||
print(f" Response: {result['response_preview'][:1000]}")
|
||||
|
||||
status_str = str(status) if status else "N/A"
|
||||
print(f" Status: {status_str}{error_details}")
|
||||
|
||||
# Summary
|
||||
print()
|
||||
print("=" * 80)
|
||||
print("SUMMARY")
|
||||
print("=" * 80)
|
||||
|
||||
total = len(results)
|
||||
ise_errors = sum(1 for _, r in results if r.get("has_ise"))
|
||||
tracebacks = sum(1 for _, r in results if r.get("has_traceback"))
|
||||
timeouts = sum(1 for _, r in results if r.get("error") == "Timeout")
|
||||
exceptions = sum(
|
||||
1 for _, r in results if r.get("error") and r.get("error") != "Timeout"
|
||||
)
|
||||
|
||||
print(f"Total endpoints tested: {total}")
|
||||
print(f"Internal Server Errors: {ise_errors}")
|
||||
print(f"Tracebacks: {tracebacks}")
|
||||
print(f"Timeouts: {timeouts}")
|
||||
print(f"Exceptions: {exceptions}")
|
||||
print()
|
||||
|
||||
if ise_errors > 0 or tracebacks > 0:
|
||||
print("Endpoints with issues:")
|
||||
for desc, r in results:
|
||||
if r.get("has_ise") or r.get("has_traceback"):
|
||||
print(f" - {desc}: {r['endpoint']} -> {r['status_code']}")
|
||||
if r.get("has_traceback"):
|
||||
print(f" Traceback detected in response")
|
||||
|
||||
print()
|
||||
if has_errors:
|
||||
print("❌ Some endpoints have issues. Please review the output above.")
|
||||
return 1
|
||||
else:
|
||||
print("✅ All endpoints passed! No Internal Server Errors detected.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
68
backend/test_session_debug.py
Normal file
68
backend/test_session_debug.py
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Debug redirect on AI playground page.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
|
||||
def main():
|
||||
print("Debugging redirect on AI playground page...")
|
||||
|
||||
with httpx.Client(base_url=BASE_URL, timeout=30.0) as client:
|
||||
# Login first
|
||||
response = client.get("/admin/login")
|
||||
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||
csrf_token = match.group(1) if match else ""
|
||||
|
||||
response = client.post(
|
||||
"/admin/login",
|
||||
data={
|
||||
"username": "admin",
|
||||
"password": "admin123",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
print(f"Logged in, URL: {response.url}")
|
||||
|
||||
# Get AI playground page without following redirects
|
||||
print("\nGetting AI playground page without following redirects...")
|
||||
response = client.get(
|
||||
"/admin/questions/1/generate?tab=review", follow_redirects=False
|
||||
)
|
||||
print(f"Status: {response.status_code}")
|
||||
print(f"Location header: {response.headers.get('location', 'None')}")
|
||||
|
||||
# Follow the redirect
|
||||
if response.headers.get("location"):
|
||||
redirect_url = response.headers["location"]
|
||||
print(f"\nFollowing redirect to: {redirect_url}")
|
||||
response = client.get(redirect_url, follow_redirects=True)
|
||||
print(f"Final status: {response.status_code}")
|
||||
print(f"Final URL: {response.url}")
|
||||
|
||||
# Check for forms
|
||||
post_forms = re.findall(
|
||||
r'<form[^>]*method="post"[^>]*>', response.text, re.IGNORECASE
|
||||
)
|
||||
print(f"\nFound {len(post_forms)} POST forms")
|
||||
|
||||
# Look for CSRF token
|
||||
csrf_inputs = re.findall(
|
||||
r'<input[^>]*name="csrf_token"[^>]*>', response.text, re.IGNORECASE
|
||||
)
|
||||
if csrf_inputs:
|
||||
print(f"Found {len(csrf_inputs)} CSRF token inputs:")
|
||||
for inp in csrf_inputs[:3]:
|
||||
print(f" {inp}")
|
||||
else:
|
||||
print("No CSRF token inputs found")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
374
backend/test_variant_approval.py
Normal file
374
backend/test_variant_approval.py
Normal file
@@ -0,0 +1,374 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test variant approval endpoints with proper session handling.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
|
||||
def get_csrf_token(client: httpx.Client, page_url: str) -> str:
|
||||
"""Extract CSRF token from a page."""
|
||||
try:
|
||||
response = client.get(page_url)
|
||||
if response.status_code == 200:
|
||||
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except Exception as e:
|
||||
print(f" Error getting CSRF token from {page_url}: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def login(client: httpx.Client) -> bool:
|
||||
"""Login and maintain session."""
|
||||
# Get login page
|
||||
response = client.get("/admin/login")
|
||||
if response.status_code != 200:
|
||||
print(f" Failed to get login page: {response.status_code}")
|
||||
return False
|
||||
|
||||
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||
csrf_token = match.group(1) if match else ""
|
||||
|
||||
if not csrf_token:
|
||||
print(" Failed to get CSRF token")
|
||||
return False
|
||||
|
||||
# Submit login - follow redirects to complete login
|
||||
response = client.post(
|
||||
"/admin/login",
|
||||
data={
|
||||
"username": "admin",
|
||||
"password": "admin123",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
if response.status_code == 200 and "/admin/dashboard" in str(response.url):
|
||||
print(" ✅ Successfully logged in!")
|
||||
return True
|
||||
|
||||
print(f" Login failed: {response.status_code}, URL: {response.url}")
|
||||
return False
|
||||
|
||||
|
||||
def test_variant_approval(client: httpx.Client) -> dict:
|
||||
"""Test the variant approval endpoint."""
|
||||
|
||||
# Get CSRF token from the review page
|
||||
csrf_token = get_csrf_token(client, "/admin/questions/1/generate?tab=review")
|
||||
|
||||
if not csrf_token:
|
||||
return {
|
||||
"status_code": None,
|
||||
"has_ise": False,
|
||||
"has_traceback": False,
|
||||
"error": "Could not get CSRF token - likely not authenticated",
|
||||
"response_preview": "",
|
||||
}
|
||||
|
||||
# Submit variant approval
|
||||
response = client.post(
|
||||
"/admin/questions/1/generate/review-bulk",
|
||||
data={
|
||||
"item_ids": "1",
|
||||
"action": "approved",
|
||||
"tab": "review",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
print(f" Response status: {response.status_code}")
|
||||
print(f" Final URL: {response.url}")
|
||||
|
||||
# Check for errors
|
||||
has_ise = response.status_code == 500 or "Internal Server Error" in response.text
|
||||
has_traceback = "Traceback" in response.text
|
||||
|
||||
if has_traceback:
|
||||
print("\n === TRACEBACK DETECTED ===")
|
||||
# Extract just the traceback part
|
||||
if "Traceback" in response.text:
|
||||
idx = response.text.find("Traceback")
|
||||
print(response.text[idx : idx + 3000])
|
||||
print(" ==========================\n")
|
||||
|
||||
return {
|
||||
"status_code": response.status_code,
|
||||
"has_ise": has_ise,
|
||||
"has_traceback": has_traceback,
|
||||
"response_preview": response.text[:1000],
|
||||
}
|
||||
|
||||
|
||||
def test_basis_item_review(client: httpx.Client) -> dict:
|
||||
"""Test the basis item review bulk endpoint."""
|
||||
|
||||
# Get CSRF token from the basis item page
|
||||
csrf_token = get_csrf_token(client, "/admin/basis-items/1")
|
||||
|
||||
if not csrf_token:
|
||||
return {
|
||||
"status_code": None,
|
||||
"has_ise": False,
|
||||
"has_traceback": False,
|
||||
"error": "Could not get CSRF token - likely not authenticated",
|
||||
"response_preview": "",
|
||||
}
|
||||
|
||||
# Submit basis item review
|
||||
response = client.post(
|
||||
"/admin/basis-items/1/review-bulk",
|
||||
data={
|
||||
"item_ids": "1",
|
||||
"action": "approved",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
print(f" Response status: {response.status_code}")
|
||||
print(f" Final URL: {response.url}")
|
||||
|
||||
# Check for errors
|
||||
has_ise = response.status_code == 500 or "Internal Server Error" in response.text
|
||||
has_traceback = "Traceback" in response.text
|
||||
|
||||
if has_traceback:
|
||||
print("\n === TRACEBACK DETECTED ===")
|
||||
if "Traceback" in response.text:
|
||||
idx = response.text.find("Traceback")
|
||||
print(response.text[idx : idx + 3000])
|
||||
print(" ==========================\n")
|
||||
|
||||
return {
|
||||
"status_code": response.status_code,
|
||||
"has_ise": has_ise,
|
||||
"has_traceback": has_traceback,
|
||||
"response_preview": response.text[:1000],
|
||||
}
|
||||
|
||||
|
||||
def test_snapshot_promote(client: httpx.Client) -> dict:
|
||||
"""Test the snapshot questions promote bulk endpoint."""
|
||||
|
||||
# Get CSRF token from the hierarchy page
|
||||
csrf_token = get_csrf_token(client, "/admin/hierarchy")
|
||||
|
||||
if not csrf_token:
|
||||
return {
|
||||
"status_code": None,
|
||||
"has_ise": False,
|
||||
"has_traceback": False,
|
||||
"error": "Could not get CSRF token - likely not authenticated",
|
||||
"response_preview": "",
|
||||
}
|
||||
|
||||
# Submit snapshot promote (with empty list)
|
||||
response = client.post(
|
||||
"/admin/snapshot-questions/promote-bulk",
|
||||
data={
|
||||
"snapshot_id": "1",
|
||||
"snapshot_question_ids": "",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
print(f" Response status: {response.status_code}")
|
||||
|
||||
# Check for errors
|
||||
has_ise = response.status_code == 500 or "Internal Server Error" in response.text
|
||||
has_traceback = "Traceback" in response.text
|
||||
|
||||
if has_traceback:
|
||||
print("\n === TRACEBACK DETECTED ===")
|
||||
if "Traceback" in response.text:
|
||||
idx = response.text.find("Traceback")
|
||||
print(response.text[idx : idx + 3000])
|
||||
print(" ==========================\n")
|
||||
|
||||
return {
|
||||
"status_code": response.status_code,
|
||||
"has_ise": has_ise,
|
||||
"has_traceback": has_traceback,
|
||||
"response_preview": response.text[:1000],
|
||||
}
|
||||
|
||||
|
||||
def test_tryout_import_preview(client: httpx.Client) -> dict:
|
||||
"""Test the tryout import preview endpoint."""
|
||||
|
||||
csrf_token = get_csrf_token(client, "/admin/tryout-import")
|
||||
|
||||
if not csrf_token:
|
||||
return {
|
||||
"status_code": None,
|
||||
"has_ise": False,
|
||||
"has_traceback": False,
|
||||
"error": "Could not get CSRF token",
|
||||
"response_preview": "",
|
||||
}
|
||||
|
||||
# Submit tryout import preview (without file)
|
||||
response = client.post(
|
||||
"/admin/tryout-import/preview",
|
||||
data={
|
||||
"website_id": "1",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
print(f" Response status: {response.status_code}")
|
||||
|
||||
has_ise = response.status_code == 500 or "Internal Server Error" in response.text
|
||||
has_traceback = "Traceback" in response.text
|
||||
|
||||
if has_traceback:
|
||||
print("\n === TRACEBACK DETECTED ===")
|
||||
if "Traceback" in response.text:
|
||||
idx = response.text.find("Traceback")
|
||||
print(response.text[idx : idx + 3000])
|
||||
print(" ==========================\n")
|
||||
|
||||
return {
|
||||
"status_code": response.status_code,
|
||||
"has_ise": has_ise,
|
||||
"has_traceback": has_traceback,
|
||||
"response_preview": response.text[:1000],
|
||||
}
|
||||
|
||||
|
||||
def test_website_crud(client: httpx.Client) -> dict:
|
||||
"""Test website creation endpoint."""
|
||||
|
||||
csrf_token = get_csrf_token(client, "/admin/websites")
|
||||
|
||||
if not csrf_token:
|
||||
return {
|
||||
"status_code": None,
|
||||
"has_ise": False,
|
||||
"has_traceback": False,
|
||||
"error": "Could not get CSRF token",
|
||||
"response_preview": "",
|
||||
}
|
||||
|
||||
# Submit website creation
|
||||
response = client.post(
|
||||
"/admin/websites",
|
||||
data={
|
||||
"site_name": "Test Site",
|
||||
"site_url": "https://test.example.com",
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
print(f" Response status: {response.status_code}")
|
||||
|
||||
has_ise = response.status_code == 500 or "Internal Server Error" in response.text
|
||||
has_traceback = "Traceback" in response.text
|
||||
|
||||
if has_traceback:
|
||||
print("\n === TRACEBACK DETECTED ===")
|
||||
if "Traceback" in response.text:
|
||||
idx = response.text.find("Traceback")
|
||||
print(response.text[idx : idx + 3000])
|
||||
print(" ==========================\n")
|
||||
|
||||
return {
|
||||
"status_code": response.status_code,
|
||||
"has_ise": has_ise,
|
||||
"has_traceback": has_traceback,
|
||||
"response_preview": response.text[:1000],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 80)
|
||||
print("Testing Form POST Endpoints for Internal Server Errors")
|
||||
print("=" * 80)
|
||||
print()
|
||||
|
||||
results = []
|
||||
|
||||
with httpx.Client(base_url=BASE_URL, timeout=30.0) as client:
|
||||
# Login
|
||||
print("Step 1: Logging in...")
|
||||
if not login(client):
|
||||
print("❌ Login failed")
|
||||
return 1
|
||||
print()
|
||||
|
||||
# Test 1: Variant approval
|
||||
print(
|
||||
"Step 2: Testing variant approval (/admin/questions/1/generate/review-bulk)..."
|
||||
)
|
||||
result1 = test_variant_approval(client)
|
||||
results.append(("Variant approval", result1))
|
||||
print()
|
||||
|
||||
# Test 2: Basis item review
|
||||
print("Step 3: Testing basis item review (/admin/basis-items/1/review-bulk)...")
|
||||
result2 = test_basis_item_review(client)
|
||||
results.append(("Basis item review", result2))
|
||||
print()
|
||||
|
||||
# Test 3: Snapshot promote
|
||||
print(
|
||||
"Step 4: Testing snapshot promote (/admin/snapshot-questions/promote-bulk)..."
|
||||
)
|
||||
result3 = test_snapshot_promote(client)
|
||||
results.append(("Snapshot promote", result3))
|
||||
print()
|
||||
|
||||
# Test 4: Tryout import preview
|
||||
print("Step 5: Testing tryout import preview (/admin/tryout-import/preview)...")
|
||||
result4 = test_tryout_import_preview(client)
|
||||
results.append(("Tryout import preview", result4))
|
||||
print()
|
||||
|
||||
# Test 5: Website creation
|
||||
print("Step 6: Testing website creation (/admin/websites)...")
|
||||
result5 = test_website_crud(client)
|
||||
results.append(("Website creation", result5))
|
||||
print()
|
||||
|
||||
# Summary
|
||||
print("=" * 80)
|
||||
print("RESULTS SUMMARY")
|
||||
print("=" * 80)
|
||||
|
||||
all_good = True
|
||||
for name, result in results:
|
||||
if result.get("has_ise") or result.get("has_traceback"):
|
||||
print(f"❌ {name}: INTERNAL SERVER ERROR!")
|
||||
print(f" Status: {result['status_code']}")
|
||||
print(f" Preview: {result['response_preview'][:200]}...")
|
||||
all_good = False
|
||||
elif result.get("error"):
|
||||
print(f"⚠️ {name}: {result['error']}")
|
||||
elif result["status_code"] in [200, 303]:
|
||||
print(f"✅ {name}: OK ({result['status_code']})")
|
||||
else:
|
||||
print(f"⚠️ {name}: Unexpected status {result['status_code']}")
|
||||
|
||||
print()
|
||||
if all_good:
|
||||
print("✅ All form POST endpoints passed! No Internal Server Errors detected.")
|
||||
return 0
|
||||
else:
|
||||
print("❌ Some endpoints have issues. Please review the output above.")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
42
backend/tests/test_auth_scope.py
Normal file
42
backend/tests/test_auth_scope.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.core.auth import ( # noqa: E402
|
||||
AuthContext,
|
||||
ensure_website_scope_matches,
|
||||
require_website_auth,
|
||||
)
|
||||
|
||||
|
||||
def test_require_website_auth_returns_scoped_website_for_allowed_role():
|
||||
auth = AuthContext(website_id=5, role="admin", wp_user_id=None)
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
assert website_id == 5
|
||||
|
||||
|
||||
def test_require_website_auth_allows_global_system_admin_scope():
|
||||
auth = AuthContext(website_id=None, role="system_admin", wp_user_id=None)
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
assert website_id is None
|
||||
|
||||
|
||||
def test_require_website_auth_rejects_disallowed_role():
|
||||
auth = AuthContext(website_id=5, role="student", wp_user_id="u1")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
def test_cross_website_payload_mismatch_is_blocked():
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
ensure_website_scope_matches(auth_website_id=10, payload_website_id=11)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
def test_global_system_admin_scope_can_write_any_payload_website():
|
||||
ensure_website_scope_matches(auth_website_id=None, payload_website_id=11)
|
||||
74
backend/tests/test_auth_tokens.py
Normal file
74
backend/tests/test_auth_tokens.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.core.auth import decode_access_token, issue_access_token # noqa: E402
|
||||
|
||||
|
||||
def test_issue_and_decode_access_token_round_trip():
|
||||
token = issue_access_token(
|
||||
website_id=42,
|
||||
role="student",
|
||||
wp_user_id="wp-1001",
|
||||
expires_in_seconds=3600,
|
||||
)
|
||||
auth = decode_access_token(token)
|
||||
assert auth.website_id == 42
|
||||
assert auth.role == "student"
|
||||
assert auth.wp_user_id == "wp-1001"
|
||||
|
||||
|
||||
def test_system_admin_token_can_be_global_without_website_scope():
|
||||
token = issue_access_token(
|
||||
website_id=None,
|
||||
role="system_admin",
|
||||
wp_user_id=None,
|
||||
expires_in_seconds=3600,
|
||||
)
|
||||
auth = decode_access_token(token)
|
||||
assert auth.website_id is None
|
||||
assert auth.role == "system_admin"
|
||||
|
||||
|
||||
def test_non_system_admin_token_requires_website_scope():
|
||||
token = issue_access_token(
|
||||
website_id=None,
|
||||
role="admin",
|
||||
wp_user_id=None,
|
||||
expires_in_seconds=3600,
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
decode_access_token(token)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
def test_decode_access_token_rejects_tampered_signature():
|
||||
token = issue_access_token(
|
||||
website_id=7,
|
||||
role="admin",
|
||||
wp_user_id=None,
|
||||
expires_in_seconds=3600,
|
||||
)
|
||||
payload, signature = token.split(".", 1)
|
||||
tampered_token = f"{payload}.{'A' if signature[0] != 'A' else 'B'}{signature[1:]}"
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
decode_access_token(tampered_token)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
def test_decode_access_token_rejects_expired_token():
|
||||
token = issue_access_token(
|
||||
website_id=9,
|
||||
role="student",
|
||||
wp_user_id="u-1",
|
||||
expires_in_seconds=-1,
|
||||
)
|
||||
time.sleep(0.01)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
decode_access_token(token)
|
||||
assert exc_info.value.status_code == 401
|
||||
12
backend/tests/test_model_mappings.py
Normal file
12
backend/tests/test_model_mappings.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from sqlalchemy.orm import configure_mappers
|
||||
|
||||
|
||||
def test_sqlalchemy_mappers_configure_without_join_errors():
|
||||
"""
|
||||
Ensure relationship joins are fully resolvable.
|
||||
|
||||
This catches missing FK/primaryjoin regressions early.
|
||||
"""
|
||||
import app.models # noqa: F401
|
||||
|
||||
configure_mappers()
|
||||
77
backend/tests/test_normalization.py
Normal file
77
backend/tests/test_normalization.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure project root is importable when tests run in isolated environments.
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from app.services.normalization import apply_normalization
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("nm", "rataan", "sb", "expected"),
|
||||
[
|
||||
(500, 500, 100, 500),
|
||||
(600, 500, 100, 600),
|
||||
(400, 500, 100, 400),
|
||||
(1000, 500, 100, 1000),
|
||||
(0, 500, 100, 0),
|
||||
(500, 600, 80, 375),
|
||||
],
|
||||
)
|
||||
def test_apply_normalization_nominal_cases(nm: int, rataan: float, sb: float, expected: int):
|
||||
assert apply_normalization(nm, rataan, sb) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nm", [-1, 1001, 1500, -100])
|
||||
def test_apply_normalization_rejects_invalid_nm(nm: int):
|
||||
with pytest.raises(ValueError):
|
||||
apply_normalization(nm, 500, 100)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sb", [0, -1, -100.0])
|
||||
def test_apply_normalization_returns_default_when_sd_non_positive(sb: float):
|
||||
assert apply_normalization(500, 500, sb) == 500
|
||||
|
||||
|
||||
def test_dynamic_normalization_distribution_behaves_as_expected():
|
||||
nm_scores = [450, 480, 500, 520, 550, 480, 510, 490, 530, 470]
|
||||
|
||||
mean = sum(nm_scores) / len(nm_scores)
|
||||
variance = sum((x - mean) ** 2 for x in nm_scores) / len(nm_scores)
|
||||
std = math.sqrt(variance)
|
||||
|
||||
nn_scores = [apply_normalization(nm, mean, std) for nm in nm_scores]
|
||||
nn_mean = sum(nn_scores) / len(nn_scores)
|
||||
nn_variance = sum((x - nn_mean) ** 2 for x in nn_scores) / len(nn_scores)
|
||||
nn_std = math.sqrt(nn_variance)
|
||||
|
||||
# Rounding in apply_normalization introduces small drift; these bounds are tight.
|
||||
assert abs(nn_mean - 500) <= 5
|
||||
assert abs(nn_std - 100) <= 5
|
||||
|
||||
|
||||
def test_incremental_population_stats_match_batch_stats():
|
||||
scores = [500, 550, 450, 600, 400]
|
||||
|
||||
participant_count = 0
|
||||
total_nm_sum = 0.0
|
||||
total_nm_sq_sum = 0.0
|
||||
|
||||
for score in scores:
|
||||
participant_count += 1
|
||||
total_nm_sum += score
|
||||
total_nm_sq_sum += score * score
|
||||
|
||||
incremental_mean = total_nm_sum / participant_count
|
||||
incremental_variance = (total_nm_sq_sum / participant_count) - (incremental_mean**2)
|
||||
incremental_std = math.sqrt(max(0.0, incremental_variance))
|
||||
|
||||
batch_mean = sum(scores) / len(scores)
|
||||
batch_variance = sum((x - batch_mean) ** 2 for x in scores) / len(scores)
|
||||
batch_std = math.sqrt(batch_variance)
|
||||
|
||||
assert incremental_mean == pytest.approx(batch_mean, rel=0, abs=1e-10)
|
||||
assert incremental_std == pytest.approx(batch_std, rel=0, abs=1e-10)
|
||||
271
backend/tests/test_operational_hardening.py
Normal file
271
backend/tests/test_operational_hardening.py
Normal file
@@ -0,0 +1,271 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.core import rate_limit
|
||||
from app.core.config import Settings
|
||||
from app.models.report_schedule import ReportScheduleModel
|
||||
from app.services import ai_generation
|
||||
from app.services import cat_selection
|
||||
from app.services.reporting import (
|
||||
cancel_scheduled_report,
|
||||
get_scheduled_report,
|
||||
list_scheduled_reports,
|
||||
schedule_report,
|
||||
)
|
||||
from app.schemas.ai import GeneratedQuestion
|
||||
|
||||
|
||||
class DummyRequest:
|
||||
client = SimpleNamespace(host="127.0.0.1")
|
||||
|
||||
|
||||
class DummyScalarResult:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self._value
|
||||
|
||||
def scalar(self):
|
||||
return self._value
|
||||
|
||||
|
||||
class DummyScalars:
|
||||
def __init__(self, values):
|
||||
self._values = values
|
||||
|
||||
def all(self):
|
||||
return self._values
|
||||
|
||||
|
||||
class DummyListResult:
|
||||
def __init__(self, values):
|
||||
self._values = values
|
||||
|
||||
def scalars(self):
|
||||
return DummyScalars(self._values)
|
||||
|
||||
|
||||
class DummyRowsResult:
|
||||
def __init__(self, values):
|
||||
self._values = values
|
||||
|
||||
def all(self):
|
||||
return self._values
|
||||
|
||||
|
||||
class DummyDb:
|
||||
def __init__(self, execute_results=None):
|
||||
self.execute_results = list(execute_results or [])
|
||||
self.added = []
|
||||
self.flushed = False
|
||||
|
||||
def add(self, row):
|
||||
self.added.append(row)
|
||||
|
||||
async def flush(self):
|
||||
self.flushed = True
|
||||
|
||||
async def execute(self, _query):
|
||||
return self.execute_results.pop(0)
|
||||
|
||||
|
||||
class DummyRedis:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
async def incr(self, _key):
|
||||
self.calls += 1
|
||||
return self.calls
|
||||
|
||||
async def expire(self, _key, _seconds):
|
||||
return True
|
||||
|
||||
async def ttl(self, _key):
|
||||
return 60
|
||||
|
||||
|
||||
def test_ai_stats_accepts_website_scope(monkeypatch):
|
||||
captured_queries = []
|
||||
|
||||
class CaptureDb:
|
||||
async def execute(self, query):
|
||||
captured_queries.append(str(query))
|
||||
if len(captured_queries) == 1:
|
||||
return DummyScalarResult(0)
|
||||
return DummyRowsResult([])
|
||||
|
||||
asyncio.run(ai_generation.get_ai_stats(CaptureDb(), website_id=9))
|
||||
|
||||
assert all("items.website_id" in query for query in captured_queries)
|
||||
|
||||
|
||||
def test_ai_prompt_preserves_basis_option_labels():
|
||||
prompt = ai_generation.get_prompt_template(
|
||||
basis_stem="<p>Basis question?</p>",
|
||||
basis_options={
|
||||
"A": "Option A",
|
||||
"B": "Option B",
|
||||
"C": "Option C",
|
||||
"D": "Option D",
|
||||
"E": "Option E",
|
||||
},
|
||||
basis_correct="A",
|
||||
basis_explanation="<p>Because A.</p>",
|
||||
target_level="mudah",
|
||||
)
|
||||
|
||||
assert "Create exactly 5 answer options with labels exactly: A, B, C, D, E" in prompt
|
||||
assert '"E": "Option E text"' in prompt
|
||||
assert "The correct field must be exactly one of: A, B, C, D, E" in prompt
|
||||
|
||||
|
||||
def test_generated_question_must_match_basis_option_labels():
|
||||
basis_item = SimpleNamespace(
|
||||
options={
|
||||
"A": "Option A",
|
||||
"B": "Option B",
|
||||
"C": "Option C",
|
||||
"D": "Option D",
|
||||
"E": "Option E",
|
||||
}
|
||||
)
|
||||
generated = GeneratedQuestion(
|
||||
stem="Generated",
|
||||
options={
|
||||
"A": "Option A",
|
||||
"B": "Option B",
|
||||
"C": "Option C",
|
||||
"D": "Option D",
|
||||
},
|
||||
correct="A",
|
||||
)
|
||||
|
||||
assert not ai_generation.generated_matches_basis_options(generated, basis_item)
|
||||
|
||||
|
||||
def test_cat_selection_only_serves_active_or_approved_variants():
|
||||
compiled = str(
|
||||
cat_selection._servable_item_filter().compile(
|
||||
compile_kwargs={"literal_binds": True}
|
||||
)
|
||||
)
|
||||
|
||||
assert "active" in compiled
|
||||
assert "approved" in compiled
|
||||
assert "draft" not in compiled
|
||||
assert "rejected" not in compiled
|
||||
|
||||
|
||||
def test_production_init_db_skips_create_all(monkeypatch):
|
||||
import app.database as database
|
||||
|
||||
class ExplodingEngine:
|
||||
def begin(self):
|
||||
raise AssertionError("create_all should not run in production")
|
||||
|
||||
monkeypatch.setattr(database, "settings", Settings(ENVIRONMENT="production"))
|
||||
monkeypatch.setattr(database, "engine", ExplodingEngine())
|
||||
|
||||
asyncio.run(database.init_db())
|
||||
|
||||
|
||||
def test_rate_limit_uses_redis_and_blocks_when_limit_exceeded(monkeypatch):
|
||||
dummy_redis = DummyRedis()
|
||||
rate_limit.reset_rate_limit_state()
|
||||
monkeypatch.setattr(rate_limit, "_get_redis_client", lambda: dummy_redis)
|
||||
|
||||
asyncio.run(
|
||||
rate_limit.enforce_rate_limit(
|
||||
DummyRequest(),
|
||||
scope="test.redis",
|
||||
max_requests=1,
|
||||
window_seconds=60,
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(
|
||||
rate_limit.enforce_rate_limit(
|
||||
DummyRequest(),
|
||||
scope="test.redis",
|
||||
max_requests=1,
|
||||
window_seconds=60,
|
||||
)
|
||||
)
|
||||
assert exc_info.value.status_code == 429
|
||||
|
||||
|
||||
def test_rate_limit_falls_back_to_memory_when_redis_unavailable(monkeypatch):
|
||||
rate_limit.reset_rate_limit_state()
|
||||
monkeypatch.setattr(rate_limit, "_get_redis_client", lambda: None)
|
||||
|
||||
asyncio.run(
|
||||
rate_limit.enforce_rate_limit(
|
||||
DummyRequest(),
|
||||
scope="test.memory",
|
||||
max_requests=1,
|
||||
window_seconds=60,
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(
|
||||
rate_limit.enforce_rate_limit(
|
||||
DummyRequest(),
|
||||
scope="test.memory",
|
||||
max_requests=1,
|
||||
window_seconds=60,
|
||||
)
|
||||
)
|
||||
assert exc_info.value.status_code == 429
|
||||
|
||||
|
||||
def test_schedule_report_persists_model_row():
|
||||
db = DummyDb()
|
||||
|
||||
schedule_id = asyncio.run(
|
||||
schedule_report(
|
||||
db,
|
||||
report_type="student_performance",
|
||||
schedule="daily",
|
||||
tryout_ids=["t1"],
|
||||
website_id=3,
|
||||
recipients=["ops@example.com"],
|
||||
export_format="xlsx",
|
||||
)
|
||||
)
|
||||
|
||||
assert db.flushed is True
|
||||
assert isinstance(db.added[0], ReportScheduleModel)
|
||||
assert db.added[0].schedule_id == schedule_id
|
||||
assert db.added[0].website_id == 3
|
||||
|
||||
|
||||
def test_schedule_helpers_read_list_and_soft_cancel():
|
||||
row = ReportScheduleModel(
|
||||
schedule_id="sched-1",
|
||||
report_type="student_performance",
|
||||
schedule="daily",
|
||||
tryout_ids=["t1"],
|
||||
website_id=3,
|
||||
recipients=["ops@example.com"],
|
||||
format="xlsx",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
get_db = DummyDb([DummyScalarResult(row)])
|
||||
listed_db = DummyDb([DummyListResult([row])])
|
||||
cancel_db = DummyDb([DummyScalarResult(row)])
|
||||
|
||||
got = asyncio.run(get_scheduled_report(get_db, "sched-1"))
|
||||
listed = asyncio.run(list_scheduled_reports(listed_db, website_id=3))
|
||||
cancelled = asyncio.run(cancel_scheduled_report(cancel_db, "sched-1"))
|
||||
|
||||
assert got.schedule_id == "sched-1"
|
||||
assert listed[0].website_id == 3
|
||||
assert cancelled is True
|
||||
assert row.is_active is False
|
||||
11
backend/tests/test_route_wiring.py
Normal file
11
backend/tests/test_route_wiring.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_next_item_route_is_registered():
|
||||
paths = set(app.openapi()["paths"])
|
||||
assert "/api/v1/session/{session_id}/next_item" in paths
|
||||
132
backend/tests/test_security_regressions.py
Normal file
132
backend/tests/test_security_regressions.py
Normal file
@@ -0,0 +1,132 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi.params import Depends
|
||||
|
||||
from app.api.v1.session import SubmitAnswerResponse
|
||||
from app.core.auth import AuthContext, get_auth_context
|
||||
from app.routers import admin as admin_router
|
||||
from app.routers import reports as reports_router
|
||||
from app.routers import wordpress as wordpress_router
|
||||
from app.schemas.session import SessionCompleteResponse, UserAnswerOutput
|
||||
from app.services.reporting import AggregatePerformanceStats, StudentPerformanceReport
|
||||
|
||||
|
||||
def _depends_on_auth(callable_obj, parameter_name: str = "auth") -> bool:
|
||||
parameter = inspect.signature(callable_obj).parameters[parameter_name]
|
||||
default = parameter.default
|
||||
return isinstance(default, Depends) and default.dependency is get_auth_context
|
||||
|
||||
|
||||
def test_admin_actions_require_signed_auth_context():
|
||||
assert _depends_on_auth(admin_router.admin_trigger_calibration)
|
||||
assert _depends_on_auth(admin_router.admin_toggle_ai_generation)
|
||||
assert _depends_on_auth(admin_router.admin_reset_normalization)
|
||||
|
||||
|
||||
def test_wordpress_user_lookup_routes_require_signed_auth_context():
|
||||
assert _depends_on_auth(wordpress_router.get_website_users)
|
||||
assert _depends_on_auth(wordpress_router.get_user_endpoint)
|
||||
|
||||
|
||||
def test_wordpress_roles_map_to_api_admin_roles():
|
||||
assert wordpress_router._api_role_from_wordpress_roles(["subscriber"]) == "student"
|
||||
assert wordpress_router._api_role_from_wordpress_roles(["administrator"]) == "admin"
|
||||
assert wordpress_router._api_role_from_wordpress_roles(["super_admin"]) == "system_admin"
|
||||
|
||||
|
||||
def test_adaptive_submit_response_does_not_expose_answer_key_or_correctness():
|
||||
payload = SubmitAnswerResponse(theta=0.12, theta_se=0.8).model_dump()
|
||||
|
||||
assert "is_correct" not in payload
|
||||
assert "correct_answer" not in payload
|
||||
assert "explanation" not in payload
|
||||
|
||||
|
||||
def test_session_completion_answer_output_does_not_expose_correctness():
|
||||
answer_payload = UserAnswerOutput(
|
||||
id=1,
|
||||
item_id=10,
|
||||
response="A",
|
||||
time_spent=12,
|
||||
bobot_earned=0.5,
|
||||
scoring_mode_used="ctt",
|
||||
).model_dump()
|
||||
|
||||
assert "is_correct" not in answer_payload
|
||||
|
||||
response_payload = SessionCompleteResponse(
|
||||
id=1,
|
||||
session_id="s-1",
|
||||
wp_user_id="wp-1",
|
||||
website_id=2,
|
||||
tryout_id="tryout-1",
|
||||
start_time=datetime.now(timezone.utc),
|
||||
end_time=datetime.now(timezone.utc),
|
||||
is_completed=True,
|
||||
scoring_mode_used="ctt",
|
||||
total_benar=1,
|
||||
total_bobot_earned=0.5,
|
||||
NM=500,
|
||||
NN=500,
|
||||
rataan_used=500,
|
||||
sb_used=100,
|
||||
user_answers=[
|
||||
UserAnswerOutput(
|
||||
id=1,
|
||||
item_id=10,
|
||||
response="A",
|
||||
time_spent=12,
|
||||
bobot_earned=0.5,
|
||||
scoring_mode_used="ctt",
|
||||
)
|
||||
],
|
||||
).model_dump()
|
||||
|
||||
assert "is_correct" not in response_payload["user_answers"][0]
|
||||
|
||||
|
||||
def test_student_performance_report_is_scoped_to_student_user(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_generate_student_performance_report(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return StudentPerformanceReport(
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
tryout_id=kwargs["tryout_id"],
|
||||
website_id=kwargs["website_id"],
|
||||
date_range=kwargs["date_range"],
|
||||
aggregate=AggregatePerformanceStats(
|
||||
tryout_id=kwargs["tryout_id"],
|
||||
participant_count=0,
|
||||
avg_nm=None,
|
||||
std_nm=None,
|
||||
min_nm=None,
|
||||
max_nm=None,
|
||||
median_nm=None,
|
||||
avg_nn=None,
|
||||
std_nn=None,
|
||||
avg_theta=None,
|
||||
pass_rate=0.0,
|
||||
avg_time_spent=0.0,
|
||||
),
|
||||
individual_records=[],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
reports_router,
|
||||
"generate_student_performance_report",
|
||||
fake_generate_student_performance_report,
|
||||
)
|
||||
|
||||
asyncio.run(
|
||||
reports_router.get_student_performance_report(
|
||||
tryout_id="tryout-1",
|
||||
db=object(),
|
||||
auth=AuthContext(website_id=5, role="student", wp_user_id="wp-1"),
|
||||
)
|
||||
)
|
||||
|
||||
assert captured["website_id"] == 5
|
||||
assert captured["wp_user_id"] == "wp-1"
|
||||
110
backend/tests/test_tryout_json_import.py
Normal file
110
backend/tests/test_tryout_json_import.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.services.tryout_json_import import preview_tryout_json_import
|
||||
|
||||
|
||||
class DummyScalarResult:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self._value
|
||||
|
||||
|
||||
class DummyScalars:
|
||||
def __init__(self, values):
|
||||
self._values = values
|
||||
|
||||
def all(self):
|
||||
return self._values
|
||||
|
||||
|
||||
class DummyListResult:
|
||||
def __init__(self, values):
|
||||
self._values = values
|
||||
|
||||
def scalars(self):
|
||||
return DummyScalars(self._values)
|
||||
|
||||
|
||||
class DummySession:
|
||||
def __init__(self, responses):
|
||||
self._responses = list(responses)
|
||||
|
||||
async def execute(self, _query):
|
||||
return self._responses.pop(0)
|
||||
|
||||
|
||||
def test_preview_tryout_json_import_classifies_new_updated_and_removed_questions():
|
||||
payload = {
|
||||
"export_info": {
|
||||
"exported_at": "2026-04-02 09:12:59",
|
||||
"exported_by": "Admin",
|
||||
"tryout_id": 1038,
|
||||
},
|
||||
"tryouts": {
|
||||
"tryout_1038": {
|
||||
"info": {
|
||||
"id": 1038,
|
||||
"title": "Tryout PPDS Obgyn",
|
||||
"permalink": "https://member.example.com/tryout/1038",
|
||||
},
|
||||
"questions": [
|
||||
{
|
||||
"id": 269,
|
||||
"title": "Question A",
|
||||
"question": "<p>Question A body</p>",
|
||||
"options": [
|
||||
{"increment": "A", "label": "", "value": "0"},
|
||||
{"increment": "B", "label": "", "value": "1"},
|
||||
],
|
||||
"answer": "B",
|
||||
"explanation": "<p>Because.</p>",
|
||||
},
|
||||
{
|
||||
"id": 270,
|
||||
"title": "Question B new",
|
||||
"question": "<p>Question B body</p>",
|
||||
"options": [
|
||||
{"increment": "A", "label": "", "value": "1"},
|
||||
{"increment": "B", "label": "", "value": "0"},
|
||||
],
|
||||
"answer": "A",
|
||||
"explanation": "<p>New item.</p>",
|
||||
},
|
||||
],
|
||||
"results": [],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
existing_question = SimpleNamespace(
|
||||
source_question_id="269",
|
||||
content_checksum="old-checksum",
|
||||
is_active=True,
|
||||
)
|
||||
removed_question = SimpleNamespace(
|
||||
source_question_id="999",
|
||||
content_checksum="removed-checksum",
|
||||
is_active=True,
|
||||
)
|
||||
db = DummySession(
|
||||
[
|
||||
DummyScalarResult(SimpleNamespace(id=1)),
|
||||
DummyListResult([existing_question, removed_question]),
|
||||
]
|
||||
)
|
||||
|
||||
preview = asyncio.run(preview_tryout_json_import(payload, website_id=1, db=db))
|
||||
|
||||
assert preview["tryout_count"] == 1
|
||||
assert preview["totals"]["new_questions"] == 1
|
||||
assert preview["totals"]["updated_questions"] == 1
|
||||
assert preview["totals"]["removed_questions"] == 1
|
||||
assert preview["totals"]["missing_option_labels"] == 2
|
||||
assert "read-only reference data" not in str(preview)
|
||||
Reference in New Issue
Block a user