Checkpoint React frontend migration

This commit is contained in:
Dwindi Ramadhana
2026-06-20 01:43:39 +07:00
parent ab86c254d1
commit b8e201b45f
173 changed files with 34116 additions and 782 deletions

7
backend/app/__init__.py Normal file
View 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

File diff suppressed because it is too large Load Diff

6456
backend/app/admin_web.py Normal file

File diff suppressed because it is too large Load Diff

View 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,
}

View File

@@ -0,0 +1,5 @@
"""
API module for IRT Bank Soal.
Contains FastAPI routers and endpoint definitions.
"""

View 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"]
)

View 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
}

View File

@@ -0,0 +1,3 @@
"""
Core configuration and database utilities.
"""

170
backend/app/core/auth.py Normal file
View 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
View 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

View 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
View 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
View 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",
)

View 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",
]

View 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
View 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})>"

View 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"),
)

View 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})>"

View 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})>"

View 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(),
)

View 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",
),
)

View 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})>"

View 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})>"

View 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})>"

View 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})>"

View 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

File diff suppressed because it is too large Load Diff

530
backend/app/routers/ai.py Normal file
View 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}

View 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",
}

View 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),
)

View 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),
)

View 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),
)

View 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)

View 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,
}

View 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"}

View 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)

View 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
View 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

View 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

View 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}

View 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()

View 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"
)

View 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",
]

View 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

View 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
}

View 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

View 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)))

View 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

File diff suppressed because it is too large Load Diff

View 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,
}

File diff suppressed because it is too large Load Diff

View 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.",
}

View 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