Checkpoint React frontend migration
This commit is contained in:
7
backend/app/__init__.py
Normal file
7
backend/app/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
IRT Bank Soal - Adaptive Question Bank System
|
||||
|
||||
Main application package.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
1016
backend/app/admin.py
Normal file
1016
backend/app/admin.py
Normal file
File diff suppressed because it is too large
Load Diff
6456
backend/app/admin_web.py
Normal file
6456
backend/app/admin_web.py
Normal file
File diff suppressed because it is too large
Load Diff
110
backend/app/admin_web_icons.py
Normal file
110
backend/app/admin_web_icons.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Icon constants using inline SVG (Heroicons style).
|
||||
These replace emoji usage in the admin UI for consistent, professional icons.
|
||||
"""
|
||||
|
||||
# Navigation icons
|
||||
ICON_DASHBOARD = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" /></svg>"""
|
||||
|
||||
ICON_QUESTIONS = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" /></svg>"""
|
||||
|
||||
ICON_IMPORT = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" /></svg>"""
|
||||
|
||||
ICON_AI = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" /></svg>"""
|
||||
|
||||
ICON_EXAMS = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></svg>"""
|
||||
|
||||
ICON_REPORTS = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /></svg>"""
|
||||
|
||||
ICON_SETTINGS = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>"""
|
||||
|
||||
ICON_LOGOUT = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15M12 9l-3 3m0 0 3 3m-3-3h12.75" /></svg>"""
|
||||
|
||||
# Page icons
|
||||
ICON_TARGET = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 0 0 1.5-.189m-1.5.189a6.01 6.01 0 0 1-1.5-.189m3.75 7.478a12.06 12.06 0 0 1-4.5 0m3.75 2.383a14.406 14.406 0 0 1-3 0M14.25 18v-.192c0-.983.658-1.727 1.5-1.727s1.5.744 1.5 1.727V18m-4.5 0h.008v.008H14.25v-.008Z" /></svg>"""
|
||||
|
||||
ICON_USERS = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" /></svg>"""
|
||||
|
||||
ICON_CALIBRATION = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15ZM21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h6" /></svg>"""
|
||||
|
||||
ICON_STUDENTS = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" /></svg>"""
|
||||
|
||||
ICON_DOWNLOAD = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>"""
|
||||
|
||||
ICON_UPLOAD = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" /></svg>"""
|
||||
|
||||
ICON_SEARCH = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>"""
|
||||
|
||||
ICON_CHECK = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /></svg>"""
|
||||
|
||||
ICON_WARNING = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" /></svg>"""
|
||||
|
||||
ICON_INFO = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" /></svg>"""
|
||||
|
||||
ICON_LIGHTBULB = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 0 0 1.5-.189m-1.5.189a6.01 6.01 0 0 1-1.5-.189m3.75 7.478a12.06 12.06 0 0 1-4.5 0m3.75 2.383a14.406 14.406 0 0 1-3 0M14.25 18v-.192c0-.983.658-1.727 1.5-1.727s1.5.744 1.5 1.727V18m-4.5 0h.008v.008H14.25v-.008Z" /></svg>"""
|
||||
|
||||
ICON_TREND_UP = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941" /></svg>"""
|
||||
|
||||
ICON_TREND_DOWN = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="page-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6 9 12.75l4.286-4.286a11.948 11.948 0 0 1 4.306 6.43l.776 2.898m0 0 3.182-5.511m-3.182 5.51-5.511-3.181" /></svg>"""
|
||||
|
||||
# Huge icons for replacing emojis (24x24 with larger visual weight)
|
||||
ICON_HUGE_TARGET = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a8.01 8.01 0 0 0 1.5-.189m-1.5.189a8.01 8.01 0 0 1-1.5-.189m3.75 7.478a10.56 10.56 0 0 1-4.5 0m3.75 2.383a13.406 13.406 0 0 1-3 0M14.25 18v-.192c0-.983.658-1.727 1.5-1.727s1.5.744 1.5 1.727V18m-4.5 0h.008v.008H14.25v-.008Z" /></svg>"""
|
||||
|
||||
ICON_HUGE_USER = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" /></svg>"""
|
||||
|
||||
ICON_HUGE_CHECK = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /></svg>"""
|
||||
|
||||
ICON_HUGE_CLOCK = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>"""
|
||||
|
||||
ICON_HUGE_ROCKET = """<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none" /><g fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linejoin="round" d="m11.801 6.49l1.486-1.486c1.673-1.673 3.862-2.367 6.18-2.48c.902-.044 1.352-.066 1.714.295c.361.362.34.812.295 1.714c-.113 2.318-.807 4.507-2.48 6.18L17.511 12.2c-1.224 1.223-1.572 1.571-1.315 2.898c.254 1.014.499 1.995-.238 2.732c-.894.895-1.71.895-2.604 0l-7.183-7.183c-.895-.894-.895-1.71 0-2.604c.737-.737 1.718-.492 2.732-.238c1.327.257 1.675-.091 2.898-1.315Z" /><path stroke-linecap="round" d="m2.5 21.5l5-5m1 5l2-2m-8-4l2-2" /><path stroke-linecap="round" stroke-linejoin="round" d="M17.125 7H17m.25 0a.25.25 0 1 1-.5 0a.25.25 0 0 1 .5 0" /></g></svg>"""
|
||||
|
||||
ICON_HUGE_CHART = """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /></svg>"""
|
||||
|
||||
# Emoji to SVG icon mapping for replacement
|
||||
EMOJI_TO_ICON = {
|
||||
# Navigation & main icons
|
||||
"🏠": ICON_DASHBOARD,
|
||||
"📝": ICON_QUESTIONS,
|
||||
"📥": ICON_IMPORT,
|
||||
"🤖": ICON_AI,
|
||||
"📋": ICON_EXAMS,
|
||||
"📊": ICON_REPORTS,
|
||||
"⚙️": ICON_SETTINGS,
|
||||
"🚪": ICON_LOGOUT,
|
||||
"🎯": ICON_HUGE_TARGET,
|
||||
"👤": ICON_HUGE_USER,
|
||||
"👥": ICON_USERS,
|
||||
"⚠️": ICON_WARNING,
|
||||
"ℹ️": ICON_INFO,
|
||||
"🚀": ICON_HUGE_ROCKET,
|
||||
"✅": ICON_HUGE_CHECK,
|
||||
"❌": """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>""",
|
||||
"⏳": ICON_HUGE_CLOCK,
|
||||
"📈": ICON_TREND_UP,
|
||||
"📉": ICON_TREND_DOWN,
|
||||
"💡": ICON_LIGHTBULB,
|
||||
"👋": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="display:inline;width:28px;height:28px;margin-bottom:-4px;"><path stroke-linecap="round" stroke-linejoin="round" d="M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672ZM12 2.25V4.5m5.834.166-1.591 1.591M20.25 10.5H18M7.757 14.743l-1.59 1.59M6 10.5H3.75m4.007-4.243-1.59-1.59" /></svg>',
|
||||
"📊": ICON_REPORTS,
|
||||
"🚀": ICON_HUGE_ROCKET,
|
||||
"📈": ICON_TREND_UP,
|
||||
# Additional icons from UI
|
||||
"🌐": """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /></svg>""",
|
||||
"🔍": ICON_SEARCH,
|
||||
"📁": """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" /></svg>""",
|
||||
"🔐": """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>""",
|
||||
"⚡": """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z" /></svg>""",
|
||||
"💾": """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0v3.75C20.25 20.653 16.556 22.5 12 22.5s-8.25-1.847-8.25-4.125v-3.75m-16.5 0v3.75" /></svg>""",
|
||||
"🔄": """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="huge-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" /></svg>""",
|
||||
"🔘": """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" /></svg>""",
|
||||
"📍": """<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="nav-icon"><path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z" /></svg>""",
|
||||
}
|
||||
|
||||
# Navigation icon mapping
|
||||
NAV_ICONS_SVG = {
|
||||
"Dashboard": ICON_DASHBOARD,
|
||||
"Import": ICON_IMPORT,
|
||||
"Exams": ICON_EXAMS,
|
||||
"Reports": ICON_REPORTS,
|
||||
"Settings": ICON_SETTINGS,
|
||||
"Logout": ICON_LOGOUT,
|
||||
}
|
||||
5
backend/app/api/__init__.py
Normal file
5
backend/app/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
API module for IRT Bank Soal.
|
||||
|
||||
Contains FastAPI routers and endpoint definitions.
|
||||
"""
|
||||
25
backend/app/api/v1/__init__.py
Normal file
25
backend/app/api/v1/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
API v1 Router configuration.
|
||||
|
||||
Defines all API v1 endpoints and their prefixes.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import session
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# Include session endpoints
|
||||
api_router.include_router(
|
||||
session.router,
|
||||
prefix="/session",
|
||||
tags=["session"]
|
||||
)
|
||||
|
||||
# Include admin endpoints
|
||||
api_router.include_router(
|
||||
session.admin_router,
|
||||
prefix="/admin",
|
||||
tags=["admin"]
|
||||
)
|
||||
448
backend/app/api/v1/session.py
Normal file
448
backend/app/api/v1/session.py
Normal file
@@ -0,0 +1,448 @@
|
||||
"""
|
||||
Session API endpoints for CAT item selection.
|
||||
|
||||
Provides endpoints for:
|
||||
- GET /api/v1/session/{session_id}/next_item - Get next question
|
||||
- POST /api/v1/admin/cat/test - Admin playground for testing CAT
|
||||
"""
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.auth import (
|
||||
AuthContext,
|
||||
ensure_website_scope_matches,
|
||||
get_auth_context,
|
||||
require_website_auth,
|
||||
)
|
||||
from app.models import Item, Session, Tryout, UserAnswer
|
||||
from app.services.cat_selection import (
|
||||
CATSelectionError,
|
||||
get_next_item,
|
||||
should_terminate,
|
||||
simulate_cat_selection,
|
||||
update_theta,
|
||||
)
|
||||
|
||||
# Default SE threshold for termination
|
||||
DEFAULT_SE_THRESHOLD = 0.5
|
||||
|
||||
# Session router for student-facing endpoints
|
||||
router = APIRouter()
|
||||
|
||||
# Admin router for admin-only endpoints
|
||||
admin_router = APIRouter()
|
||||
|
||||
|
||||
# ============== Request/Response Models ==============
|
||||
|
||||
class NextItemResponse(BaseModel):
|
||||
"""Response for next item endpoint."""
|
||||
status: Literal["item", "completed"] = "item"
|
||||
item_id: Optional[int] = None
|
||||
stem: Optional[str] = None
|
||||
options: Optional[dict] = None
|
||||
slot: Optional[int] = None
|
||||
level: Optional[str] = None
|
||||
display_level: Optional[str] = None
|
||||
generated_by: Optional[str] = None
|
||||
source_snapshot_question_id: Optional[int] = None
|
||||
selection_method: Optional[str] = None
|
||||
reason: Optional[str] = None
|
||||
current_theta: Optional[float] = None
|
||||
current_se: Optional[float] = None
|
||||
items_answered: Optional[int] = None
|
||||
|
||||
|
||||
class SubmitAnswerRequest(BaseModel):
|
||||
"""Request for submitting an answer."""
|
||||
item_id: int = Field(..., description="Item ID being answered")
|
||||
response: str = Field(..., description="User's answer (A, B, C, D)")
|
||||
time_spent: int = Field(default=0, ge=0, description="Time spent on question (seconds)")
|
||||
|
||||
|
||||
class SubmitAnswerResponse(BaseModel):
|
||||
"""Response for submitting an answer."""
|
||||
theta: Optional[float] = None
|
||||
theta_se: Optional[float] = None
|
||||
|
||||
|
||||
class CATTestRequest(BaseModel):
|
||||
"""Request for admin CAT test endpoint."""
|
||||
tryout_id: str = Field(..., description="Tryout identifier")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
initial_theta: float = Field(default=0.0, ge=-3.0, le=3.0, description="Initial theta value")
|
||||
selection_mode: Literal["fixed", "adaptive", "hybrid"] = Field(
|
||||
default="adaptive", description="Selection mode"
|
||||
)
|
||||
max_items: int = Field(default=15, ge=1, le=100, description="Maximum items to simulate")
|
||||
se_threshold: float = Field(
|
||||
default=0.5, ge=0.1, le=3.0, description="SE threshold for termination"
|
||||
)
|
||||
hybrid_transition_slot: int = Field(
|
||||
default=10, ge=1, description="Slot to transition in hybrid mode"
|
||||
)
|
||||
|
||||
|
||||
class CATTestResponse(BaseModel):
|
||||
"""Response for admin CAT test endpoint."""
|
||||
tryout_id: str
|
||||
website_id: int
|
||||
initial_theta: float
|
||||
selection_mode: str
|
||||
total_items: int
|
||||
final_theta: float
|
||||
final_se: float
|
||||
se_threshold_met: bool
|
||||
items: list
|
||||
|
||||
|
||||
# ============== Session Endpoints ==============
|
||||
|
||||
@router.get(
|
||||
"/{session_id}/next_item",
|
||||
response_model=NextItemResponse,
|
||||
summary="Get next item for session",
|
||||
description="Returns the next question for a session based on the tryout's selection mode."
|
||||
)
|
||||
async def get_next_item_endpoint(
|
||||
session_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> NextItemResponse:
|
||||
"""
|
||||
Get the next item for a session.
|
||||
|
||||
Validates session exists and is not completed.
|
||||
Gets Tryout config (scoring_mode, selection_mode, max_items).
|
||||
Calls appropriate selection function based on selection_mode.
|
||||
Returns item or completion status.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||
|
||||
# Get session
|
||||
session_query = select(Session).where(
|
||||
Session.session_id == session_id,
|
||||
Session.website_id == website_id,
|
||||
)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Session {session_id} not found"
|
||||
)
|
||||
if auth.role == "student" and session.wp_user_id != auth.wp_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Session does not belong to this authenticated user",
|
||||
)
|
||||
|
||||
if session.is_completed:
|
||||
return NextItemResponse(
|
||||
status="completed",
|
||||
reason="Session already completed"
|
||||
)
|
||||
|
||||
# Get tryout config
|
||||
tryout_query = select(Tryout).where(
|
||||
Tryout.tryout_id == session.tryout_id,
|
||||
Tryout.website_id == session.website_id
|
||||
)
|
||||
tryout_result = await db.execute(tryout_query)
|
||||
tryout = tryout_result.scalar_one_or_none()
|
||||
|
||||
if not tryout:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tryout {session.tryout_id} not found"
|
||||
)
|
||||
|
||||
# Check termination conditions
|
||||
termination = await should_terminate(
|
||||
db,
|
||||
session_id,
|
||||
max_items=None, # Will be set from tryout config if needed
|
||||
se_threshold=DEFAULT_SE_THRESHOLD
|
||||
)
|
||||
|
||||
if termination.should_terminate:
|
||||
return NextItemResponse(
|
||||
status="completed",
|
||||
reason=termination.reason,
|
||||
current_theta=session.theta,
|
||||
current_se=session.theta_se,
|
||||
items_answered=termination.items_answered
|
||||
)
|
||||
|
||||
# Get next item based on selection mode
|
||||
try:
|
||||
result = await get_next_item(
|
||||
db,
|
||||
session_id,
|
||||
selection_mode=tryout.selection_mode,
|
||||
hybrid_transition_slot=tryout.hybrid_transition_slot or 10,
|
||||
ai_generation_enabled=tryout.ai_generation_enabled
|
||||
)
|
||||
except CATSelectionError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
if result.item is None:
|
||||
return NextItemResponse(
|
||||
status="completed",
|
||||
reason=result.reason,
|
||||
current_theta=session.theta,
|
||||
current_se=session.theta_se,
|
||||
items_answered=termination.items_answered
|
||||
)
|
||||
|
||||
item = result.item
|
||||
|
||||
return NextItemResponse(
|
||||
status="item",
|
||||
item_id=item.id,
|
||||
stem=item.stem,
|
||||
options=item.options,
|
||||
slot=item.slot,
|
||||
level=item.level,
|
||||
display_level="Original"
|
||||
if item.generated_by != "ai" and item.source_snapshot_question_id is not None
|
||||
else item.level,
|
||||
generated_by=item.generated_by,
|
||||
source_snapshot_question_id=item.source_snapshot_question_id,
|
||||
selection_method=result.selection_method,
|
||||
reason=result.reason,
|
||||
current_theta=session.theta,
|
||||
current_se=session.theta_se,
|
||||
items_answered=termination.items_answered
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{session_id}/submit_answer",
|
||||
response_model=SubmitAnswerResponse,
|
||||
summary="Submit answer for item",
|
||||
description="Submit an answer for an item and update theta estimate."
|
||||
)
|
||||
async def submit_answer_endpoint(
|
||||
session_id: str,
|
||||
request: SubmitAnswerRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> SubmitAnswerResponse:
|
||||
"""
|
||||
Submit an answer for an item.
|
||||
|
||||
Validates session and item.
|
||||
Checks correctness.
|
||||
Updates theta estimate.
|
||||
Records response time.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||
|
||||
# Get session
|
||||
session_query = select(Session).where(
|
||||
Session.session_id == session_id,
|
||||
Session.website_id == website_id,
|
||||
)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Session {session_id} not found"
|
||||
)
|
||||
if auth.role == "student" and session.wp_user_id != auth.wp_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Session does not belong to this authenticated user",
|
||||
)
|
||||
|
||||
if session.is_completed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Session already completed"
|
||||
)
|
||||
|
||||
# Get item
|
||||
item_query = select(Item).where(
|
||||
Item.id == request.item_id,
|
||||
Item.website_id == session.website_id,
|
||||
Item.tryout_id == session.tryout_id,
|
||||
)
|
||||
item_result = await db.execute(item_query)
|
||||
item = item_result.scalar_one_or_none()
|
||||
|
||||
if not item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Item {request.item_id} not found"
|
||||
)
|
||||
|
||||
existing_answer_result = await db.execute(
|
||||
select(UserAnswer.id).where(
|
||||
UserAnswer.session_id == session_id,
|
||||
UserAnswer.item_id == request.item_id,
|
||||
)
|
||||
)
|
||||
if existing_answer_result.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Item was already answered for this session",
|
||||
)
|
||||
|
||||
# Check correctness
|
||||
is_correct = request.response.upper() == item.correct_answer.upper()
|
||||
|
||||
# Update theta
|
||||
theta, theta_se = await update_theta(db, session_id, request.item_id, is_correct)
|
||||
|
||||
user_answer = UserAnswer(
|
||||
session_id=session_id,
|
||||
wp_user_id=session.wp_user_id,
|
||||
website_id=session.website_id,
|
||||
tryout_id=session.tryout_id,
|
||||
item_id=request.item_id,
|
||||
response=request.response.upper(),
|
||||
is_correct=is_correct,
|
||||
time_spent=request.time_spent,
|
||||
scoring_mode_used=session.scoring_mode_used,
|
||||
bobot_earned=item.ctt_bobot if is_correct and item.ctt_bobot else 0.0
|
||||
)
|
||||
|
||||
db.add(user_answer)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Item was already answered for this session",
|
||||
) from exc
|
||||
|
||||
return SubmitAnswerResponse(
|
||||
theta=theta,
|
||||
theta_se=theta_se
|
||||
)
|
||||
|
||||
|
||||
# ============== Admin Endpoints ==============
|
||||
|
||||
@admin_router.post(
|
||||
"/cat/test",
|
||||
response_model=CATTestResponse,
|
||||
summary="Test CAT selection algorithm",
|
||||
description="Admin playground for testing adaptive selection behavior."
|
||||
)
|
||||
async def test_cat_endpoint(
|
||||
request: CATTestRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> CATTestResponse:
|
||||
"""
|
||||
Test CAT selection algorithm.
|
||||
|
||||
Simulates CAT selection for a tryout and returns
|
||||
the sequence of selected items with theta progression.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
ensure_website_scope_matches(website_id, request.website_id)
|
||||
|
||||
# Verify tryout exists
|
||||
tryout_query = select(Tryout).where(
|
||||
Tryout.tryout_id == request.tryout_id,
|
||||
Tryout.website_id == website_id
|
||||
)
|
||||
tryout_result = await db.execute(tryout_query)
|
||||
tryout = tryout_result.scalar_one_or_none()
|
||||
|
||||
if not tryout:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tryout {request.tryout_id} not found for website {website_id}"
|
||||
)
|
||||
|
||||
# Run simulation
|
||||
result = await simulate_cat_selection(
|
||||
db,
|
||||
tryout_id=request.tryout_id,
|
||||
website_id=website_id,
|
||||
initial_theta=request.initial_theta,
|
||||
selection_mode=request.selection_mode,
|
||||
max_items=request.max_items,
|
||||
se_threshold=request.se_threshold,
|
||||
hybrid_transition_slot=request.hybrid_transition_slot
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result["error"]
|
||||
)
|
||||
|
||||
return CATTestResponse(**result)
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/session/{session_id}/status",
|
||||
summary="Get session status",
|
||||
description="Get detailed session status including theta and SE."
|
||||
)
|
||||
async def get_session_status_endpoint(
|
||||
session_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> dict:
|
||||
"""
|
||||
Get session status for admin monitoring.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
|
||||
# Get session
|
||||
session_query = select(Session).where(
|
||||
Session.session_id == session_id,
|
||||
Session.website_id == website_id,
|
||||
)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Session {session_id} not found"
|
||||
)
|
||||
|
||||
# Count answers
|
||||
from sqlalchemy import func
|
||||
from app.models import UserAnswer
|
||||
|
||||
count_query = select(func.count(UserAnswer.id)).where(
|
||||
UserAnswer.session_id == session_id
|
||||
)
|
||||
count_result = await db.execute(count_query)
|
||||
items_answered = count_result.scalar() or 0
|
||||
|
||||
return {
|
||||
"session_id": session.session_id,
|
||||
"wp_user_id": session.wp_user_id,
|
||||
"tryout_id": session.tryout_id,
|
||||
"is_completed": session.is_completed,
|
||||
"theta": session.theta,
|
||||
"theta_se": session.theta_se,
|
||||
"items_answered": items_answered,
|
||||
"scoring_mode_used": session.scoring_mode_used,
|
||||
"NM": session.NM,
|
||||
"NN": session.NN,
|
||||
"start_time": session.start_time.isoformat() if session.start_time else None,
|
||||
"end_time": session.end_time.isoformat() if session.end_time else None
|
||||
}
|
||||
3
backend/app/core/__init__.py
Normal file
3
backend/app/core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Core configuration and database utilities.
|
||||
"""
|
||||
170
backend/app/core/auth.py
Normal file
170
backend/app/core/auth.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Token-based authentication helpers for website-scoped access control.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Header, HTTPException, status
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthContext:
|
||||
website_id: Optional[int]
|
||||
role: str
|
||||
wp_user_id: Optional[str] = None
|
||||
|
||||
|
||||
def _b64url_encode(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(raw: str) -> bytes:
|
||||
padding = "=" * (-len(raw) % 4)
|
||||
return base64.urlsafe_b64decode((raw + padding).encode("ascii"))
|
||||
|
||||
|
||||
def issue_access_token(
|
||||
website_id: int | None,
|
||||
role: str = "student",
|
||||
wp_user_id: str | None = None,
|
||||
expires_in_seconds: int = 3600,
|
||||
) -> str:
|
||||
payload = {
|
||||
"website_id": int(website_id) if website_id is not None else None,
|
||||
"role": role,
|
||||
"wp_user_id": wp_user_id,
|
||||
"exp": int(time.time()) + int(expires_in_seconds),
|
||||
}
|
||||
payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
payload_b64 = _b64url_encode(payload_bytes)
|
||||
sig = hmac.new(settings.SECRET_KEY.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||
return f"{payload_b64}.{_b64url_encode(sig)}"
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> AuthContext:
|
||||
try:
|
||||
payload_b64, sig_b64 = token.split(".", 1)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid access token format",
|
||||
) from exc
|
||||
|
||||
expected_sig = hmac.new(
|
||||
settings.SECRET_KEY.encode("utf-8"),
|
||||
payload_b64.encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
provided_sig = _b64url_decode(sig_b64)
|
||||
if not hmac.compare_digest(provided_sig, expected_sig):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid access token signature",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = json.loads(_b64url_decode(payload_b64).decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid access token payload",
|
||||
) from exc
|
||||
|
||||
exp = int(payload.get("exp", 0))
|
||||
if exp <= int(time.time()):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Access token has expired",
|
||||
)
|
||||
|
||||
website_id = payload.get("website_id")
|
||||
role = payload.get("role")
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Access token missing required claims",
|
||||
)
|
||||
if website_id is None and role != "system_admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Access token missing website scope",
|
||||
)
|
||||
|
||||
return AuthContext(
|
||||
website_id=int(website_id) if website_id is not None else None,
|
||||
role=str(role),
|
||||
wp_user_id=payload.get("wp_user_id"),
|
||||
)
|
||||
|
||||
|
||||
def get_auth_context(
|
||||
authorization: str | None = Header(None, alias="Authorization"),
|
||||
x_website_id: str | None = Header(None, alias="X-Website-ID"),
|
||||
) -> AuthContext:
|
||||
if authorization is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authorization header is required",
|
||||
)
|
||||
parts = authorization.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Authorization header format. Use: Bearer {token}",
|
||||
)
|
||||
|
||||
context = decode_access_token(parts[1])
|
||||
|
||||
# If system_admin explicitly sets a website context via header, use it
|
||||
if context.role == "system_admin" and x_website_id and x_website_id.isdigit():
|
||||
context.website_id = int(x_website_id)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def require_website_auth(
|
||||
auth: AuthContext,
|
||||
allowed_roles: set[str] | None = None,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Check if the authenticated user has required roles.
|
||||
Returns the website_id if scoped to a specific website.
|
||||
Returns None if the user is a system_admin with global access and no specific website context.
|
||||
"""
|
||||
if allowed_roles is not None and auth.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions for this endpoint",
|
||||
)
|
||||
|
||||
if auth.role == "system_admin":
|
||||
if auth.website_id is not None:
|
||||
return auth.website_id
|
||||
return None
|
||||
|
||||
return auth.website_id
|
||||
|
||||
|
||||
def ensure_website_scope_matches(
|
||||
auth_website_id: int | None,
|
||||
payload_website_id: int,
|
||||
) -> None:
|
||||
if auth_website_id is None:
|
||||
return
|
||||
if int(auth_website_id) != int(payload_website_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="website_id in payload must match authenticated website scope",
|
||||
)
|
||||
151
backend/app/core/config.py
Normal file
151
backend/app/core/config.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
Application configuration using Pydantic Settings.
|
||||
|
||||
Loads configuration from environment variables with validation.
|
||||
"""
|
||||
|
||||
from typing import Annotated, Literal, List, Union
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = Field(
|
||||
default="postgresql+asyncpg://postgres:postgres@localhost:5432/irt_bank_soal",
|
||||
description="PostgreSQL database URL with asyncpg driver",
|
||||
)
|
||||
|
||||
# FastAPI
|
||||
SECRET_KEY: str = Field(
|
||||
default="dev-secret-key-change-in-production",
|
||||
description="Secret key for JWT token signing",
|
||||
)
|
||||
API_V1_STR: str = Field(default="/api/v1", description="API v1 prefix")
|
||||
PROJECT_NAME: str = Field(default="IRT Bank Soal", description="Project name")
|
||||
ENVIRONMENT: Literal["development", "staging", "production"] = Field(
|
||||
default="development", description="Environment name"
|
||||
)
|
||||
ENABLE_ADMIN: bool = Field(
|
||||
default=False,
|
||||
description="Enable admin UI and admin-only API routes",
|
||||
)
|
||||
ADMIN_USERNAME: str = Field(
|
||||
default="",
|
||||
description="Admin panel username",
|
||||
)
|
||||
ADMIN_PASSWORD: str = Field(
|
||||
default="",
|
||||
description="Admin panel password (plain env value)",
|
||||
)
|
||||
ADMIN_SESSION_EXPIRE_SECONDS: int = Field(
|
||||
default=3600,
|
||||
description="Admin session lifetime in seconds",
|
||||
)
|
||||
|
||||
# OpenRouter (AI Generation)
|
||||
OPENROUTER_API_KEY: str = Field(
|
||||
default="", description="OpenRouter API key for AI generation"
|
||||
)
|
||||
OPENROUTER_MODEL_QWEN: str = Field(
|
||||
default="qwen/qwen2.5-32b-instruct",
|
||||
description="Balanced Qwen model identifier",
|
||||
)
|
||||
OPENROUTER_MODEL_CHEAP: str = Field(
|
||||
default="mistralai/mistral-small-2603",
|
||||
description="Low-cost model identifier",
|
||||
)
|
||||
OPENROUTER_MODEL_LLAMA: str = Field(
|
||||
default="meta-llama/llama-3.3-70b-instruct",
|
||||
description="Premium Llama model identifier",
|
||||
)
|
||||
OPENROUTER_TIMEOUT: int = Field(default=30, description="OpenRouter API timeout in seconds")
|
||||
OPENROUTER_PROVIDER_ORDER: List[str] = Field(
|
||||
default=["NovitaAI", "AkashML", "Inception"],
|
||||
description="Preferred OpenRouter providers in priority order",
|
||||
)
|
||||
OPENROUTER_ALLOW_PROVIDER_FALLBACKS: bool = Field(
|
||||
default=True,
|
||||
description="Allow OpenRouter to fallback outside preferred providers",
|
||||
)
|
||||
|
||||
# WordPress Integration
|
||||
WORDPRESS_API_URL: str = Field(
|
||||
default="", description="WordPress REST API base URL"
|
||||
)
|
||||
WORDPRESS_AUTH_TOKEN: str = Field(
|
||||
default="", description="WordPress JWT authentication token"
|
||||
)
|
||||
|
||||
# Redis (Celery)
|
||||
REDIS_URL: str = Field(
|
||||
default="redis://localhost:6379/0", description="Redis connection URL"
|
||||
)
|
||||
CELERY_BROKER_URL: str = Field(
|
||||
default="redis://localhost:6379/0", description="Celery broker URL"
|
||||
)
|
||||
CELERY_RESULT_BACKEND: str = Field(
|
||||
default="redis://localhost:6379/0", description="Celery result backend URL"
|
||||
)
|
||||
|
||||
# CORS - stored as list, accepts comma-separated string from env
|
||||
ALLOWED_ORIGINS: Annotated[List[str], NoDecode] = Field(
|
||||
default=["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:5173"],
|
||||
description="List of allowed CORS origins",
|
||||
)
|
||||
|
||||
@field_validator("ALLOWED_ORIGINS", mode="before")
|
||||
@classmethod
|
||||
def parse_allowed_origins(cls, v: Union[str, List[str]]) -> List[str]:
|
||||
"""Parse comma-separated origins into list."""
|
||||
if isinstance(v, str):
|
||||
return [origin.strip() for origin in v.split(",") if origin.strip()]
|
||||
return v
|
||||
|
||||
@field_validator("OPENROUTER_PROVIDER_ORDER", mode="before")
|
||||
@classmethod
|
||||
def parse_provider_order(cls, v: Union[str, List[str]]) -> List[str]:
|
||||
"""Parse comma-separated OpenRouter provider list into array."""
|
||||
if isinstance(v, str):
|
||||
return [provider.strip() for provider in v.split(",") if provider.strip()]
|
||||
return v
|
||||
|
||||
|
||||
# Global settings instance
|
||||
_settings: Union[Settings, None] = None
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""
|
||||
Get application settings instance.
|
||||
|
||||
Returns:
|
||||
Settings: Application settings
|
||||
|
||||
Raises:
|
||||
ValueError: If settings not initialized
|
||||
"""
|
||||
global _settings
|
||||
if _settings is None:
|
||||
_settings = Settings()
|
||||
return _settings
|
||||
|
||||
|
||||
def init_settings(settings: Settings) -> None:
|
||||
"""
|
||||
Initialize settings with custom instance (useful for testing).
|
||||
|
||||
Args:
|
||||
settings: Settings instance to use
|
||||
"""
|
||||
global _settings
|
||||
_settings = settings
|
||||
121
backend/app/core/rate_limit.py
Normal file
121
backend/app/core/rate_limit.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Lightweight in-process rate limiting helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
_lock = threading.Lock()
|
||||
_hits: dict[str, deque[float]] = defaultdict(deque)
|
||||
_redis_client: Redis | None = None
|
||||
_redis_unavailable = False
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
if request.client and request.client.host:
|
||||
return request.client.host
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _get_redis_client() -> Redis | None:
|
||||
global _redis_client
|
||||
if _redis_unavailable:
|
||||
return None
|
||||
if _redis_client is None:
|
||||
settings = get_settings()
|
||||
if not settings.REDIS_URL:
|
||||
return None
|
||||
_redis_client = Redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
return _redis_client
|
||||
|
||||
|
||||
def _enforce_in_memory_rate_limit(
|
||||
*,
|
||||
key: str,
|
||||
scope: str,
|
||||
max_requests: int,
|
||||
window_seconds: int,
|
||||
) -> None:
|
||||
now = time.time()
|
||||
cutoff = now - window_seconds
|
||||
|
||||
with _lock:
|
||||
dq = _hits[key]
|
||||
while dq and dq[0] <= cutoff:
|
||||
dq.popleft()
|
||||
if len(dq) >= max_requests:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Too many requests for {scope}. Please try again later.",
|
||||
)
|
||||
dq.append(now)
|
||||
|
||||
|
||||
async def enforce_rate_limit(
|
||||
request: Request,
|
||||
*,
|
||||
scope: str,
|
||||
max_requests: int,
|
||||
window_seconds: int,
|
||||
) -> None:
|
||||
global _redis_unavailable
|
||||
|
||||
ip = _client_ip(request)
|
||||
key = f"{scope}:{ip}"
|
||||
|
||||
redis = _get_redis_client()
|
||||
if redis is not None:
|
||||
try:
|
||||
current = await redis.incr(key)
|
||||
if current == 1:
|
||||
await redis.expire(key, window_seconds)
|
||||
if current > max_requests:
|
||||
ttl = await redis.ttl(key)
|
||||
retry_after = ttl if ttl and ttl > 0 else window_seconds
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Too many requests for {scope}. Please try again later.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
return
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_redis_unavailable = True
|
||||
logger.warning("Redis rate limiter unavailable; falling back to memory: %s", exc)
|
||||
|
||||
_enforce_in_memory_rate_limit(
|
||||
key=key,
|
||||
scope=scope,
|
||||
max_requests=max_requests,
|
||||
window_seconds=window_seconds,
|
||||
)
|
||||
|
||||
|
||||
async def close_rate_limit() -> None:
|
||||
global _redis_client
|
||||
if _redis_client is None:
|
||||
return
|
||||
try:
|
||||
await _redis_client.aclose()
|
||||
finally:
|
||||
_redis_client = None
|
||||
|
||||
|
||||
def reset_rate_limit_state() -> None:
|
||||
"""Reset local limiter state for tests."""
|
||||
global _redis_client, _redis_unavailable
|
||||
_redis_client = None
|
||||
_redis_unavailable = False
|
||||
with _lock:
|
||||
_hits.clear()
|
||||
88
backend/app/database.py
Normal file
88
backend/app/database.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Database configuration and session management for async PostgreSQL.
|
||||
|
||||
Uses SQLAlchemy 2.0 async ORM with asyncpg driver.
|
||||
"""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Create async engine with connection pooling
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.ENVIRONMENT == "development", # Log SQL in development
|
||||
pool_pre_ping=True, # Verify connections before using
|
||||
pool_size=10, # Number of connections to maintain
|
||||
max_overflow=20, # Max additional connections beyond pool_size
|
||||
pool_recycle=3600, # Recycle connections after 1 hour
|
||||
)
|
||||
|
||||
# Create async session factory
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False, # Prevent attributes from being expired after commit
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all database models."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
Dependency for getting async database session.
|
||||
|
||||
Yields:
|
||||
AsyncSession: Database session
|
||||
|
||||
Example:
|
||||
```python
|
||||
@app.get("/items/")
|
||||
async def get_items(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Item))
|
||||
return result.scalars().all()
|
||||
```
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""
|
||||
Initialize database - create all tables.
|
||||
|
||||
Note: In production, use Alembic migrations instead.
|
||||
This is useful for development and testing.
|
||||
"""
|
||||
if settings.ENVIRONMENT == "production":
|
||||
return
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
"""Close database connections."""
|
||||
await engine.dispose()
|
||||
265
backend/app/main.py
Normal file
265
backend/app/main.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
IRT Bank Soal - Adaptive Question Bank System
|
||||
|
||||
Main FastAPI application entry point.
|
||||
|
||||
Features:
|
||||
- CTT (Classical Test Theory) scoring with exact Excel formulas
|
||||
- IRT (Item Response Theory) support for adaptive testing
|
||||
- Multi-website support for WordPress integration
|
||||
- AI-powered question generation
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.v1.session import (
|
||||
admin_router as adaptive_admin_router,
|
||||
router as adaptive_session_router,
|
||||
)
|
||||
from app.core.rate_limit import close_rate_limit
|
||||
from app.admin_web import (
|
||||
configure_admin_web,
|
||||
router as admin_web_router,
|
||||
shutdown_admin_web,
|
||||
)
|
||||
from app.core.config import get_settings
|
||||
from app.database import close_db, init_db
|
||||
from app.routers import (
|
||||
admin_router,
|
||||
ai_router,
|
||||
auth_router,
|
||||
import_export_router,
|
||||
reports_router,
|
||||
sessions_router,
|
||||
tryouts_router,
|
||||
wordpress_router,
|
||||
websites_router,
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def validate_security_config() -> None:
|
||||
"""
|
||||
Enforce minimum security requirements for production deployments.
|
||||
"""
|
||||
if settings.ENVIRONMENT != "production":
|
||||
return
|
||||
|
||||
insecure_secret_values = {
|
||||
"",
|
||||
"dev-secret-key-change-in-production",
|
||||
"your-secret-key-here-change-in-production",
|
||||
}
|
||||
if settings.SECRET_KEY in insecure_secret_values:
|
||||
raise RuntimeError(
|
||||
"In production, SECRET_KEY must be set to a strong non-default value."
|
||||
)
|
||||
|
||||
if settings.ENABLE_ADMIN and (
|
||||
not settings.ADMIN_USERNAME
|
||||
or not settings.ADMIN_PASSWORD
|
||||
or settings.ADMIN_PASSWORD == "change-me"
|
||||
):
|
||||
raise RuntimeError(
|
||||
"In production with ENABLE_ADMIN=true, ADMIN_USERNAME and ADMIN_PASSWORD must be configured securely."
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""
|
||||
Application lifespan manager.
|
||||
|
||||
Handles startup and shutdown events.
|
||||
"""
|
||||
validate_security_config()
|
||||
|
||||
# Startup: Initialize database
|
||||
await init_db()
|
||||
if settings.ENABLE_ADMIN:
|
||||
await configure_admin_web()
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown: Close database connections
|
||||
if settings.ENABLE_ADMIN:
|
||||
await shutdown_admin_web()
|
||||
await close_rate_limit()
|
||||
await close_db()
|
||||
|
||||
|
||||
# Initialize FastAPI application
|
||||
app = FastAPI(
|
||||
title="IRT Bank Soal",
|
||||
description="""
|
||||
## Adaptive Question Bank System with IRT/CTT Scoring
|
||||
|
||||
This API provides a comprehensive backend for adaptive assessment systems.
|
||||
|
||||
### Features
|
||||
- **CTT Scoring**: Classical Test Theory with exact Excel formula compatibility
|
||||
- **IRT Support**: Item Response Theory for adaptive testing (1PL Rasch model)
|
||||
- **Multi-Site**: Single backend serving multiple WordPress sites
|
||||
- **AI Generation**: Automatic question variant generation
|
||||
|
||||
### Scoring Formulas (PRD Section 13.1)
|
||||
- **CTT p-value**: `p = Σ Benar / Total Peserta`
|
||||
- **CTT Bobot**: `Bobot = 1 - p`
|
||||
- **CTT NM**: `NM = (Total_Bobot_Siswa / Total_Bobot_Max) × 1000`
|
||||
- **CTT NN**: `NN = 500 + 100 × ((NM - Rataan) / SB)`
|
||||
|
||||
### Authentication
|
||||
Most endpoints require `X-Website-ID` header for multi-site isolation.
|
||||
""",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Configure CORS middleware
|
||||
# Parse ALLOWED_ORIGINS from settings (comma-separated string)
|
||||
allowed_origins = settings.ALLOWED_ORIGINS
|
||||
if isinstance(allowed_origins, str):
|
||||
allowed_origins = [origin.strip() for origin in allowed_origins.split(",") if origin.strip()]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=allowed_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@app.get(
|
||||
"/",
|
||||
summary="Health check",
|
||||
description="Returns API status and version information.",
|
||||
tags=["health"],
|
||||
)
|
||||
async def root():
|
||||
"""
|
||||
Health check endpoint.
|
||||
|
||||
Returns basic API information for monitoring and load balancer checks.
|
||||
"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "IRT Bank Soal",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
}
|
||||
|
||||
|
||||
@app.get(
|
||||
"/health",
|
||||
summary="Detailed health check",
|
||||
description="Returns detailed health status including database connectivity.",
|
||||
tags=["health"],
|
||||
)
|
||||
async def health_check():
|
||||
"""
|
||||
Detailed health check endpoint.
|
||||
|
||||
Includes database connectivity verification.
|
||||
"""
|
||||
from app.database import engine
|
||||
from sqlalchemy import text
|
||||
|
||||
db_status = "unknown"
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
db_status = "connected"
|
||||
except Exception as e:
|
||||
db_status = f"error: {str(e)}"
|
||||
|
||||
return {
|
||||
"status": "healthy" if db_status == "connected" else "degraded",
|
||||
"service": "IRT Bank Soal",
|
||||
"version": "1.0.0",
|
||||
"database": db_status,
|
||||
"environment": settings.ENVIRONMENT,
|
||||
}
|
||||
|
||||
|
||||
# Include API routers with version prefix
|
||||
app.include_router(
|
||||
auth_router,
|
||||
prefix=f"{settings.API_V1_STR}",
|
||||
)
|
||||
app.include_router(
|
||||
import_export_router,
|
||||
)
|
||||
app.include_router(
|
||||
sessions_router,
|
||||
prefix=f"{settings.API_V1_STR}",
|
||||
)
|
||||
app.include_router(
|
||||
adaptive_session_router,
|
||||
prefix=f"{settings.API_V1_STR}/session",
|
||||
)
|
||||
app.include_router(
|
||||
tryouts_router,
|
||||
prefix=f"{settings.API_V1_STR}",
|
||||
)
|
||||
app.include_router(
|
||||
wordpress_router,
|
||||
prefix=f"{settings.API_V1_STR}",
|
||||
)
|
||||
app.include_router(
|
||||
reports_router,
|
||||
prefix=f"{settings.API_V1_STR}",
|
||||
)
|
||||
app.include_router(
|
||||
websites_router,
|
||||
prefix=f"{settings.API_V1_STR}",
|
||||
)
|
||||
|
||||
if settings.ENABLE_ADMIN:
|
||||
app.include_router(
|
||||
ai_router,
|
||||
prefix=f"{settings.API_V1_STR}",
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
adaptive_admin_router,
|
||||
prefix=f"{settings.API_V1_STR}/admin",
|
||||
)
|
||||
|
||||
app.include_router(admin_web_router)
|
||||
|
||||
# Include admin API router for custom actions
|
||||
app.include_router(
|
||||
admin_router,
|
||||
prefix=f"{settings.API_V1_STR}",
|
||||
)
|
||||
|
||||
|
||||
# Placeholder routers for future implementation
|
||||
# These will be implemented in subsequent phases
|
||||
|
||||
# app.include_router(
|
||||
# items_router,
|
||||
# prefix=f"{settings.API_V1_STR}",
|
||||
# tags=["items"],
|
||||
# )
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=settings.ENVIRONMENT == "development",
|
||||
)
|
||||
33
backend/app/models/__init__.py
Normal file
33
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Database models for IRT Bank Soal system.
|
||||
|
||||
Exports all SQLAlchemy ORM models for use in the application.
|
||||
"""
|
||||
|
||||
from app.database import Base
|
||||
from app.models.ai_generation_run import AIGenerationRun
|
||||
from app.models.item import Item
|
||||
from app.models.report_schedule import ReportScheduleModel
|
||||
from app.models.session import Session
|
||||
from app.models.tryout import Tryout
|
||||
from app.models.tryout_import_snapshot import TryoutImportSnapshot
|
||||
from app.models.tryout_snapshot_question import TryoutSnapshotQuestion
|
||||
from app.models.tryout_stats import TryoutStats
|
||||
from app.models.user import User
|
||||
from app.models.user_answer import UserAnswer
|
||||
from app.models.website import Website
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"AIGenerationRun",
|
||||
"User",
|
||||
"Website",
|
||||
"Tryout",
|
||||
"TryoutImportSnapshot",
|
||||
"TryoutSnapshotQuestion",
|
||||
"Item",
|
||||
"ReportScheduleModel",
|
||||
"Session",
|
||||
"UserAnswer",
|
||||
"TryoutStats",
|
||||
]
|
||||
74
backend/app/models/ai_generation_run.py
Normal file
74
backend/app/models/ai_generation_run.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
AI generation run model.
|
||||
|
||||
Represents one admin generation request that can produce one or many variants.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AIGenerationRun(Base):
|
||||
__tablename__ = "ai_generation_runs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
basis_item_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("items.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Basis item ID",
|
||||
)
|
||||
source_snapshot_question_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("tryout_snapshot_questions.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Source snapshot question ID",
|
||||
)
|
||||
target_level: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
comment="Target level (mudah/sulit)",
|
||||
)
|
||||
requested_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=1,
|
||||
comment="Requested output count",
|
||||
)
|
||||
model: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
comment="Model identifier",
|
||||
)
|
||||
prompt_version: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="v1",
|
||||
comment="Prompt template version",
|
||||
)
|
||||
operator_notes: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="Optional admin notes",
|
||||
)
|
||||
created_by: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
comment="Admin username",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
generated_items: Mapped[list["Item"]] = relationship(
|
||||
"Item",
|
||||
back_populates="generation_run",
|
||||
primaryjoin="AIGenerationRun.id == Item.generation_run_id",
|
||||
foreign_keys="Item.generation_run_id",
|
||||
lazy="selectin",
|
||||
)
|
||||
270
backend/app/models/item.py
Normal file
270
backend/app/models/item.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
Item model for questions with CTT and IRT parameters.
|
||||
|
||||
Represents individual questions with both classical test theory (CTT)
|
||||
and item response theory (IRT) parameters.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Union
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
ForeignKeyConstraint,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Item(Base):
|
||||
"""
|
||||
Item model representing individual questions.
|
||||
|
||||
Supports both CTT (p, bobot, category) and IRT (b, se) parameters.
|
||||
Tracks AI generation metadata and calibration status.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
tryout_id: Tryout identifier
|
||||
website_id: Website identifier
|
||||
slot: Question position in tryout
|
||||
level: Difficulty level (mudah, sedang, sulit)
|
||||
stem: Question text
|
||||
options: JSON array of answer options
|
||||
correct_answer: Correct option (A, B, C, D)
|
||||
explanation: Answer explanation
|
||||
ctt_p: CTT difficulty (proportion correct)
|
||||
ctt_bobot: CTT weight (1 - p)
|
||||
ctt_category: CTT difficulty category
|
||||
irt_b: IRT difficulty parameter [-3, +3]
|
||||
irt_se: IRT standard error
|
||||
calibrated: Calibration status
|
||||
calibration_sample_size: Sample size for calibration
|
||||
generated_by: Generation source (manual, ai)
|
||||
ai_model: AI model used (if generated by AI)
|
||||
basis_item_id: Original item ID (for AI variants)
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
tryout: Tryout relationship
|
||||
user_answers: User responses to this item
|
||||
"""
|
||||
|
||||
__tablename__ = "items"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Foreign keys
|
||||
tryout_id: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, index=True, comment="Tryout identifier"
|
||||
)
|
||||
website_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Website identifier",
|
||||
)
|
||||
|
||||
# Position and difficulty
|
||||
slot: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, comment="Question position in tryout"
|
||||
)
|
||||
level: Mapped[Literal["mudah", "sedang", "sulit"]] = mapped_column(
|
||||
String(50), nullable=False, comment="Difficulty level"
|
||||
)
|
||||
|
||||
# Question content
|
||||
stem: Mapped[str] = mapped_column(Text, nullable=False, comment="Question text")
|
||||
options: Mapped[dict] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
comment="JSON object with options (e.g., {\"A\": \"option1\", \"B\": \"option2\"})",
|
||||
)
|
||||
correct_answer: Mapped[str] = mapped_column(
|
||||
String(10), nullable=False, comment="Correct option (A, B, C, D)"
|
||||
)
|
||||
explanation: Mapped[Union[str, None]] = mapped_column(
|
||||
Text, nullable=True, comment="Answer explanation"
|
||||
)
|
||||
|
||||
# CTT parameters
|
||||
ctt_p: Mapped[Union[float, None]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
comment="CTT difficulty (proportion correct)",
|
||||
)
|
||||
ctt_bobot: Mapped[Union[float, None]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
comment="CTT weight (1 - p)",
|
||||
)
|
||||
ctt_category: Mapped[Union[Literal["mudah", "sedang", "sulit"], None]] = mapped_column(
|
||||
String(50),
|
||||
nullable=True,
|
||||
comment="CTT difficulty category",
|
||||
)
|
||||
|
||||
# IRT parameters (1PL Rasch model)
|
||||
irt_b: Mapped[Union[float, None]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
comment="IRT difficulty parameter [-3, +3]",
|
||||
)
|
||||
irt_se: Mapped[Union[float, None]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
comment="IRT standard error",
|
||||
)
|
||||
|
||||
# Calibration status
|
||||
calibrated: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, comment="Calibration status"
|
||||
)
|
||||
calibration_sample_size: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="Sample size for calibration",
|
||||
)
|
||||
|
||||
# AI generation metadata
|
||||
generated_by: Mapped[Literal["manual", "ai"]] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="manual",
|
||||
comment="Generation source",
|
||||
)
|
||||
ai_model: Mapped[Union[str, None]] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
comment="AI model used (if generated by AI)",
|
||||
)
|
||||
basis_item_id: Mapped[Union[int, None]] = mapped_column(
|
||||
ForeignKey("items.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
comment="Original item ID (for AI variants)",
|
||||
)
|
||||
generation_run_id: Mapped[Union[int, None]] = mapped_column(
|
||||
ForeignKey("ai_generation_runs.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="AI generation run ID",
|
||||
)
|
||||
source_snapshot_question_id: Mapped[Union[int, None]] = mapped_column(
|
||||
ForeignKey("tryout_snapshot_questions.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Source snapshot question ID",
|
||||
)
|
||||
variant_status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="active",
|
||||
comment="Lifecycle status (active/draft/approved/rejected/archived/stale)",
|
||||
)
|
||||
reviewed_by: Mapped[Union[str, None]] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
comment="Reviewer username",
|
||||
)
|
||||
reviewed_at: Mapped[Union[datetime, None]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Review timestamp",
|
||||
)
|
||||
review_notes: Mapped[Union[str, None]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="Review notes",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tryout: Mapped["Tryout"] = relationship(
|
||||
"Tryout", back_populates="items", lazy="selectin"
|
||||
)
|
||||
user_answers: Mapped[list["UserAnswer"]] = relationship(
|
||||
"UserAnswer", back_populates="item", lazy="selectin", cascade="all, delete-orphan"
|
||||
)
|
||||
basis_item: Mapped[Union["Item", None]] = relationship(
|
||||
"Item",
|
||||
remote_side=[id],
|
||||
back_populates="variants",
|
||||
lazy="selectin",
|
||||
single_parent=True,
|
||||
)
|
||||
variants: Mapped[list["Item"]] = relationship(
|
||||
"Item",
|
||||
back_populates="basis_item",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
generation_run: Mapped[Union["AIGenerationRun", None]] = relationship(
|
||||
"AIGenerationRun",
|
||||
back_populates="generated_items",
|
||||
foreign_keys=[generation_run_id],
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
# Constraints and indexes
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(
|
||||
["website_id", "tryout_id"],
|
||||
["tryouts.website_id", "tryouts.tryout_id"],
|
||||
name="fk_items_tryout",
|
||||
ondelete="CASCADE",
|
||||
onupdate="CASCADE",
|
||||
),
|
||||
Index(
|
||||
"ix_items_tryout_id_website_id_slot",
|
||||
"tryout_id",
|
||||
"website_id",
|
||||
"slot",
|
||||
"level",
|
||||
unique=False,
|
||||
),
|
||||
Index("ix_items_calibrated", "calibrated"),
|
||||
Index("ix_items_basis_item_id", "basis_item_id"),
|
||||
Index("ix_items_variant_status", "variant_status"),
|
||||
# IRT b parameter constraint [-3, +3]
|
||||
CheckConstraint(
|
||||
"irt_b IS NULL OR (irt_b >= -3 AND irt_b <= 3)",
|
||||
"ck_irt_b_range",
|
||||
),
|
||||
# CTT p constraint [0, 1]
|
||||
CheckConstraint(
|
||||
"ctt_p IS NULL OR (ctt_p >= 0 AND ctt_p <= 1)",
|
||||
"ck_ctt_p_range",
|
||||
),
|
||||
# CTT bobot constraint [0, 1]
|
||||
CheckConstraint(
|
||||
"ctt_bobot IS NULL OR (ctt_bobot >= 0 AND ctt_bobot <= 1)",
|
||||
"ck_ctt_bobot_range",
|
||||
),
|
||||
# Slot must be positive
|
||||
CheckConstraint("slot > 0", "ck_slot_positive"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Item(id={self.id}, slot={self.slot}, level={self.level})>"
|
||||
46
backend/app/models/report_schedule.py
Normal file
46
backend/app/models/report_schedule.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Persistent report schedule model.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, JSON, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ReportScheduleModel(Base):
|
||||
"""Database-backed report schedule configuration."""
|
||||
|
||||
__tablename__ = "report_schedules"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
schedule_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
index=True,
|
||||
comment="Public schedule identifier",
|
||||
)
|
||||
report_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
schedule: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
tryout_ids: Mapped[list[str]] = mapped_column(JSON, nullable=False)
|
||||
website_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
recipients: Mapped[list[str]] = mapped_column(JSON, nullable=False)
|
||||
format: Mapped[str] = mapped_column(String(10), nullable=False, default="xlsx")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
last_run: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
next_run: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_report_schedules_website_active", "website_id", "is_active"),
|
||||
)
|
||||
219
backend/app/models/session.py
Normal file
219
backend/app/models/session.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
Session model for tryout attempt tracking.
|
||||
|
||||
Represents a student's attempt at a tryout with scoring information.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Union
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
ForeignKeyConstraint,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Session(Base):
|
||||
"""
|
||||
Session model representing a student's tryout attempt.
|
||||
|
||||
Tracks session metadata, scoring results, and IRT estimates.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
session_id: Unique session identifier
|
||||
wp_user_id: WordPress user ID
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
start_time: Session start timestamp
|
||||
end_time: Session end timestamp
|
||||
is_completed: Completion status
|
||||
scoring_mode_used: Scoring mode used for this session
|
||||
total_benar: Total correct answers
|
||||
total_bobot_earned: Total weight earned
|
||||
NM: Nilai Mentah (raw score) [0, 1000]
|
||||
NN: Nilai Nasional (normalized score) [0, 1000]
|
||||
theta: IRT ability estimate [-3, +3]
|
||||
theta_se: IRT standard error
|
||||
rataan_used: Mean value used for normalization
|
||||
sb_used: Standard deviation used for normalization
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
user: User relationship
|
||||
tryout: Tryout relationship
|
||||
user_answers: User's responses in this session
|
||||
"""
|
||||
|
||||
__tablename__ = "sessions"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Session identifier (globally unique)
|
||||
session_id: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
index=True,
|
||||
comment="Unique session identifier",
|
||||
)
|
||||
|
||||
# Foreign keys
|
||||
wp_user_id: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, comment="WordPress user ID"
|
||||
)
|
||||
website_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
comment="Website identifier",
|
||||
)
|
||||
tryout_id: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, comment="Tryout identifier"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
start_time: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
end_time: Mapped[Union[datetime, None]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="Session end timestamp"
|
||||
)
|
||||
expires_at: Mapped[Union[datetime, None]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="Session expiration timestamp"
|
||||
)
|
||||
is_completed: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, comment="Completion status"
|
||||
)
|
||||
|
||||
# Scoring metadata
|
||||
scoring_mode_used: Mapped[Literal["ctt", "irt", "hybrid"]] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
comment="Scoring mode used for this session",
|
||||
)
|
||||
|
||||
# CTT scoring results
|
||||
total_benar: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, comment="Total correct answers"
|
||||
)
|
||||
total_bobot_earned: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.0, comment="Total weight earned"
|
||||
)
|
||||
NM: Mapped[Union[int, None]] = mapped_column(
|
||||
name='NM',
|
||||
quote=True,
|
||||
nullable=True,
|
||||
comment="Nilai Mentah (raw score) [0, 1000]",
|
||||
)
|
||||
NN: Mapped[Union[int, None]] = mapped_column(
|
||||
name='NN',
|
||||
quote=True,
|
||||
nullable=True,
|
||||
comment="Nilai Nasional (normalized score) [0, 1000]",
|
||||
)
|
||||
|
||||
# IRT scoring results
|
||||
theta: Mapped[Union[float, None]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
comment="IRT ability estimate [-3, +3]",
|
||||
)
|
||||
theta_se: Mapped[Union[float, None]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
comment="IRT standard error",
|
||||
)
|
||||
|
||||
# Normalization metadata
|
||||
rataan_used: Mapped[Union[float, None]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
comment="Mean value used for normalization",
|
||||
)
|
||||
sb_used: Mapped[Union[float, None]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
comment="Standard deviation used for normalization",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="sessions",
|
||||
lazy="selectin",
|
||||
overlaps="tryout,sessions",
|
||||
)
|
||||
tryout: Mapped["Tryout"] = relationship(
|
||||
"Tryout",
|
||||
back_populates="sessions",
|
||||
lazy="selectin",
|
||||
overlaps="user",
|
||||
)
|
||||
user_answers: Mapped[list["UserAnswer"]] = relationship(
|
||||
"UserAnswer", back_populates="session", lazy="selectin", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# Constraints and indexes
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(
|
||||
["website_id", "tryout_id"],
|
||||
["tryouts.website_id", "tryouts.tryout_id"],
|
||||
name="fk_sessions_tryout",
|
||||
ondelete="CASCADE",
|
||||
onupdate="CASCADE",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["wp_user_id", "website_id"],
|
||||
["users.wp_user_id", "users.website_id"],
|
||||
name="fk_sessions_user",
|
||||
ondelete="CASCADE",
|
||||
onupdate="CASCADE",
|
||||
),
|
||||
Index("ix_sessions_wp_user_id", "wp_user_id"),
|
||||
Index("ix_sessions_website_id", "website_id"),
|
||||
Index("ix_sessions_tryout_id", "tryout_id"),
|
||||
Index("ix_sessions_is_completed", "is_completed"),
|
||||
# Score constraints [0, 1000] - quote column names to match quoted identifiers
|
||||
CheckConstraint(
|
||||
'"NM" IS NULL OR ("NM" >= 0 AND "NM" <= 1000)',
|
||||
"ck_nm_range",
|
||||
),
|
||||
CheckConstraint(
|
||||
'"NN" IS NULL OR ("NN" >= 0 AND "NN" <= 1000)',
|
||||
"ck_nn_range",
|
||||
),
|
||||
# IRT theta constraint [-3, +3]
|
||||
CheckConstraint(
|
||||
"theta IS NULL OR (theta >= -3 AND theta <= 3)",
|
||||
"ck_theta_range",
|
||||
),
|
||||
# Total correct must be non-negative
|
||||
CheckConstraint("total_benar >= 0", "ck_total_benar_non_negative"),
|
||||
# Total bobot must be non-negative
|
||||
CheckConstraint("total_bobot_earned >= 0", "ck_total_bobot_non_negative"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Session(session_id={self.session_id}, tryout_id={self.tryout_id})>"
|
||||
200
backend/app/models/tryout.py
Normal file
200
backend/app/models/tryout.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Tryout model with configuration for assessment sessions.
|
||||
|
||||
Represents tryout exams with configurable scoring, selection, and normalization modes.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Union
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Tryout(Base):
|
||||
"""
|
||||
Tryout model with configuration for assessment sessions.
|
||||
|
||||
Supports multiple scoring modes (CTT, IRT, hybrid), selection strategies
|
||||
(fixed, adaptive, hybrid), and normalization modes (static, dynamic, hybrid).
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier (unique per website)
|
||||
name: Tryout name
|
||||
description: Tryout description
|
||||
scoring_mode: Scoring algorithm (ctt, irt, hybrid)
|
||||
selection_mode: Item selection strategy (fixed, adaptive, hybrid)
|
||||
normalization_mode: Normalization method (static, dynamic, hybrid)
|
||||
min_sample_for_dynamic: Minimum sample size for dynamic normalization
|
||||
static_rataan: Static mean value for manual normalization
|
||||
static_sb: Static standard deviation for manual normalization
|
||||
AI_generation_enabled: Enable/disable AI question generation
|
||||
hybrid_transition_slot: Slot number to transition from fixed to adaptive
|
||||
min_calibration_sample: Minimum responses needed for IRT calibration
|
||||
theta_estimation_method: Method for estimating theta (mle, map, eap)
|
||||
fallback_to_ctt_on_error: Fallback to CTT if IRT fails
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
website: Website relationship
|
||||
items: Items in this tryout
|
||||
sessions: Sessions for this tryout
|
||||
stats: Tryout statistics
|
||||
"""
|
||||
|
||||
__tablename__ = "tryouts"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Foreign keys
|
||||
website_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Website identifier",
|
||||
)
|
||||
|
||||
# Tryout identifier (unique per website)
|
||||
tryout_id: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Tryout identifier (unique per website)",
|
||||
)
|
||||
|
||||
# Basic information
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, comment="Tryout name"
|
||||
)
|
||||
description: Mapped[Union[str, None]] = mapped_column(
|
||||
String(1000), nullable=True, comment="Tryout description"
|
||||
)
|
||||
|
||||
# Scoring mode: ctt (Classical Test Theory), irt (Item Response Theory), hybrid
|
||||
scoring_mode: Mapped[Literal["ctt", "irt", "hybrid"]] = mapped_column(
|
||||
String(50), nullable=False, default="ctt", comment="Scoring mode"
|
||||
)
|
||||
|
||||
# Selection mode: fixed (slot order), adaptive (CAT), hybrid (mixed)
|
||||
selection_mode: Mapped[Literal["fixed", "adaptive", "hybrid"]] = mapped_column(
|
||||
String(50), nullable=False, default="fixed", comment="Item selection mode"
|
||||
)
|
||||
|
||||
# Normalization mode: static (hardcoded), dynamic (real-time), hybrid
|
||||
normalization_mode: Mapped[Literal["static", "dynamic", "hybrid"]] = mapped_column(
|
||||
String(50), nullable=False, default="static", comment="Normalization mode"
|
||||
)
|
||||
|
||||
# Normalization settings
|
||||
min_sample_for_dynamic: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=100,
|
||||
comment="Minimum sample size for dynamic normalization",
|
||||
)
|
||||
static_rataan: Mapped[float] = mapped_column(
|
||||
Float,
|
||||
nullable=False,
|
||||
default=500.0,
|
||||
comment="Static mean value for manual normalization",
|
||||
)
|
||||
static_sb: Mapped[float] = mapped_column(
|
||||
Float,
|
||||
nullable=False,
|
||||
default=100.0,
|
||||
comment="Static standard deviation for manual normalization",
|
||||
)
|
||||
|
||||
# AI generation settings
|
||||
ai_generation_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment="Enable/disable AI question generation",
|
||||
)
|
||||
|
||||
# Hybrid mode settings
|
||||
hybrid_transition_slot: Mapped[Union[int, None]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Slot number to transition from fixed to adaptive (hybrid mode)",
|
||||
)
|
||||
|
||||
# IRT settings
|
||||
min_calibration_sample: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=100,
|
||||
comment="Minimum responses needed for IRT calibration",
|
||||
)
|
||||
theta_estimation_method: Mapped[Literal["mle", "map", "eap"]] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="mle",
|
||||
comment="Method for estimating theta",
|
||||
)
|
||||
fallback_to_ctt_on_error: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
comment="Fallback to CTT if IRT fails",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
website: Mapped["Website"] = relationship(
|
||||
"Website", back_populates="tryouts", lazy="selectin"
|
||||
)
|
||||
items: Mapped[list["Item"]] = relationship(
|
||||
"Item", back_populates="tryout", lazy="selectin", cascade="all, delete-orphan"
|
||||
)
|
||||
sessions: Mapped[list["Session"]] = relationship(
|
||||
"Session",
|
||||
back_populates="tryout",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
overlaps="user",
|
||||
)
|
||||
stats: Mapped["TryoutStats"] = relationship(
|
||||
"TryoutStats", back_populates="tryout", lazy="selectin", uselist=False
|
||||
)
|
||||
|
||||
# Constraints and indexes
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"website_id",
|
||||
"tryout_id",
|
||||
name="uq_tryouts_website_id_tryout_id",
|
||||
),
|
||||
CheckConstraint("min_sample_for_dynamic > 0", "ck_min_sample_positive"),
|
||||
CheckConstraint("static_rataan > 0", "ck_static_rataan_positive"),
|
||||
CheckConstraint("static_sb > 0", "ck_static_sb_positive"),
|
||||
CheckConstraint("min_calibration_sample > 0", "ck_min_calibration_positive"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Tryout(id={self.id}, tryout_id={self.tryout_id}, website_id={self.website_id})>"
|
||||
103
backend/app/models/tryout_import_snapshot.py
Normal file
103
backend/app/models/tryout_import_snapshot.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Snapshot archive for imported external tryout payloads.
|
||||
|
||||
Stores each imported JSON export so the backend can trace source changes
|
||||
without treating the source file itself as the system of record.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class TryoutImportSnapshot(Base):
|
||||
__tablename__ = "tryout_import_snapshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
website_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Website identifier",
|
||||
)
|
||||
source_tryout_id: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="External source tryout identifier",
|
||||
)
|
||||
source_key: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
comment="External tryout object key in source payload",
|
||||
)
|
||||
title: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
comment="Imported tryout title",
|
||||
)
|
||||
source_permalink: Mapped[Optional[str]] = mapped_column(
|
||||
String(1024),
|
||||
nullable=True,
|
||||
comment="Imported source permalink",
|
||||
)
|
||||
source_status: Mapped[Optional[str]] = mapped_column(
|
||||
String(50),
|
||||
nullable=True,
|
||||
comment="Imported source status",
|
||||
)
|
||||
exported_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Timestamp from source export metadata",
|
||||
)
|
||||
source_created_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Source tryout created timestamp",
|
||||
)
|
||||
source_modified_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Source tryout modified timestamp",
|
||||
)
|
||||
exported_by: Mapped[Optional[str]] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
comment="Source exporter identity",
|
||||
)
|
||||
question_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="Number of questions in imported payload",
|
||||
)
|
||||
result_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="Number of result rows in imported payload",
|
||||
)
|
||||
payload_checksum: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
comment="Checksum for the imported payload",
|
||||
)
|
||||
raw_payload: Mapped[dict] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
comment="Original imported payload",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
139
backend/app/models/tryout_snapshot_question.py
Normal file
139
backend/app/models/tryout_snapshot_question.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Read-only normalized reference rows for imported tryout questions.
|
||||
|
||||
These rows reflect the latest imported source version of each question and are
|
||||
kept separate from operational items and AI-generated variants.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class TryoutSnapshotQuestion(Base):
|
||||
__tablename__ = "tryout_snapshot_questions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
website_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Website identifier",
|
||||
)
|
||||
source_tryout_id: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="External source tryout identifier",
|
||||
)
|
||||
source_question_id: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
comment="External source question identifier",
|
||||
)
|
||||
latest_snapshot_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("tryout_import_snapshots.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Latest snapshot containing this question",
|
||||
)
|
||||
question_title: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
nullable=False,
|
||||
comment="Imported title or short label",
|
||||
)
|
||||
question_html: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
nullable=False,
|
||||
comment="Imported question body HTML",
|
||||
)
|
||||
explanation_html: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="Imported explanation HTML",
|
||||
)
|
||||
raw_options: Mapped[list] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
comment="Raw source options payload",
|
||||
)
|
||||
correct_answer: Mapped[str] = mapped_column(
|
||||
String(10),
|
||||
nullable=False,
|
||||
comment="Imported correct answer key",
|
||||
)
|
||||
category_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Imported category id",
|
||||
)
|
||||
category_name: Mapped[Optional[str]] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
comment="Imported category name",
|
||||
)
|
||||
category_code: Mapped[Optional[str]] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
comment="Imported category code",
|
||||
)
|
||||
option_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="Count of source options",
|
||||
)
|
||||
has_option_labels: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment="Whether source options include visible labels",
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
comment="Whether question is still present in latest source import",
|
||||
)
|
||||
content_checksum: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
comment="Checksum of normalized question content",
|
||||
)
|
||||
raw_payload: Mapped[dict] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
comment="Original source question payload",
|
||||
)
|
||||
first_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"website_id",
|
||||
"source_tryout_id",
|
||||
"source_question_id",
|
||||
name="uq_snapshot_questions_website_tryout_question",
|
||||
),
|
||||
)
|
||||
168
backend/app/models/tryout_stats.py
Normal file
168
backend/app/models/tryout_stats.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
TryoutStats model for tracking tryout-level statistics.
|
||||
|
||||
Maintains running statistics for dynamic normalization and reporting.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Union
|
||||
|
||||
from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
ForeignKeyConstraint,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class TryoutStats(Base):
|
||||
"""
|
||||
TryoutStats model for maintaining tryout-level statistics.
|
||||
|
||||
Tracks participant counts, score distributions, and calculated
|
||||
normalization parameters (rataan, sb) for dynamic normalization.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
participant_count: Number of completed sessions
|
||||
total_nm_sum: Running sum of NM scores
|
||||
total_nm_sq_sum: Running sum of squared NM scores (for variance calc)
|
||||
rataan: Calculated mean of NM scores
|
||||
sb: Calculated standard deviation of NM scores
|
||||
min_nm: Minimum NM score observed
|
||||
max_nm: Maximum NM score observed
|
||||
last_calculated: Timestamp of last statistics update
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
tryout: Tryout relationship
|
||||
"""
|
||||
|
||||
__tablename__ = "tryout_stats"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Foreign keys
|
||||
website_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Website identifier",
|
||||
)
|
||||
tryout_id: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Tryout identifier",
|
||||
)
|
||||
|
||||
# Running statistics
|
||||
participant_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="Number of completed sessions",
|
||||
)
|
||||
total_nm_sum: Mapped[float] = mapped_column(
|
||||
Float,
|
||||
nullable=False,
|
||||
default=0.0,
|
||||
comment="Running sum of NM scores",
|
||||
)
|
||||
total_nm_sq_sum: Mapped[float] = mapped_column(
|
||||
Float,
|
||||
nullable=False,
|
||||
default=0.0,
|
||||
comment="Running sum of squared NM scores",
|
||||
)
|
||||
|
||||
# Calculated statistics
|
||||
rataan: Mapped[Union[float, None]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
comment="Calculated mean of NM scores",
|
||||
)
|
||||
sb: Mapped[Union[float, None]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
comment="Calculated standard deviation of NM scores",
|
||||
)
|
||||
|
||||
# Score range
|
||||
min_nm: Mapped[Union[int, None]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Minimum NM score observed",
|
||||
)
|
||||
max_nm: Mapped[Union[int, None]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
comment="Maximum NM score observed",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
last_calculated: Mapped[Union[datetime, None]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Timestamp of last statistics update",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tryout: Mapped["Tryout"] = relationship(
|
||||
"Tryout", back_populates="stats", lazy="selectin"
|
||||
)
|
||||
|
||||
# Constraints and indexes
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(
|
||||
["website_id", "tryout_id"],
|
||||
["tryouts.website_id", "tryouts.tryout_id"],
|
||||
name="fk_tryout_stats_tryout",
|
||||
ondelete="CASCADE",
|
||||
onupdate="CASCADE",
|
||||
),
|
||||
Index(
|
||||
"ix_tryout_stats_website_id_tryout_id",
|
||||
"website_id",
|
||||
"tryout_id",
|
||||
unique=True,
|
||||
),
|
||||
# Participant count must be non-negative
|
||||
CheckConstraint("participant_count >= 0", "ck_participant_count_non_negative"),
|
||||
# Min and max NM must be within valid range [0, 1000]
|
||||
CheckConstraint(
|
||||
"min_nm IS NULL OR (min_nm >= 0 AND min_nm <= 1000)",
|
||||
"ck_min_nm_range",
|
||||
),
|
||||
CheckConstraint(
|
||||
"max_nm IS NULL OR (max_nm >= 0 AND max_nm <= 1000)",
|
||||
"ck_max_nm_range",
|
||||
),
|
||||
# Min must be less than or equal to max
|
||||
CheckConstraint(
|
||||
"min_nm IS NULL OR max_nm IS NULL OR min_nm <= max_nm",
|
||||
"ck_min_max_nm_order",
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TryoutStats(tryout_id={self.tryout_id}, participant_count={self.participant_count})>"
|
||||
79
backend/app/models/user.py
Normal file
79
backend/app/models/user.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
User model for WordPress user integration.
|
||||
|
||||
Represents users from WordPress that can take tryouts.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""
|
||||
User model representing WordPress users.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
wp_user_id: WordPress user ID (unique per site)
|
||||
website_id: Website identifier (for multi-site support)
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
sessions: User's tryout sessions
|
||||
"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# WordPress user ID (unique within website context)
|
||||
wp_user_id: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, index=True, comment="WordPress user ID"
|
||||
)
|
||||
|
||||
# Website identifier (for multi-site support)
|
||||
website_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
comment="Website identifier",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
website: Mapped["Website"] = relationship(
|
||||
"Website", back_populates="users", lazy="selectin"
|
||||
)
|
||||
sessions: Mapped[list["Session"]] = relationship(
|
||||
"Session",
|
||||
back_populates="user",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
overlaps="sessions,tryout",
|
||||
)
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"wp_user_id",
|
||||
"website_id",
|
||||
name="uq_users_wp_user_id_website_id",
|
||||
),
|
||||
Index("ix_users_website_id", "website_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User(wp_user_id={self.wp_user_id}, website_id={self.website_id})>"
|
||||
134
backend/app/models/user_answer.py
Normal file
134
backend/app/models/user_answer.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
UserAnswer model for tracking individual question responses.
|
||||
|
||||
Represents a student's response to a single question with scoring metadata.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Union
|
||||
|
||||
from sqlalchemy import Boolean, CheckConstraint, DateTime, Float, ForeignKey, Index, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class UserAnswer(Base):
|
||||
"""
|
||||
UserAnswer model representing a student's response to a question.
|
||||
|
||||
Tracks response, correctness, scoring, and timing information.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
session_id: Session identifier
|
||||
wp_user_id: WordPress user ID
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
item_id: Item identifier
|
||||
response: User's answer (A, B, C, D)
|
||||
is_correct: Whether answer is correct
|
||||
time_spent: Time spent on this question (seconds)
|
||||
scoring_mode_used: Scoring mode used
|
||||
bobot_earned: Weight earned for this answer
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
session: Session relationship
|
||||
item: Item relationship
|
||||
"""
|
||||
|
||||
__tablename__ = "user_answers"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Foreign keys
|
||||
session_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("sessions.session_id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
comment="Session identifier",
|
||||
)
|
||||
wp_user_id: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, comment="WordPress user ID"
|
||||
)
|
||||
website_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
comment="Website identifier",
|
||||
)
|
||||
tryout_id: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, comment="Tryout identifier"
|
||||
)
|
||||
item_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("items.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
comment="Item identifier",
|
||||
)
|
||||
|
||||
# Response information
|
||||
response: Mapped[str] = mapped_column(
|
||||
String(10), nullable=False, comment="User's answer (A, B, C, D)"
|
||||
)
|
||||
is_correct: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, comment="Whether answer is correct"
|
||||
)
|
||||
time_spent: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="Time spent on this question (seconds)",
|
||||
)
|
||||
|
||||
# Scoring metadata
|
||||
scoring_mode_used: Mapped[Literal["ctt", "irt", "hybrid"]] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
comment="Scoring mode used",
|
||||
)
|
||||
bobot_earned: Mapped[float] = mapped_column(
|
||||
Float,
|
||||
nullable=False,
|
||||
default=0.0,
|
||||
comment="Weight earned for this answer",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
session: Mapped["Session"] = relationship(
|
||||
"Session", back_populates="user_answers", lazy="selectin"
|
||||
)
|
||||
item: Mapped["Item"] = relationship(
|
||||
"Item", back_populates="user_answers", lazy="selectin"
|
||||
)
|
||||
|
||||
# Constraints and indexes
|
||||
__table_args__ = (
|
||||
Index("ix_user_answers_session_id", "session_id"),
|
||||
Index("ix_user_answers_wp_user_id", "wp_user_id"),
|
||||
Index("ix_user_answers_website_id", "website_id"),
|
||||
Index("ix_user_answers_tryout_id", "tryout_id"),
|
||||
Index("ix_user_answers_item_id", "item_id"),
|
||||
Index(
|
||||
"ix_user_answers_session_id_item_id",
|
||||
"session_id",
|
||||
"item_id",
|
||||
unique=True,
|
||||
),
|
||||
# Time spent must be non-negative
|
||||
CheckConstraint("time_spent >= 0", "ck_time_spent_non_negative"),
|
||||
# Bobot earned must be non-negative
|
||||
CheckConstraint("bobot_earned >= 0", "ck_bobot_earned_non_negative"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<UserAnswer(id={self.id}, session_id={self.session_id}, item_id={self.item_id})>"
|
||||
69
backend/app/models/website.py
Normal file
69
backend/app/models/website.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Website model for multi-site support.
|
||||
|
||||
Represents WordPress websites that use the IRT Bank Soal system.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Website(Base):
|
||||
"""
|
||||
Website model representing WordPress sites.
|
||||
|
||||
Enables multi-site support where a single backend serves multiple
|
||||
WordPress-powered educational sites.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
site_url: WordPress site URL
|
||||
site_name: Human-readable site name
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
users: Users belonging to this website
|
||||
tryouts: Tryouts available on this website
|
||||
"""
|
||||
|
||||
__tablename__ = "websites"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Site information
|
||||
site_url: Mapped[str] = mapped_column(
|
||||
String(512),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
index=True,
|
||||
comment="WordPress site URL",
|
||||
)
|
||||
site_name: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, comment="Human-readable site name"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
users: Mapped[list["User"]] = relationship(
|
||||
"User", back_populates="website", lazy="selectin", cascade="all, delete-orphan"
|
||||
)
|
||||
tryouts: Mapped[list["Tryout"]] = relationship(
|
||||
"Tryout", back_populates="website", lazy="selectin", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Website(id={self.id}, site_url={self.site_url})>"
|
||||
25
backend/app/routers/__init__.py
Normal file
25
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
API routers package.
|
||||
"""
|
||||
|
||||
from app.routers.admin import router as admin_router
|
||||
from app.routers.ai import router as ai_router
|
||||
from app.routers.auth import router as auth_router
|
||||
from app.routers.import_export import router as import_export_router
|
||||
from app.routers.reports import router as reports_router
|
||||
from app.routers.sessions import router as sessions_router
|
||||
from app.routers.tryouts import router as tryouts_router
|
||||
from app.routers.wordpress import router as wordpress_router
|
||||
from app.routers.websites import router as websites_router
|
||||
|
||||
__all__ = [
|
||||
"admin_router",
|
||||
"ai_router",
|
||||
"auth_router",
|
||||
"import_export_router",
|
||||
"reports_router",
|
||||
"sessions_router",
|
||||
"tryouts_router",
|
||||
"wordpress_router",
|
||||
"websites_router",
|
||||
]
|
||||
1077
backend/app/routers/admin.py
Normal file
1077
backend/app/routers/admin.py
Normal file
File diff suppressed because it is too large
Load Diff
530
backend/app/routers/ai.py
Normal file
530
backend/app/routers/ai.py
Normal file
@@ -0,0 +1,530 @@
|
||||
"""
|
||||
AI Generation Router.
|
||||
|
||||
Admin endpoints for AI question generation playground.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.auth import (
|
||||
AuthContext,
|
||||
ensure_website_scope_matches,
|
||||
get_auth_context,
|
||||
require_website_auth,
|
||||
)
|
||||
from app.core.rate_limit import enforce_rate_limit
|
||||
from app.database import get_db
|
||||
from app.models.item import Item
|
||||
from app.schemas.ai import (
|
||||
AIBatchGeneratedItem,
|
||||
AIGenerateBatchRequest,
|
||||
AIGenerateBatchResponse,
|
||||
AIGeneratePreviewRequest,
|
||||
AIGeneratePreviewResponse,
|
||||
AISaveRequest,
|
||||
AISaveResponse,
|
||||
AIStatsResponse,
|
||||
)
|
||||
from app.services.ai_generation import (
|
||||
SUPPORTED_MODELS,
|
||||
combine_usage,
|
||||
create_generation_run,
|
||||
generate_question,
|
||||
generate_questions_batch,
|
||||
generated_matches_basis_options,
|
||||
get_ai_stats,
|
||||
get_model_pricing,
|
||||
save_ai_question,
|
||||
validate_ai_model,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
router = APIRouter(prefix="/admin/ai", tags=["admin", "ai-generation"])
|
||||
|
||||
|
||||
def _validate_original_basis_item(basis_item: Item) -> None:
|
||||
if basis_item.level != "sedang":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Basis item must be 'sedang' level, got: {basis_item.level}",
|
||||
)
|
||||
if basis_item.generated_by == "ai":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Basis item must be an original question, not an AI-generated variant.",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate-preview",
|
||||
response_model=AIGeneratePreviewResponse,
|
||||
summary="Preview AI-generated question",
|
||||
description="""
|
||||
Generate a question preview using AI without saving to database.
|
||||
|
||||
This is an admin playground endpoint for testing AI generation quality.
|
||||
Admins can retry unlimited times until satisfied with the result.
|
||||
|
||||
Requirements:
|
||||
- basis_item_id must reference an existing item at 'sedang' level
|
||||
- target_level must be 'mudah' or 'sulit'
|
||||
- ai_model must be a supported OpenRouter model
|
||||
""",
|
||||
responses={
|
||||
200: {"description": "Question generated successfully (preview mode)"},
|
||||
400: {"description": "Invalid request (wrong level, unsupported model)"},
|
||||
404: {"description": "Basis item not found"},
|
||||
500: {"description": "AI generation failed"},
|
||||
},
|
||||
)
|
||||
async def generate_preview(
|
||||
request_http: Request,
|
||||
request: AIGeneratePreviewRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> AIGeneratePreviewResponse:
|
||||
"""
|
||||
Generate AI question preview (no database save).
|
||||
|
||||
- **basis_item_id**: ID of the sedang-level question to base generation on
|
||||
- **target_level**: Target difficulty (mudah/sulit)
|
||||
- **ai_model**: OpenRouter model to use (default: qwen/qwen2.5-32b-instruct)
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
await enforce_rate_limit(
|
||||
request_http,
|
||||
scope="ai.generate_preview",
|
||||
max_requests=40,
|
||||
window_seconds=300,
|
||||
)
|
||||
|
||||
# Validate AI model
|
||||
if not validate_ai_model(request.ai_model):
|
||||
supported = ", ".join(SUPPORTED_MODELS.keys())
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported AI model: {request.ai_model}. "
|
||||
f"Supported models: {supported}",
|
||||
)
|
||||
|
||||
# Fetch basis item
|
||||
result = await db.execute(
|
||||
select(Item).where(Item.id == request.basis_item_id)
|
||||
)
|
||||
basis_item = result.scalar_one_or_none()
|
||||
|
||||
if not basis_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Basis item not found: {request.basis_item_id}",
|
||||
)
|
||||
ensure_website_scope_matches(website_id, basis_item.website_id)
|
||||
|
||||
_validate_original_basis_item(basis_item)
|
||||
|
||||
# Generate question
|
||||
try:
|
||||
generated = await generate_question(
|
||||
basis_item=basis_item,
|
||||
target_level=request.target_level,
|
||||
ai_model=request.ai_model,
|
||||
)
|
||||
|
||||
if not generated:
|
||||
return AIGeneratePreviewResponse(
|
||||
success=False,
|
||||
error="AI generation failed. Please check logs or try again.",
|
||||
ai_model=request.ai_model,
|
||||
basis_item_id=request.basis_item_id,
|
||||
target_level=request.target_level,
|
||||
)
|
||||
|
||||
return AIGeneratePreviewResponse(
|
||||
success=True,
|
||||
stem=generated.stem,
|
||||
options=generated.options,
|
||||
correct=generated.correct,
|
||||
explanation=generated.explanation,
|
||||
usage=generated.usage,
|
||||
ai_model=request.ai_model,
|
||||
basis_item_id=request.basis_item_id,
|
||||
target_level=request.target_level,
|
||||
cached=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AI preview generation failed: {e}")
|
||||
return AIGeneratePreviewResponse(
|
||||
success=False,
|
||||
error=f"AI generation error: {str(e)}",
|
||||
ai_model=request.ai_model,
|
||||
basis_item_id=request.basis_item_id,
|
||||
target_level=request.target_level,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate-save",
|
||||
response_model=AISaveResponse,
|
||||
summary="Save AI-generated question",
|
||||
description="""
|
||||
Save an AI-generated question to the database.
|
||||
|
||||
This endpoint creates a new Item record with:
|
||||
- generated_by='ai'
|
||||
- ai_model from request
|
||||
- basis_item_id linking to original question
|
||||
- calibrated=False (will be calculated later)
|
||||
""",
|
||||
responses={
|
||||
200: {"description": "Question saved successfully"},
|
||||
400: {"description": "Invalid request data"},
|
||||
404: {"description": "Basis item or tryout not found"},
|
||||
500: {"description": "Database save failed"},
|
||||
},
|
||||
)
|
||||
async def generate_save(
|
||||
request_http: Request,
|
||||
request: AISaveRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> AISaveResponse:
|
||||
"""
|
||||
Save AI-generated question to database.
|
||||
|
||||
- **stem**: Question text
|
||||
- **options**: Dict with the same option labels as the basis item
|
||||
- **correct**: Correct answer label from the generated options
|
||||
- **explanation**: Answer explanation (optional)
|
||||
- **tryout_id**: Tryout identifier
|
||||
- **website_id**: Website identifier
|
||||
- **basis_item_id**: Original item ID this was generated from
|
||||
- **slot**: Question slot position
|
||||
- **level**: Difficulty level
|
||||
- **ai_model**: AI model used for generation
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
await enforce_rate_limit(
|
||||
request_http,
|
||||
scope="ai.generate_save",
|
||||
max_requests=40,
|
||||
window_seconds=300,
|
||||
)
|
||||
ensure_website_scope_matches(website_id, request.website_id)
|
||||
|
||||
# Verify basis item exists
|
||||
basis_result = await db.execute(
|
||||
select(Item).where(Item.id == request.basis_item_id)
|
||||
)
|
||||
basis_item = basis_result.scalar_one_or_none()
|
||||
|
||||
if not basis_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Basis item not found: {request.basis_item_id}",
|
||||
)
|
||||
ensure_website_scope_matches(website_id, basis_item.website_id)
|
||||
_validate_original_basis_item(basis_item)
|
||||
|
||||
# Create GeneratedQuestion from request
|
||||
from app.schemas.ai import GeneratedQuestion
|
||||
|
||||
generated_data = GeneratedQuestion(
|
||||
stem=request.stem,
|
||||
options=request.options,
|
||||
correct=request.correct,
|
||||
explanation=request.explanation,
|
||||
)
|
||||
if not generated_matches_basis_options(generated_data, basis_item):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Generated options must match the basis question option labels exactly.",
|
||||
)
|
||||
|
||||
run_id = await create_generation_run(
|
||||
basis_item_id=basis_item.id,
|
||||
source_snapshot_question_id=basis_item.source_snapshot_question_id,
|
||||
target_level=request.level,
|
||||
requested_count=1,
|
||||
model=request.ai_model,
|
||||
created_by=auth.wp_user_id or auth.role,
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Save to database
|
||||
item_id = await save_ai_question(
|
||||
generated_data=generated_data,
|
||||
tryout_id=request.tryout_id,
|
||||
website_id=request.website_id,
|
||||
basis_item_id=request.basis_item_id,
|
||||
slot=request.slot,
|
||||
level=request.level,
|
||||
ai_model=request.ai_model,
|
||||
generation_run_id=run_id,
|
||||
source_snapshot_question_id=basis_item.source_snapshot_question_id,
|
||||
variant_status=request.variant_status,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if not item_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to save AI-generated question",
|
||||
)
|
||||
|
||||
return AISaveResponse(
|
||||
success=True,
|
||||
item_id=item_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate-batch",
|
||||
response_model=AIGenerateBatchResponse,
|
||||
summary="Generate and save AI question batch",
|
||||
description="Generate multiple trusted active variants from one medium-level basis question and track the run.",
|
||||
)
|
||||
async def generate_batch(
|
||||
request_http: Request,
|
||||
request: AIGenerateBatchRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> AIGenerateBatchResponse:
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
await enforce_rate_limit(
|
||||
request_http,
|
||||
scope="ai.generate_batch",
|
||||
max_requests=10,
|
||||
window_seconds=300,
|
||||
)
|
||||
|
||||
if not validate_ai_model(request.ai_model):
|
||||
supported = ", ".join(SUPPORTED_MODELS.keys())
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported AI model: {request.ai_model}. Supported models: {supported}",
|
||||
)
|
||||
|
||||
result = await db.execute(select(Item).where(Item.id == request.basis_item_id))
|
||||
basis_item = result.scalar_one_or_none()
|
||||
if not basis_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Basis item not found: {request.basis_item_id}",
|
||||
)
|
||||
ensure_website_scope_matches(website_id, basis_item.website_id)
|
||||
_validate_original_basis_item(basis_item)
|
||||
|
||||
run_id = await create_generation_run(
|
||||
basis_item_id=basis_item.id,
|
||||
source_snapshot_question_id=basis_item.source_snapshot_question_id,
|
||||
target_level=request.target_level,
|
||||
requested_count=request.count,
|
||||
model=request.ai_model,
|
||||
created_by=auth.wp_user_id or auth.role,
|
||||
operator_notes=request.operator_notes,
|
||||
db=db,
|
||||
)
|
||||
|
||||
generated_questions = await generate_questions_batch(
|
||||
basis_item=basis_item,
|
||||
target_level=request.target_level,
|
||||
ai_model=request.ai_model,
|
||||
count=request.count,
|
||||
operator_notes=request.operator_notes,
|
||||
)
|
||||
item_ids: list[int] = []
|
||||
response_items: list[AIBatchGeneratedItem] = []
|
||||
for generated in generated_questions:
|
||||
item_id = await save_ai_question(
|
||||
generated_data=generated,
|
||||
tryout_id=basis_item.tryout_id,
|
||||
website_id=basis_item.website_id,
|
||||
basis_item_id=basis_item.id,
|
||||
slot=basis_item.slot,
|
||||
level=request.target_level,
|
||||
ai_model=request.ai_model,
|
||||
db=db,
|
||||
generation_run_id=run_id,
|
||||
source_snapshot_question_id=basis_item.source_snapshot_question_id,
|
||||
variant_status="active",
|
||||
)
|
||||
if item_id is not None:
|
||||
item_ids.append(item_id)
|
||||
response_items.append(
|
||||
AIBatchGeneratedItem(
|
||||
item_id=item_id,
|
||||
stem=generated.stem,
|
||||
options=generated.options,
|
||||
correct=generated.correct,
|
||||
explanation=generated.explanation,
|
||||
level=request.target_level,
|
||||
variant_status="active",
|
||||
usage=generated.usage,
|
||||
)
|
||||
)
|
||||
|
||||
if not item_ids:
|
||||
return AIGenerateBatchResponse(
|
||||
success=False,
|
||||
run_id=run_id,
|
||||
generated_count=0,
|
||||
error="AI generation failed. No variants were saved.",
|
||||
)
|
||||
|
||||
return AIGenerateBatchResponse(
|
||||
success=True,
|
||||
run_id=run_id,
|
||||
item_ids=item_ids,
|
||||
items=response_items,
|
||||
generated_count=len(item_ids),
|
||||
usage=combine_usage([item.usage for item in response_items]),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=AIStatsResponse,
|
||||
summary="Get AI generation statistics",
|
||||
description="""
|
||||
Get statistics about AI-generated questions.
|
||||
|
||||
Returns:
|
||||
- Total AI-generated items count
|
||||
- Items count by model
|
||||
- Cache hit rate (placeholder)
|
||||
""",
|
||||
)
|
||||
async def get_stats(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> AIStatsResponse:
|
||||
"""
|
||||
Get AI generation statistics.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
stats = await get_ai_stats(db, website_id=website_id)
|
||||
|
||||
return AIStatsResponse(
|
||||
total_ai_items=stats["total_ai_items"],
|
||||
items_by_model=stats["items_by_model"],
|
||||
cache_hit_rate=stats["cache_hit_rate"],
|
||||
total_cache_hits=stats["total_cache_hits"],
|
||||
total_requests=stats["total_requests"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/models",
|
||||
summary="List supported AI models",
|
||||
description="Returns list of supported AI models for question generation.",
|
||||
)
|
||||
async def list_models(auth: AuthContext = Depends(get_auth_context)) -> dict:
|
||||
"""
|
||||
List supported AI models.
|
||||
"""
|
||||
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
configured_models = [
|
||||
{
|
||||
"id": settings.OPENROUTER_MODEL_CHEAP,
|
||||
"name": "Mistral Small 4",
|
||||
"description": "Cheap and fast option for routine variant generation",
|
||||
},
|
||||
{
|
||||
"id": settings.OPENROUTER_MODEL_QWEN,
|
||||
"name": "Qwen 2.5 32B Instruct",
|
||||
"description": "Balanced default for structured soal generation",
|
||||
},
|
||||
{
|
||||
"id": settings.OPENROUTER_MODEL_LLAMA,
|
||||
"name": "Llama 3.3 70B",
|
||||
"description": "Premium fallback when you want better quality over cost",
|
||||
},
|
||||
]
|
||||
|
||||
models = []
|
||||
for model in configured_models:
|
||||
pricing = await get_model_pricing(model["id"])
|
||||
models.append({**model, "pricing": pricing})
|
||||
return {"models": models}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/pending-reviews",
|
||||
summary="Get pending AI generated questions",
|
||||
description="Retrieve all AI generated questions that are pending review (variant_status='draft').",
|
||||
)
|
||||
async def admin_get_pending_reviews(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> dict:
|
||||
"""Retrieve pending reviews."""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
|
||||
query = (
|
||||
select(Item)
|
||||
.where(Item.generated_by == "ai", Item.variant_status == "draft")
|
||||
.order_by(Item.created_at.desc())
|
||||
.limit(200)
|
||||
)
|
||||
if website_id is not None:
|
||||
query = query.where(Item.website_id == website_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": i.id,
|
||||
"tryout_id": i.tryout_id,
|
||||
"level": i.level,
|
||||
"stem_text": i.stem_text if hasattr(i, 'stem_text') else i.stem[:100],
|
||||
"ai_model": i.ai_model,
|
||||
"basis_item_id": i.basis_item_id,
|
||||
"created_at": i.created_at,
|
||||
"status": i.variant_status,
|
||||
}
|
||||
for i in items
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/review/{item_id}",
|
||||
summary="Approve or reject AI generated question",
|
||||
description="Update the variant_status of an AI generated question.",
|
||||
)
|
||||
async def admin_review_ai_question(
|
||||
item_id: int,
|
||||
status: str, # "active", "rejected"
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> dict:
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
|
||||
result = await db.execute(select(Item).where(Item.id == item_id))
|
||||
item = result.scalar_one_or_none()
|
||||
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
if website_id is not None and item.website_id != website_id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized for this website")
|
||||
|
||||
if status not in ["active", "rejected"]:
|
||||
raise HTTPException(status_code=400, detail="Status must be active or rejected")
|
||||
|
||||
item.variant_status = status
|
||||
await db.commit()
|
||||
|
||||
return {"success": True, "item_id": item_id, "status": status}
|
||||
60
backend/app/routers/auth.py
Normal file
60
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Authentication endpoints.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.auth import issue_access_token
|
||||
from app.core.config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.post(
|
||||
"/admin-login",
|
||||
summary="Admin Login",
|
||||
description="Login for standalone app administration.",
|
||||
)
|
||||
async def admin_login(request: LoginRequest) -> Dict[str, Any]:
|
||||
"""Authenticate an app admin and issue a JWT token."""
|
||||
if not settings.ENABLE_ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin functionality is disabled.",
|
||||
)
|
||||
|
||||
if not settings.ADMIN_USERNAME or not settings.ADMIN_PASSWORD:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Admin credentials not configured.",
|
||||
)
|
||||
|
||||
if (
|
||||
request.username != settings.ADMIN_USERNAME
|
||||
or request.password != settings.ADMIN_PASSWORD
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
|
||||
token = issue_access_token(
|
||||
website_id=None,
|
||||
role="system_admin",
|
||||
expires_in_seconds=86400 * 7, # 7 days
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"role": "system_admin",
|
||||
}
|
||||
424
backend/app/routers/import_export.py
Normal file
424
backend/app/routers/import_export.py
Normal file
@@ -0,0 +1,424 @@
|
||||
"""
|
||||
Import/Export API router for migration and snapshot ingestion.
|
||||
|
||||
Endpoints:
|
||||
- POST /api/v1/import/preview: Preview Excel import without saving
|
||||
- POST /api/v1/import/questions: Import questions from Excel to database
|
||||
- GET /api/v1/export/questions: Export questions to Excel file
|
||||
- POST /api/v1/import-export/tryout-json/preview: Preview Sejoli tryout JSON import
|
||||
- POST /api/v1/import-export/tryout-json: Import Sejoli tryout JSON as read-only snapshot
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.auth import AuthContext, get_auth_context, require_website_auth
|
||||
from app.core.rate_limit import enforce_rate_limit
|
||||
from app.database import get_db
|
||||
from app.models import Website
|
||||
from app.services.excel_import import (
|
||||
bulk_insert_items,
|
||||
export_questions_to_excel,
|
||||
parse_excel_import,
|
||||
validate_excel_structure,
|
||||
)
|
||||
from app.services.tryout_json_import import (
|
||||
TryoutImportError,
|
||||
import_tryout_json_snapshot,
|
||||
preview_tryout_json_import,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/import-export", tags=["import-export"])
|
||||
|
||||
|
||||
async def ensure_website_exists(
|
||||
website_id: int,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
website = await db.get(Website, website_id)
|
||||
if website is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=(
|
||||
f"Website {website_id} not found. Website registration is stored in the database, "
|
||||
"not in .env."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/preview",
|
||||
summary="Preview Excel import",
|
||||
description="Parse Excel file and return preview without saving to database.",
|
||||
)
|
||||
async def preview_import(
|
||||
request: Request,
|
||||
file: UploadFile = File(..., description="Excel file (.xlsx)"),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> dict:
|
||||
"""
|
||||
Preview Excel import without saving to database.
|
||||
|
||||
Args:
|
||||
file: Excel file upload (.xlsx format)
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- items_count: Number of items parsed
|
||||
- preview: List of item previews
|
||||
- validation_errors: List of validation errors if any
|
||||
|
||||
Raises:
|
||||
HTTPException: If file format is invalid or parsing fails
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
await enforce_rate_limit(
|
||||
request,
|
||||
scope="import.preview",
|
||||
max_requests=30,
|
||||
window_seconds=300,
|
||||
)
|
||||
|
||||
# Validate file format
|
||||
if not file.filename or not file.filename.lower().endswith('.xlsx'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File must be .xlsx format",
|
||||
)
|
||||
|
||||
# Save uploaded file to temporary location
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as temp_file:
|
||||
content = await file.read()
|
||||
temp_file.write(content)
|
||||
temp_file_path = temp_file.name
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to save uploaded file: {str(e)}",
|
||||
)
|
||||
|
||||
try:
|
||||
# Validate Excel structure
|
||||
validation = validate_excel_structure(temp_file_path)
|
||||
if not validation["valid"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"error": "Invalid Excel structure",
|
||||
"validation_errors": validation["errors"],
|
||||
},
|
||||
)
|
||||
|
||||
# Parse Excel (tryout_id is optional for preview)
|
||||
tryout_id = "preview" # Use dummy tryout_id for preview
|
||||
result = parse_excel_import(
|
||||
temp_file_path,
|
||||
website_id=website_id,
|
||||
tryout_id=tryout_id
|
||||
)
|
||||
|
||||
if result["validation_errors"]:
|
||||
return {
|
||||
"items_count": result["items_count"],
|
||||
"preview": result["items"],
|
||||
"validation_errors": result["validation_errors"],
|
||||
"has_errors": True,
|
||||
}
|
||||
|
||||
# Return limited preview (first 5 items)
|
||||
preview_items = result["items"][:5]
|
||||
|
||||
return {
|
||||
"items_count": result["items_count"],
|
||||
"preview": preview_items,
|
||||
"validation_errors": [],
|
||||
"has_errors": False,
|
||||
}
|
||||
|
||||
finally:
|
||||
# Clean up temporary file
|
||||
if os.path.exists(temp_file_path):
|
||||
os.unlink(temp_file_path)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/questions",
|
||||
summary="Import questions from Excel",
|
||||
description="Parse Excel file and import questions to database with 100% data integrity.",
|
||||
)
|
||||
async def import_questions(
|
||||
request: Request,
|
||||
file: UploadFile = File(..., description="Excel file (.xlsx)"),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
tryout_id: str = Form(..., description="Tryout identifier"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
Import questions from Excel to database.
|
||||
|
||||
Validates file format, parses Excel content, checks for duplicates,
|
||||
and performs bulk insert with rollback on error.
|
||||
|
||||
Args:
|
||||
file: Excel file upload (.xlsx format)
|
||||
website_id: Website ID from header
|
||||
tryout_id: Tryout identifier
|
||||
db: Async database session
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- imported: Number of items successfully imported
|
||||
- duplicates: Number of duplicate items skipped
|
||||
- errors: List of errors if any
|
||||
|
||||
Raises:
|
||||
HTTPException: If file format is invalid, validation fails, or import fails
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
await enforce_rate_limit(
|
||||
request,
|
||||
scope="import.questions",
|
||||
max_requests=20,
|
||||
window_seconds=300,
|
||||
)
|
||||
|
||||
# Validate file format
|
||||
if not file.filename or not file.filename.lower().endswith('.xlsx'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File must be .xlsx format",
|
||||
)
|
||||
|
||||
# Save uploaded file to temporary location
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as temp_file:
|
||||
content = await file.read()
|
||||
temp_file.write(content)
|
||||
temp_file_path = temp_file.name
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to save uploaded file: {str(e)}",
|
||||
)
|
||||
|
||||
try:
|
||||
# Validate Excel structure
|
||||
validation = validate_excel_structure(temp_file_path)
|
||||
if not validation["valid"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"error": "Invalid Excel structure",
|
||||
"validation_errors": validation["errors"],
|
||||
},
|
||||
)
|
||||
|
||||
# Parse Excel
|
||||
result = parse_excel_import(
|
||||
temp_file_path,
|
||||
website_id=website_id,
|
||||
tryout_id=tryout_id
|
||||
)
|
||||
|
||||
# Check for validation errors
|
||||
if result["validation_errors"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"error": "Validation failed",
|
||||
"validation_errors": result["validation_errors"],
|
||||
},
|
||||
)
|
||||
|
||||
# Check if items were parsed
|
||||
if result["items_count"] == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No items found in Excel file",
|
||||
)
|
||||
|
||||
# Bulk insert items
|
||||
insert_result = await bulk_insert_items(result["items"], db)
|
||||
|
||||
# Check for insertion errors
|
||||
if insert_result["errors"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"error": "Import failed",
|
||||
"errors": insert_result["errors"],
|
||||
},
|
||||
)
|
||||
|
||||
# Check for conflicts (duplicates)
|
||||
if insert_result["duplicate_count"] > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"message": f"Import completed with {insert_result['duplicate_count']} duplicate(s) skipped",
|
||||
"imported": insert_result["inserted_count"],
|
||||
"duplicates": insert_result["duplicate_count"],
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Import successful",
|
||||
"imported": insert_result["inserted_count"],
|
||||
"duplicates": insert_result["duplicate_count"],
|
||||
}
|
||||
|
||||
finally:
|
||||
# Clean up temporary file
|
||||
if os.path.exists(temp_file_path):
|
||||
os.unlink(temp_file_path)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/export/questions",
|
||||
summary="Export questions to Excel",
|
||||
description="Export questions for a tryout to Excel file in standardized format.",
|
||||
)
|
||||
async def export_questions(
|
||||
tryout_id: str,
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> FileResponse:
|
||||
"""
|
||||
Export questions to Excel file.
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
website_id: Website ID from header
|
||||
db: Async database session
|
||||
|
||||
Returns:
|
||||
FileResponse with Excel file
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout has no questions or export fails
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
|
||||
try:
|
||||
# Export questions to Excel
|
||||
output_path = await export_questions_to_excel(
|
||||
tryout_id=tryout_id,
|
||||
website_id=website_id,
|
||||
db=db
|
||||
)
|
||||
|
||||
# Return file for download
|
||||
filename = f"tryout_{tryout_id}_questions.xlsx"
|
||||
return FileResponse(
|
||||
path=output_path,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Export failed: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tryout-json/preview",
|
||||
summary="Preview Sejoli tryout JSON import",
|
||||
description="Parse a Sejoli tryout export JSON file and show snapshot diff without writing to database.",
|
||||
)
|
||||
async def preview_tryout_json(
|
||||
request: Request,
|
||||
file: UploadFile = File(..., description="Sejoli tryout export JSON"),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
await enforce_rate_limit(
|
||||
request,
|
||||
scope="import.tryout_json_preview",
|
||||
max_requests=30,
|
||||
window_seconds=300,
|
||||
)
|
||||
if not file.filename or not file.filename.lower().endswith(".json"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File must be .json format",
|
||||
)
|
||||
|
||||
await ensure_website_exists(website_id, db)
|
||||
|
||||
try:
|
||||
payload = json.loads((await file.read()).decode("utf-8"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid JSON file: {str(e)}",
|
||||
)
|
||||
|
||||
try:
|
||||
return await preview_tryout_json_import(payload, website_id, db)
|
||||
except TryoutImportError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tryout-json",
|
||||
summary="Import Sejoli tryout JSON snapshot",
|
||||
description="Store Sejoli tryout export JSON as read-only snapshot data and upsert normalized reference questions.",
|
||||
)
|
||||
async def import_tryout_json(
|
||||
request: Request,
|
||||
file: UploadFile = File(..., description="Sejoli tryout export JSON"),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
if website_id is None:
|
||||
x_website_id = request.headers.get("x-website-id")
|
||||
if not x_website_id or not x_website_id.isdigit():
|
||||
raise HTTPException(status_code=400, detail="X-Website-ID header is required for system_admin")
|
||||
website_id = int(x_website_id)
|
||||
await enforce_rate_limit(
|
||||
request,
|
||||
scope="import.tryout_json",
|
||||
max_requests=20,
|
||||
window_seconds=300,
|
||||
)
|
||||
if not file.filename or not file.filename.lower().endswith(".json"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File must be .json format",
|
||||
)
|
||||
|
||||
await ensure_website_exists(website_id, db)
|
||||
|
||||
try:
|
||||
payload = json.loads((await file.read()).decode("utf-8"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid JSON file: {str(e)}",
|
||||
)
|
||||
|
||||
try:
|
||||
return await import_tryout_json_snapshot(payload, website_id, db)
|
||||
except TryoutImportError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
279
backend/app/routers/normalization.py
Normal file
279
backend/app/routers/normalization.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Normalization API router for dynamic normalization management.
|
||||
|
||||
Endpoints:
|
||||
- GET /tryout/{tryout_id}/normalization: Get normalization configuration
|
||||
- PUT /tryout/{tryout_id}/normalization: Update normalization settings
|
||||
- POST /tryout/{tryout_id}/normalization/reset: Reset normalization stats
|
||||
- GET /tryout/{tryout_id}/normalization/validate: Validate dynamic normalization
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.services.config_management import (
|
||||
get_normalization_config,
|
||||
reset_normalization_stats,
|
||||
toggle_normalization_mode,
|
||||
update_config,
|
||||
)
|
||||
from app.services.normalization import (
|
||||
validate_dynamic_normalization,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/tryout", tags=["normalization"])
|
||||
|
||||
|
||||
def get_website_id_from_header(
|
||||
x_website_id: Optional[str] = Header(None, alias="X-Website-ID"),
|
||||
) -> int:
|
||||
"""
|
||||
Extract and validate website_id from request header.
|
||||
|
||||
Args:
|
||||
x_website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
Validated website ID as integer
|
||||
|
||||
Raises:
|
||||
HTTPException: If header is missing or invalid
|
||||
"""
|
||||
if x_website_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="X-Website-ID header is required",
|
||||
)
|
||||
try:
|
||||
return int(x_website_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="X-Website-ID must be a valid integer",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{tryout_id}/normalization",
|
||||
summary="Get normalization configuration",
|
||||
description="Retrieve current normalization configuration including mode, static values, dynamic values, and threshold status.",
|
||||
)
|
||||
async def get_normalization_endpoint(
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
website_id: int = Depends(get_website_id_from_header),
|
||||
):
|
||||
"""
|
||||
Get normalization configuration for a tryout.
|
||||
|
||||
Returns:
|
||||
Normalization configuration with:
|
||||
- mode (static/dynamic/hybrid)
|
||||
- current rataan, sb (from TryoutStats)
|
||||
- static_rataan, static_sb (from Tryout config)
|
||||
- participant_count
|
||||
- threshold_status (ready for dynamic or not)
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout not found
|
||||
"""
|
||||
try:
|
||||
config = await get_normalization_config(db, website_id, tryout_id)
|
||||
return config
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{tryout_id}/normalization",
|
||||
summary="Update normalization settings",
|
||||
description="Update normalization mode and static values for a tryout.",
|
||||
)
|
||||
async def update_normalization_endpoint(
|
||||
tryout_id: str,
|
||||
normalization_mode: Optional[str] = None,
|
||||
static_rataan: Optional[float] = None,
|
||||
static_sb: Optional[float] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
website_id: int = Depends(get_website_id_from_header),
|
||||
):
|
||||
"""
|
||||
Update normalization settings for a tryout.
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
normalization_mode: New normalization mode (static/dynamic/hybrid)
|
||||
static_rataan: New static mean value
|
||||
static_sb: New static standard deviation
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
Updated normalization configuration
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout not found or validation fails
|
||||
"""
|
||||
# Build updates dictionary
|
||||
updates = {}
|
||||
|
||||
if normalization_mode is not None:
|
||||
if normalization_mode not in ["static", "dynamic", "hybrid"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid normalization_mode: {normalization_mode}. Must be 'static', 'dynamic', or 'hybrid'",
|
||||
)
|
||||
updates["normalization_mode"] = normalization_mode
|
||||
|
||||
if static_rataan is not None:
|
||||
if static_rataan <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="static_rataan must be greater than 0",
|
||||
)
|
||||
updates["static_rataan"] = static_rataan
|
||||
|
||||
if static_sb is not None:
|
||||
if static_sb <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="static_sb must be greater than 0",
|
||||
)
|
||||
updates["static_sb"] = static_sb
|
||||
|
||||
if not updates:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No updates provided",
|
||||
)
|
||||
|
||||
try:
|
||||
# Update configuration
|
||||
await update_config(db, website_id, tryout_id, updates)
|
||||
|
||||
# Get updated configuration
|
||||
config = await get_normalization_config(db, website_id, tryout_id)
|
||||
|
||||
return config
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{tryout_id}/normalization/reset",
|
||||
summary="Reset normalization stats",
|
||||
description="Reset TryoutStats to initial values and switch to static normalization mode.",
|
||||
)
|
||||
async def reset_normalization_endpoint(
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
website_id: int = Depends(get_website_id_from_header),
|
||||
):
|
||||
"""
|
||||
Reset normalization stats for a tryout.
|
||||
|
||||
Resets TryoutStats to initial values (participant_count=0, sums cleared)
|
||||
and temporarily switches normalization_mode to "static".
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
Success message with updated configuration
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout not found
|
||||
"""
|
||||
try:
|
||||
stats = await reset_normalization_stats(db, website_id, tryout_id)
|
||||
config = await get_normalization_config(db, website_id, tryout_id)
|
||||
|
||||
return {
|
||||
"message": "Normalization stats reset successfully",
|
||||
"tryout_id": tryout_id,
|
||||
"participant_count": stats.participant_count,
|
||||
"normalization_mode": config["normalization_mode"],
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{tryout_id}/normalization/validate",
|
||||
summary="Validate dynamic normalization",
|
||||
description="Validate that dynamic normalization produces expected distribution (mean≈500±5, SD≈100±5).",
|
||||
)
|
||||
async def validate_normalization_endpoint(
|
||||
tryout_id: str,
|
||||
target_mean: float = 500.0,
|
||||
target_sd: float = 100.0,
|
||||
mean_tolerance: float = 5.0,
|
||||
sd_tolerance: float = 5.0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
website_id: int = Depends(get_website_id_from_header),
|
||||
):
|
||||
"""
|
||||
Validate dynamic normalization for a tryout.
|
||||
|
||||
Checks if calculated rataan and sb are close to target values.
|
||||
Returns validation status, deviations, warnings, and suggestions.
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
target_mean: Target mean (default: 500)
|
||||
target_sd: Target standard deviation (default: 100)
|
||||
mean_tolerance: Allowed deviation from target mean (default: 5)
|
||||
sd_tolerance: Allowed deviation from target SD (default: 5)
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
Validation result with:
|
||||
- is_valid: True if within tolerance
|
||||
- details: Full validation details
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout not found
|
||||
"""
|
||||
try:
|
||||
is_valid, details = await validate_dynamic_normalization(
|
||||
db=db,
|
||||
website_id=website_id,
|
||||
tryout_id=tryout_id,
|
||||
target_mean=target_mean,
|
||||
target_sd=target_sd,
|
||||
mean_tolerance=mean_tolerance,
|
||||
sd_tolerance=sd_tolerance,
|
||||
)
|
||||
|
||||
return {
|
||||
"tryout_id": tryout_id,
|
||||
"is_valid": is_valid,
|
||||
"target_mean": target_mean,
|
||||
"target_sd": target_sd,
|
||||
"mean_tolerance": mean_tolerance,
|
||||
"sd_tolerance": sd_tolerance,
|
||||
"details": details,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
803
backend/app/routers/reports.py
Normal file
803
backend/app/routers/reports.py
Normal file
@@ -0,0 +1,803 @@
|
||||
"""
|
||||
Reports API router for comprehensive reporting.
|
||||
|
||||
Endpoints:
|
||||
- GET /reports/student/performance: Get student performance report
|
||||
- GET /reports/items/analysis: Get item analysis report
|
||||
- GET /reports/calibration/status: Get calibration status report
|
||||
- GET /reports/tryout/comparison: Get tryout comparison report
|
||||
- POST /reports/schedule: Schedule a report
|
||||
- GET /reports/export/{schedule_id}/{format}: Export scheduled report
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.auth import (
|
||||
AuthContext,
|
||||
ensure_website_scope_matches,
|
||||
get_auth_context,
|
||||
require_website_auth,
|
||||
)
|
||||
from app.schemas.report import (
|
||||
StudentPerformanceReportOutput,
|
||||
AggregatePerformanceStatsOutput,
|
||||
StudentPerformanceRecordOutput,
|
||||
ItemAnalysisReportOutput,
|
||||
ItemAnalysisRecordOutput,
|
||||
CalibrationStatusReportOutput,
|
||||
CalibrationItemStatusOutput,
|
||||
TryoutComparisonReportOutput,
|
||||
TryoutComparisonRecordOutput,
|
||||
ReportScheduleRequest,
|
||||
ReportScheduleOutput,
|
||||
ReportScheduleResponse,
|
||||
ExportResponse,
|
||||
)
|
||||
from app.services.reporting import (
|
||||
generate_student_performance_report,
|
||||
generate_item_analysis_report,
|
||||
generate_calibration_status_report,
|
||||
generate_tryout_comparison_report,
|
||||
export_report_to_csv,
|
||||
export_report_to_excel,
|
||||
export_report_to_pdf,
|
||||
schedule_report,
|
||||
get_scheduled_report,
|
||||
list_scheduled_reports,
|
||||
cancel_scheduled_report,
|
||||
StudentPerformanceReport,
|
||||
ItemAnalysisReport,
|
||||
CalibrationStatusReport,
|
||||
TryoutComparisonReport,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/reports", tags=["reports"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Student Performance Report Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@router.get(
|
||||
"/student/performance",
|
||||
response_model=StudentPerformanceReportOutput,
|
||||
summary="Get student performance report",
|
||||
description="Generate student performance report with individual and aggregate statistics.",
|
||||
)
|
||||
async def get_student_performance_report(
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
date_start: Optional[datetime] = None,
|
||||
date_end: Optional[datetime] = None,
|
||||
format_type: Literal["individual", "aggregate", "both"] = "both",
|
||||
) -> StudentPerformanceReportOutput:
|
||||
"""
|
||||
Get student performance report.
|
||||
|
||||
Returns individual student records and/or aggregate statistics.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||
scoped_wp_user_id = None
|
||||
if auth.role == "student":
|
||||
if not auth.wp_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Student reports require an authenticated WordPress user",
|
||||
)
|
||||
scoped_wp_user_id = auth.wp_user_id
|
||||
|
||||
date_range = None
|
||||
if date_start or date_end:
|
||||
date_range = {}
|
||||
if date_start:
|
||||
date_range["start"] = date_start
|
||||
if date_end:
|
||||
date_range["end"] = date_end
|
||||
|
||||
report = await generate_student_performance_report(
|
||||
tryout_id=tryout_id,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
date_range=date_range,
|
||||
format_type=format_type,
|
||||
wp_user_id=scoped_wp_user_id,
|
||||
)
|
||||
|
||||
return _convert_student_performance_report(report)
|
||||
|
||||
|
||||
def _convert_student_performance_report(report: StudentPerformanceReport) -> StudentPerformanceReportOutput:
|
||||
"""Convert dataclass report to Pydantic output."""
|
||||
date_range_str = None
|
||||
if report.date_range:
|
||||
date_range_str = {}
|
||||
if report.date_range.get("start"):
|
||||
date_range_str["start"] = report.date_range["start"].isoformat()
|
||||
if report.date_range.get("end"):
|
||||
date_range_str["end"] = report.date_range["end"].isoformat()
|
||||
|
||||
return StudentPerformanceReportOutput(
|
||||
generated_at=report.generated_at,
|
||||
tryout_id=report.tryout_id,
|
||||
website_id=report.website_id,
|
||||
date_range=date_range_str,
|
||||
aggregate=AggregatePerformanceStatsOutput(
|
||||
tryout_id=report.aggregate.tryout_id,
|
||||
participant_count=report.aggregate.participant_count,
|
||||
avg_nm=report.aggregate.avg_nm,
|
||||
std_nm=report.aggregate.std_nm,
|
||||
min_nm=report.aggregate.min_nm,
|
||||
max_nm=report.aggregate.max_nm,
|
||||
median_nm=report.aggregate.median_nm,
|
||||
avg_nn=report.aggregate.avg_nn,
|
||||
std_nn=report.aggregate.std_nn,
|
||||
avg_theta=report.aggregate.avg_theta,
|
||||
pass_rate=report.aggregate.pass_rate,
|
||||
avg_time_spent=report.aggregate.avg_time_spent,
|
||||
),
|
||||
individual_records=[
|
||||
StudentPerformanceRecordOutput(
|
||||
session_id=r.session_id,
|
||||
wp_user_id=r.wp_user_id,
|
||||
tryout_id=r.tryout_id,
|
||||
NM=r.NM,
|
||||
NN=r.NN,
|
||||
theta=r.theta,
|
||||
theta_se=r.theta_se,
|
||||
total_benar=r.total_benar,
|
||||
time_spent=r.time_spent,
|
||||
start_time=r.start_time,
|
||||
end_time=r.end_time,
|
||||
scoring_mode_used=r.scoring_mode_used,
|
||||
rataan_used=r.rataan_used,
|
||||
sb_used=r.sb_used,
|
||||
)
|
||||
for r in report.individual_records
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Item Analysis Report Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@router.get(
|
||||
"/items/analysis",
|
||||
response_model=ItemAnalysisReportOutput,
|
||||
summary="Get item analysis report",
|
||||
description="Generate item analysis report with difficulty, discrimination, and information functions.",
|
||||
)
|
||||
async def get_item_analysis_report(
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
filter_by: Optional[Literal["difficulty", "calibrated", "discrimination"]] = None,
|
||||
difficulty_level: Optional[Literal["mudah", "sedang", "sulit"]] = None,
|
||||
) -> ItemAnalysisReportOutput:
|
||||
"""
|
||||
Get item analysis report.
|
||||
|
||||
Returns item difficulty, discrimination, and information function data.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
report = await generate_item_analysis_report(
|
||||
tryout_id=tryout_id,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
filter_by=filter_by,
|
||||
difficulty_level=difficulty_level,
|
||||
)
|
||||
|
||||
return ItemAnalysisReportOutput(
|
||||
generated_at=report.generated_at,
|
||||
tryout_id=report.tryout_id,
|
||||
website_id=report.website_id,
|
||||
total_items=report.total_items,
|
||||
items=[
|
||||
ItemAnalysisRecordOutput(
|
||||
item_id=r.item_id,
|
||||
slot=r.slot,
|
||||
level=r.level,
|
||||
ctt_p=r.ctt_p,
|
||||
ctt_bobot=r.ctt_bobot,
|
||||
ctt_category=r.ctt_category,
|
||||
irt_b=r.irt_b,
|
||||
irt_se=r.irt_se,
|
||||
calibrated=r.calibrated,
|
||||
calibration_sample_size=r.calibration_sample_size,
|
||||
correctness_rate=r.correctness_rate,
|
||||
item_total_correlation=r.item_total_correlation,
|
||||
information_values=r.information_values,
|
||||
optimal_theta_range=r.optimal_theta_range,
|
||||
)
|
||||
for r in report.items
|
||||
],
|
||||
summary=report.summary,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Calibration Status Report Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@router.get(
|
||||
"/calibration/status",
|
||||
response_model=CalibrationStatusReportOutput,
|
||||
summary="Get calibration status report",
|
||||
description="Generate calibration status report with progress tracking and readiness metrics.",
|
||||
)
|
||||
async def get_calibration_status_report(
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> CalibrationStatusReportOutput:
|
||||
"""
|
||||
Get calibration status report.
|
||||
|
||||
Returns calibration progress, items awaiting calibration, and IRT readiness status.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
report = await generate_calibration_status_report(
|
||||
tryout_id=tryout_id,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return CalibrationStatusReportOutput(
|
||||
generated_at=report.generated_at,
|
||||
tryout_id=report.tryout_id,
|
||||
website_id=report.website_id,
|
||||
total_items=report.total_items,
|
||||
calibrated_items=report.calibrated_items,
|
||||
calibration_percentage=report.calibration_percentage,
|
||||
items_awaiting_calibration=[
|
||||
CalibrationItemStatusOutput(
|
||||
item_id=r.item_id,
|
||||
slot=r.slot,
|
||||
level=r.level,
|
||||
sample_size=r.sample_size,
|
||||
calibrated=r.calibrated,
|
||||
irt_b=r.irt_b,
|
||||
irt_se=r.irt_se,
|
||||
ctt_p=r.ctt_p,
|
||||
)
|
||||
for r in report.items_awaiting_calibration
|
||||
],
|
||||
avg_calibration_sample_size=report.avg_calibration_sample_size,
|
||||
estimated_time_to_90_percent=report.estimated_time_to_90_percent,
|
||||
ready_for_irt_rollout=report.ready_for_irt_rollout,
|
||||
items=[
|
||||
CalibrationItemStatusOutput(
|
||||
item_id=r.item_id,
|
||||
slot=r.slot,
|
||||
level=r.level,
|
||||
sample_size=r.sample_size,
|
||||
calibrated=r.calibrated,
|
||||
irt_b=r.irt_b,
|
||||
irt_se=r.irt_se,
|
||||
ctt_p=r.ctt_p,
|
||||
)
|
||||
for r in report.items
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tryout Comparison Report Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@router.get(
|
||||
"/tryout/comparison",
|
||||
response_model=TryoutComparisonReportOutput,
|
||||
summary="Get tryout comparison report",
|
||||
description="Generate tryout comparison report across dates or subjects.",
|
||||
)
|
||||
async def get_tryout_comparison_report(
|
||||
tryout_ids: str, # Comma-separated list
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
group_by: Literal["date", "subject"] = "date",
|
||||
) -> TryoutComparisonReportOutput:
|
||||
"""
|
||||
Get tryout comparison report.
|
||||
|
||||
Compares tryouts across dates or subjects.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
tryout_id_list = [tid.strip() for tid in tryout_ids.split(",")]
|
||||
|
||||
if len(tryout_id_list) < 2:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="At least 2 tryout IDs are required for comparison",
|
||||
)
|
||||
|
||||
report = await generate_tryout_comparison_report(
|
||||
tryout_ids=tryout_id_list,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
group_by=group_by,
|
||||
)
|
||||
|
||||
return TryoutComparisonReportOutput(
|
||||
generated_at=report.generated_at,
|
||||
comparison_type=report.comparison_type,
|
||||
tryouts=[
|
||||
TryoutComparisonRecordOutput(
|
||||
tryout_id=r.tryout_id,
|
||||
date=r.date,
|
||||
subject=r.subject,
|
||||
participant_count=r.participant_count,
|
||||
avg_nm=r.avg_nm,
|
||||
avg_nn=r.avg_nn,
|
||||
avg_theta=r.avg_theta,
|
||||
std_nm=r.std_nm,
|
||||
calibration_percentage=r.calibration_percentage,
|
||||
)
|
||||
for r in report.tryouts
|
||||
],
|
||||
trends=report.trends,
|
||||
normalization_impact=report.normalization_impact,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Report Scheduling Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@router.post(
|
||||
"/schedule",
|
||||
response_model=ReportScheduleResponse,
|
||||
summary="Schedule a report",
|
||||
description="Schedule a report for automatic generation on a daily, weekly, or monthly basis.",
|
||||
)
|
||||
async def create_report_schedule(
|
||||
request: ReportScheduleRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> ReportScheduleResponse:
|
||||
"""
|
||||
Schedule a report.
|
||||
|
||||
Creates a scheduled report that will be generated automatically.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
ensure_website_scope_matches(website_id, request.website_id)
|
||||
schedule_id = await schedule_report(
|
||||
db,
|
||||
report_type=request.report_type,
|
||||
schedule=request.schedule,
|
||||
tryout_ids=request.tryout_ids,
|
||||
website_id=request.website_id,
|
||||
recipients=request.recipients,
|
||||
export_format=request.export_format,
|
||||
)
|
||||
|
||||
scheduled = await get_scheduled_report(db, schedule_id)
|
||||
|
||||
return ReportScheduleResponse(
|
||||
schedule_id=schedule_id,
|
||||
message=f"Report scheduled successfully for {request.schedule} generation",
|
||||
next_run=scheduled.next_run if scheduled else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/schedule/{schedule_id}",
|
||||
response_model=ReportScheduleOutput,
|
||||
summary="Get scheduled report details",
|
||||
description="Get details of a scheduled report.",
|
||||
)
|
||||
async def get_scheduled_report_details(
|
||||
schedule_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> ReportScheduleOutput:
|
||||
"""
|
||||
Get scheduled report details.
|
||||
|
||||
Returns the configuration and status of a scheduled report.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
scheduled = await get_scheduled_report(db, schedule_id)
|
||||
|
||||
if not scheduled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Scheduled report {schedule_id} not found",
|
||||
)
|
||||
|
||||
if scheduled.website_id != website_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this scheduled report",
|
||||
)
|
||||
|
||||
return ReportScheduleOutput(
|
||||
schedule_id=scheduled.schedule_id,
|
||||
report_type=scheduled.report_type,
|
||||
schedule=scheduled.schedule,
|
||||
tryout_ids=scheduled.tryout_ids,
|
||||
website_id=scheduled.website_id,
|
||||
recipients=scheduled.recipients,
|
||||
format=scheduled.format,
|
||||
created_at=scheduled.created_at,
|
||||
last_run=scheduled.last_run,
|
||||
next_run=scheduled.next_run,
|
||||
is_active=scheduled.is_active,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/schedule",
|
||||
response_model=List[ReportScheduleOutput],
|
||||
summary="List scheduled reports",
|
||||
description="List all scheduled reports for a website.",
|
||||
)
|
||||
async def list_scheduled_reports_endpoint(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> List[ReportScheduleOutput]:
|
||||
"""
|
||||
List all scheduled reports.
|
||||
|
||||
Returns all scheduled reports for the current website.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
reports = await list_scheduled_reports(db, website_id=website_id)
|
||||
|
||||
return [
|
||||
ReportScheduleOutput(
|
||||
schedule_id=r.schedule_id,
|
||||
report_type=r.report_type,
|
||||
schedule=r.schedule,
|
||||
tryout_ids=r.tryout_ids,
|
||||
website_id=r.website_id,
|
||||
recipients=r.recipients,
|
||||
format=r.format,
|
||||
created_at=r.created_at,
|
||||
last_run=r.last_run,
|
||||
next_run=r.next_run,
|
||||
is_active=r.is_active,
|
||||
)
|
||||
for r in reports
|
||||
]
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/schedule/{schedule_id}",
|
||||
summary="Cancel scheduled report",
|
||||
description="Cancel a scheduled report.",
|
||||
)
|
||||
async def cancel_scheduled_report_endpoint(
|
||||
schedule_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> dict:
|
||||
"""
|
||||
Cancel a scheduled report.
|
||||
|
||||
Removes the scheduled report from the system.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
scheduled = await get_scheduled_report(db, schedule_id)
|
||||
|
||||
if not scheduled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Scheduled report {schedule_id} not found",
|
||||
)
|
||||
|
||||
if scheduled.website_id != website_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this scheduled report",
|
||||
)
|
||||
|
||||
success = await cancel_scheduled_report(db, schedule_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to cancel scheduled report",
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"Scheduled report {schedule_id} cancelled successfully",
|
||||
"schedule_id": schedule_id,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Report Export Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@router.get(
|
||||
"/export/{schedule_id}/{format}",
|
||||
summary="Export scheduled report",
|
||||
description="Generate and export a scheduled report in the specified format.",
|
||||
)
|
||||
async def export_scheduled_report(
|
||||
schedule_id: str,
|
||||
format: Literal["csv", "xlsx", "pdf"],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
"""
|
||||
Export a scheduled report.
|
||||
|
||||
Generates the report and returns it as a file download.
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
scheduled = await get_scheduled_report(db, schedule_id)
|
||||
|
||||
if not scheduled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Scheduled report {schedule_id} not found",
|
||||
)
|
||||
|
||||
if scheduled.website_id != website_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this scheduled report",
|
||||
)
|
||||
if not scheduled.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Scheduled report is inactive",
|
||||
)
|
||||
|
||||
# Generate report based on type
|
||||
report = None
|
||||
base_filename = f"report_{scheduled.report_type}_{schedule_id}"
|
||||
|
||||
try:
|
||||
if scheduled.report_type == "student_performance":
|
||||
if len(scheduled.tryout_ids) > 0:
|
||||
report = await generate_student_performance_report(
|
||||
tryout_id=scheduled.tryout_ids[0],
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
)
|
||||
elif scheduled.report_type == "item_analysis":
|
||||
if len(scheduled.tryout_ids) > 0:
|
||||
report = await generate_item_analysis_report(
|
||||
tryout_id=scheduled.tryout_ids[0],
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
)
|
||||
elif scheduled.report_type == "calibration_status":
|
||||
if len(scheduled.tryout_ids) > 0:
|
||||
report = await generate_calibration_status_report(
|
||||
tryout_id=scheduled.tryout_ids[0],
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
)
|
||||
elif scheduled.report_type == "tryout_comparison":
|
||||
report = await generate_tryout_comparison_report(
|
||||
tryout_ids=scheduled.tryout_ids,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if not report:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to generate report",
|
||||
)
|
||||
|
||||
# Export to requested format
|
||||
if format == "csv":
|
||||
file_path = export_report_to_csv(report, base_filename)
|
||||
media_type = "text/csv"
|
||||
elif format == "xlsx":
|
||||
file_path = export_report_to_excel(report, base_filename)
|
||||
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
else: # pdf
|
||||
file_path = export_report_to_pdf(report, base_filename)
|
||||
media_type = "application/pdf"
|
||||
|
||||
# Return file
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
media_type=media_type,
|
||||
filename=os.path.basename(file_path),
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to export report: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Direct Export Endpoints (without scheduling)
|
||||
# =============================================================================
|
||||
|
||||
@router.get(
|
||||
"/student/performance/export/{format}",
|
||||
summary="Export student performance report directly",
|
||||
description="Generate and export student performance report directly without scheduling.",
|
||||
)
|
||||
async def export_student_performance_direct(
|
||||
format: Literal["csv", "xlsx", "pdf"],
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
date_start: Optional[datetime] = None,
|
||||
date_end: Optional[datetime] = None,
|
||||
):
|
||||
"""Export student performance report directly."""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
date_range = None
|
||||
if date_start or date_end:
|
||||
date_range = {}
|
||||
if date_start:
|
||||
date_range["start"] = date_start
|
||||
if date_end:
|
||||
date_range["end"] = date_end
|
||||
|
||||
report = await generate_student_performance_report(
|
||||
tryout_id=tryout_id,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
date_range=date_range,
|
||||
)
|
||||
|
||||
base_filename = f"student_performance_{tryout_id}"
|
||||
|
||||
if format == "csv":
|
||||
file_path = export_report_to_csv(report, base_filename)
|
||||
media_type = "text/csv"
|
||||
elif format == "xlsx":
|
||||
file_path = export_report_to_excel(report, base_filename)
|
||||
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
else:
|
||||
file_path = export_report_to_pdf(report, base_filename)
|
||||
media_type = "application/pdf"
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
media_type=media_type,
|
||||
filename=os.path.basename(file_path),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/items/analysis/export/{format}",
|
||||
summary="Export item analysis report directly",
|
||||
description="Generate and export item analysis report directly without scheduling.",
|
||||
)
|
||||
async def export_item_analysis_direct(
|
||||
format: Literal["csv", "xlsx", "pdf"],
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
filter_by: Optional[Literal["difficulty", "calibrated", "discrimination"]] = None,
|
||||
difficulty_level: Optional[Literal["mudah", "sedang", "sulit"]] = None,
|
||||
):
|
||||
"""Export item analysis report directly."""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
report = await generate_item_analysis_report(
|
||||
tryout_id=tryout_id,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
filter_by=filter_by,
|
||||
difficulty_level=difficulty_level,
|
||||
)
|
||||
|
||||
base_filename = f"item_analysis_{tryout_id}"
|
||||
|
||||
if format == "csv":
|
||||
file_path = export_report_to_csv(report, base_filename)
|
||||
media_type = "text/csv"
|
||||
elif format == "xlsx":
|
||||
file_path = export_report_to_excel(report, base_filename)
|
||||
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
else:
|
||||
file_path = export_report_to_pdf(report, base_filename)
|
||||
media_type = "application/pdf"
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
media_type=media_type,
|
||||
filename=os.path.basename(file_path),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/calibration/status/export/{format}",
|
||||
summary="Export calibration status report directly",
|
||||
description="Generate and export calibration status report directly without scheduling.",
|
||||
)
|
||||
async def export_calibration_status_direct(
|
||||
format: Literal["csv", "xlsx", "pdf"],
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
"""Export calibration status report directly."""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
report = await generate_calibration_status_report(
|
||||
tryout_id=tryout_id,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
base_filename = f"calibration_status_{tryout_id}"
|
||||
|
||||
if format == "csv":
|
||||
file_path = export_report_to_csv(report, base_filename)
|
||||
media_type = "text/csv"
|
||||
elif format == "xlsx":
|
||||
file_path = export_report_to_excel(report, base_filename)
|
||||
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
else:
|
||||
file_path = export_report_to_pdf(report, base_filename)
|
||||
media_type = "application/pdf"
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
media_type=media_type,
|
||||
filename=os.path.basename(file_path),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tryout/comparison/export/{format}",
|
||||
summary="Export tryout comparison report directly",
|
||||
description="Generate and export tryout comparison report directly without scheduling.",
|
||||
)
|
||||
async def export_tryout_comparison_direct(
|
||||
format: Literal["csv", "xlsx", "pdf"],
|
||||
tryout_ids: str, # Comma-separated
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
group_by: Literal["date", "subject"] = "date",
|
||||
):
|
||||
"""Export tryout comparison report directly."""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
tryout_id_list = [tid.strip() for tid in tryout_ids.split(",")]
|
||||
|
||||
if len(tryout_id_list) < 2:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="At least 2 tryout IDs are required for comparison",
|
||||
)
|
||||
|
||||
report = await generate_tryout_comparison_report(
|
||||
tryout_ids=tryout_id_list,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
group_by=group_by,
|
||||
)
|
||||
|
||||
base_filename = "tryout_comparison"
|
||||
|
||||
if format == "csv":
|
||||
file_path = export_report_to_csv(report, base_filename)
|
||||
media_type = "text/csv"
|
||||
elif format == "xlsx":
|
||||
file_path = export_report_to_excel(report, base_filename)
|
||||
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
else:
|
||||
file_path = export_report_to_pdf(report, base_filename)
|
||||
media_type = "application/pdf"
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
media_type=media_type,
|
||||
filename=os.path.basename(file_path),
|
||||
)
|
||||
455
backend/app/routers/sessions.py
Normal file
455
backend/app/routers/sessions.py
Normal file
@@ -0,0 +1,455 @@
|
||||
"""
|
||||
Session API router for tryout session management.
|
||||
|
||||
Endpoints:
|
||||
- POST /session/{session_id}/complete: Submit answers and complete session
|
||||
- GET /session/{session_id}: Get session details
|
||||
- POST /session: Create new session
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.auth import (
|
||||
AuthContext,
|
||||
ensure_website_scope_matches,
|
||||
get_auth_context,
|
||||
require_website_auth,
|
||||
)
|
||||
from app.models.item import Item
|
||||
from app.models.session import Session
|
||||
from app.models.tryout import Tryout
|
||||
from app.models.tryout_stats import TryoutStats
|
||||
from app.models.user import User
|
||||
from app.models.user_answer import UserAnswer
|
||||
from app.schemas.session import (
|
||||
SessionCompleteRequest,
|
||||
SessionCompleteResponse,
|
||||
SessionCreateRequest,
|
||||
SessionResponse,
|
||||
UserAnswerOutput,
|
||||
)
|
||||
from app.services.ctt_scoring import (
|
||||
calculate_ctt_bobot,
|
||||
calculate_ctt_nm,
|
||||
calculate_ctt_nn,
|
||||
get_total_bobot_max,
|
||||
update_tryout_stats,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/session", tags=["sessions"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{session_id}/complete",
|
||||
response_model=SessionCompleteResponse,
|
||||
summary="Complete session with answers",
|
||||
description="Submit user answers, calculate CTT scores, and complete the session.",
|
||||
)
|
||||
async def complete_session(
|
||||
session_id: str,
|
||||
request: SessionCompleteRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> SessionCompleteResponse:
|
||||
"""
|
||||
Complete a session by submitting answers and calculating CTT scores.
|
||||
|
||||
Process:
|
||||
1. Validate session exists and is not completed
|
||||
2. For each answer: check is_correct, calculate bobot_earned
|
||||
3. Save UserAnswer records
|
||||
4. Calculate CTT scores (total_benar, total_bobot_earned, NM)
|
||||
5. Update Session with CTT results
|
||||
6. Update TryoutStats incrementally
|
||||
7. Return session with scores
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier
|
||||
request: Session completion request with end_time and user_answers
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
SessionCompleteResponse with CTT scores
|
||||
|
||||
Raises:
|
||||
HTTPException: If session not found, already completed, or validation fails
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||
|
||||
# Get session with tryout relationship
|
||||
session_query = (
|
||||
select(Session)
|
||||
.options(selectinload(Session.tryout))
|
||||
.where(Session.session_id == session_id)
|
||||
)
|
||||
if website_id is not None:
|
||||
session_query = session_query.where(Session.website_id == website_id)
|
||||
|
||||
result = await db.execute(session_query)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if session is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Session {session_id} not found",
|
||||
)
|
||||
|
||||
if session.is_completed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Session is already completed",
|
||||
)
|
||||
if auth.role == "student" and session.wp_user_id != auth.wp_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Session does not belong to this authenticated user",
|
||||
)
|
||||
|
||||
effective_website_id = session.website_id
|
||||
|
||||
# Get tryout configuration
|
||||
tryout = session.tryout
|
||||
|
||||
# Get all items for this tryout to calculate bobot
|
||||
items_result = await db.execute(
|
||||
select(Item).where(
|
||||
Item.website_id == effective_website_id,
|
||||
Item.tryout_id == session.tryout_id,
|
||||
)
|
||||
)
|
||||
items = {item.id: item for item in items_result.scalars().all()}
|
||||
|
||||
existing_answers_full_result = await db.execute(
|
||||
select(UserAnswer).where(UserAnswer.session_id == session.session_id)
|
||||
)
|
||||
existing_answer_records = list(existing_answers_full_result.scalars().all())
|
||||
|
||||
# Process each answer
|
||||
submitted_item_ids = [answer.item_id for answer in request.user_answers]
|
||||
if len(submitted_item_ids) != len(set(submitted_item_ids)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Duplicate item answers are not allowed in a session completion",
|
||||
)
|
||||
|
||||
existing_answered_item_ids = {answer.item_id for answer in existing_answer_records}
|
||||
duplicate_existing_ids = sorted(set(submitted_item_ids) & existing_answered_item_ids)
|
||||
if duplicate_existing_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"message": "One or more items were already answered for this session",
|
||||
"item_ids": duplicate_existing_ids,
|
||||
},
|
||||
)
|
||||
|
||||
total_benar = 0
|
||||
total_bobot_earned = 0.0
|
||||
user_answer_records = []
|
||||
|
||||
if request.user_answers:
|
||||
answers_to_score = request.user_answers
|
||||
else:
|
||||
answers_to_score = []
|
||||
user_answer_records = existing_answer_records
|
||||
total_benar = sum(1 for answer in existing_answer_records if answer.is_correct)
|
||||
total_bobot_earned = sum(answer.bobot_earned or 0.0 for answer in existing_answer_records)
|
||||
|
||||
for answer_input in answers_to_score:
|
||||
item = items.get(answer_input.item_id)
|
||||
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Item {answer_input.item_id} not found in tryout {session.tryout_id}",
|
||||
)
|
||||
|
||||
# Check if answer is correct
|
||||
is_correct = answer_input.response.upper() == item.correct_answer.upper()
|
||||
|
||||
# Calculate bobot_earned (only if correct)
|
||||
bobot_earned = 0.0
|
||||
if is_correct:
|
||||
total_benar += 1
|
||||
if item.ctt_bobot is not None:
|
||||
bobot_earned = item.ctt_bobot
|
||||
total_bobot_earned += bobot_earned
|
||||
|
||||
# Create UserAnswer record
|
||||
user_answer = UserAnswer(
|
||||
session_id=session.session_id,
|
||||
wp_user_id=session.wp_user_id,
|
||||
website_id=effective_website_id,
|
||||
tryout_id=session.tryout_id,
|
||||
item_id=item.id,
|
||||
response=answer_input.response.upper(),
|
||||
is_correct=is_correct,
|
||||
time_spent=answer_input.time_spent,
|
||||
scoring_mode_used=session.scoring_mode_used,
|
||||
bobot_earned=bobot_earned,
|
||||
)
|
||||
user_answer_records.append(user_answer)
|
||||
db.add(user_answer)
|
||||
|
||||
# Calculate total_bobot_max for NM calculation
|
||||
try:
|
||||
total_bobot_max = await get_total_bobot_max(
|
||||
db, effective_website_id, session.tryout_id, level="sedang"
|
||||
)
|
||||
except ValueError:
|
||||
# Fallback: calculate from items we have
|
||||
total_bobot_max = sum(
|
||||
item.ctt_bobot or 0 for item in items.values() if item.level == "sedang"
|
||||
)
|
||||
if total_bobot_max == 0:
|
||||
# If no bobot values, use count of questions
|
||||
total_bobot_max = len(items)
|
||||
|
||||
# Calculate CTT NM (Nilai Mentah)
|
||||
nm = calculate_ctt_nm(total_bobot_earned, total_bobot_max)
|
||||
|
||||
# Get normalization parameters based on tryout configuration
|
||||
if tryout.normalization_mode == "static":
|
||||
rataan = tryout.static_rataan
|
||||
sb = tryout.static_sb
|
||||
elif tryout.normalization_mode == "dynamic":
|
||||
# Get current stats for dynamic normalization
|
||||
stats_result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == effective_website_id,
|
||||
TryoutStats.tryout_id == session.tryout_id,
|
||||
)
|
||||
)
|
||||
stats = stats_result.scalar_one_or_none()
|
||||
|
||||
if stats and stats.participant_count >= tryout.min_sample_for_dynamic:
|
||||
rataan = stats.rataan or tryout.static_rataan
|
||||
sb = stats.sb or tryout.static_sb
|
||||
else:
|
||||
# Not enough data, use static values
|
||||
rataan = tryout.static_rataan
|
||||
sb = tryout.static_sb
|
||||
else: # hybrid
|
||||
# Hybrid: use dynamic if enough data, otherwise static
|
||||
stats_result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == effective_website_id,
|
||||
TryoutStats.tryout_id == session.tryout_id,
|
||||
)
|
||||
)
|
||||
stats = stats_result.scalar_one_or_none()
|
||||
|
||||
if stats and stats.participant_count >= tryout.min_sample_for_dynamic:
|
||||
rataan = stats.rataan or tryout.static_rataan
|
||||
sb = stats.sb or tryout.static_sb
|
||||
else:
|
||||
rataan = tryout.static_rataan
|
||||
sb = tryout.static_sb
|
||||
|
||||
# Calculate CTT NN (Nilai Nasional)
|
||||
nn = calculate_ctt_nn(nm, rataan, sb)
|
||||
|
||||
# Update session with results
|
||||
session.end_time = request.end_time
|
||||
session.is_completed = True
|
||||
session.total_benar = total_benar
|
||||
session.total_bobot_earned = total_bobot_earned
|
||||
session.NM = nm
|
||||
session.NN = nn
|
||||
session.rataan_used = rataan
|
||||
session.sb_used = sb
|
||||
|
||||
# Update tryout stats incrementally
|
||||
await update_tryout_stats(db, effective_website_id, session.tryout_id, nm)
|
||||
|
||||
# Commit all changes
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Duplicate item answer detected for this session",
|
||||
) from exc
|
||||
|
||||
# Refresh to get updated relationships
|
||||
await db.refresh(session)
|
||||
|
||||
# Build response
|
||||
return SessionCompleteResponse(
|
||||
id=session.id,
|
||||
session_id=session.session_id,
|
||||
wp_user_id=session.wp_user_id,
|
||||
website_id=session.website_id,
|
||||
tryout_id=session.tryout_id,
|
||||
start_time=session.start_time,
|
||||
end_time=session.end_time,
|
||||
expires_at=session.expires_at,
|
||||
is_completed=session.is_completed,
|
||||
scoring_mode_used=session.scoring_mode_used,
|
||||
total_benar=session.total_benar,
|
||||
total_bobot_earned=session.total_bobot_earned,
|
||||
NM=session.NM,
|
||||
NN=session.NN,
|
||||
rataan_used=session.rataan_used,
|
||||
sb_used=session.sb_used,
|
||||
user_answers=[
|
||||
UserAnswerOutput(
|
||||
id=ua.id,
|
||||
item_id=ua.item_id,
|
||||
response=ua.response,
|
||||
time_spent=ua.time_spent,
|
||||
bobot_earned=ua.bobot_earned,
|
||||
scoring_mode_used=ua.scoring_mode_used,
|
||||
)
|
||||
for ua in user_answer_records
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{session_id}",
|
||||
response_model=SessionResponse,
|
||||
summary="Get session details",
|
||||
description="Retrieve session details including scores if completed.",
|
||||
)
|
||||
async def get_session(
|
||||
session_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> SessionResponse:
|
||||
"""
|
||||
Get session details.
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
SessionResponse with session details
|
||||
|
||||
Raises:
|
||||
HTTPException: If session not found
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||
|
||||
session_query = select(Session).where(Session.session_id == session_id)
|
||||
if website_id is not None:
|
||||
session_query = session_query.where(Session.website_id == website_id)
|
||||
|
||||
result = await db.execute(session_query)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if session is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Session {session_id} not found",
|
||||
)
|
||||
if auth.role == "student" and session.wp_user_id != auth.wp_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Session does not belong to this authenticated user",
|
||||
)
|
||||
|
||||
return SessionResponse.model_validate(session)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=SessionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create new session",
|
||||
description="Create a new tryout session for a student.",
|
||||
)
|
||||
async def create_session(
|
||||
request: SessionCreateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> SessionResponse:
|
||||
"""
|
||||
Create a new session.
|
||||
|
||||
Args:
|
||||
request: Session creation request
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
SessionResponse with created session
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout not found or session already exists
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||
|
||||
ensure_website_scope_matches(website_id, request.website_id)
|
||||
effective_website_id = website_id if website_id is not None else request.website_id
|
||||
if auth.role == "student" and request.wp_user_id != auth.wp_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="wp_user_id must match authenticated user",
|
||||
)
|
||||
|
||||
# Verify tryout exists
|
||||
tryout_result = await db.execute(
|
||||
select(Tryout).where(
|
||||
Tryout.website_id == effective_website_id,
|
||||
Tryout.tryout_id == request.tryout_id,
|
||||
)
|
||||
)
|
||||
tryout = tryout_result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tryout {request.tryout_id} not found for website {effective_website_id}",
|
||||
)
|
||||
|
||||
# Check if session already exists
|
||||
existing_result = await db.execute(
|
||||
select(Session).where(Session.session_id == request.session_id)
|
||||
)
|
||||
existing_session = existing_result.scalar_one_or_none()
|
||||
|
||||
if existing_session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Session {request.session_id} already exists",
|
||||
)
|
||||
|
||||
user_result = await db.execute(
|
||||
select(User).where(
|
||||
User.wp_user_id == request.wp_user_id,
|
||||
User.website_id == effective_website_id,
|
||||
)
|
||||
)
|
||||
if user_result.scalar_one_or_none() is None:
|
||||
db.add(User(wp_user_id=request.wp_user_id, website_id=effective_website_id))
|
||||
|
||||
started_at = datetime.now(timezone.utc)
|
||||
|
||||
# Create new session
|
||||
session = Session(
|
||||
session_id=request.session_id,
|
||||
wp_user_id=request.wp_user_id,
|
||||
website_id=effective_website_id,
|
||||
tryout_id=request.tryout_id,
|
||||
scoring_mode_used=request.scoring_mode,
|
||||
start_time=started_at,
|
||||
expires_at=started_at + timedelta(hours=2),
|
||||
is_completed=False,
|
||||
total_benar=0,
|
||||
total_bobot_earned=0.0,
|
||||
)
|
||||
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
return SessionResponse.model_validate(session)
|
||||
528
backend/app/routers/tryouts.py
Normal file
528
backend/app/routers/tryouts.py
Normal file
@@ -0,0 +1,528 @@
|
||||
"""
|
||||
Tryout API router for tryout configuration and management.
|
||||
|
||||
Endpoints:
|
||||
- GET /tryout/{tryout_id}/config: Get tryout configuration
|
||||
- PUT /tryout/{tryout_id}/normalization: Update normalization settings
|
||||
- GET /tryout: List tryouts for a website
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import Integer, cast, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.auth import AuthContext, get_auth_context, require_website_auth
|
||||
from app.models.item import Item
|
||||
from app.models.tryout import Tryout
|
||||
from app.models.tryout_stats import TryoutStats
|
||||
from app.models.tryout_snapshot_question import TryoutSnapshotQuestion
|
||||
from app.schemas.tryout import (
|
||||
NormalizationUpdateRequest,
|
||||
NormalizationUpdateResponse,
|
||||
TryoutConfigBrief,
|
||||
TryoutConfigResponse,
|
||||
TryoutConfigUpdateRequest,
|
||||
TryoutStatsResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/tryout", tags=["tryouts"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{tryout_id}/config",
|
||||
response_model=TryoutConfigResponse,
|
||||
summary="Get tryout configuration",
|
||||
description="Retrieve tryout configuration including scoring mode, normalization settings, and current stats.",
|
||||
)
|
||||
async def get_tryout_config(
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> TryoutConfigResponse:
|
||||
"""
|
||||
Get tryout configuration.
|
||||
|
||||
Returns:
|
||||
TryoutConfigResponse with scoring_mode, normalization_mode, and current_stats
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout not found
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||
|
||||
# Get tryout with stats
|
||||
query = (
|
||||
select(Tryout)
|
||||
.options(selectinload(Tryout.stats))
|
||||
.where(Tryout.tryout_id == tryout_id)
|
||||
)
|
||||
if website_id is not None:
|
||||
query = query.where(Tryout.website_id == website_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
tryout = result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tryout {tryout_id} not found for website {website_id}",
|
||||
)
|
||||
|
||||
# Build stats response
|
||||
current_stats = None
|
||||
if tryout.stats:
|
||||
current_stats = TryoutStatsResponse(
|
||||
participant_count=tryout.stats.participant_count,
|
||||
rataan=tryout.stats.rataan,
|
||||
sb=tryout.stats.sb,
|
||||
min_nm=tryout.stats.min_nm,
|
||||
max_nm=tryout.stats.max_nm,
|
||||
last_calculated=tryout.stats.last_calculated,
|
||||
)
|
||||
|
||||
return TryoutConfigResponse(
|
||||
id=tryout.id,
|
||||
website_id=tryout.website_id,
|
||||
tryout_id=tryout.tryout_id,
|
||||
name=tryout.name,
|
||||
description=tryout.description,
|
||||
scoring_mode=tryout.scoring_mode,
|
||||
selection_mode=tryout.selection_mode,
|
||||
normalization_mode=tryout.normalization_mode,
|
||||
min_sample_for_dynamic=tryout.min_sample_for_dynamic,
|
||||
static_rataan=tryout.static_rataan,
|
||||
static_sb=tryout.static_sb,
|
||||
ai_generation_enabled=tryout.ai_generation_enabled,
|
||||
hybrid_transition_slot=tryout.hybrid_transition_slot,
|
||||
min_calibration_sample=tryout.min_calibration_sample,
|
||||
theta_estimation_method=tryout.theta_estimation_method,
|
||||
fallback_to_ctt_on_error=tryout.fallback_to_ctt_on_error,
|
||||
current_stats=current_stats,
|
||||
created_at=tryout.created_at,
|
||||
updated_at=tryout.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{tryout_id}/config",
|
||||
response_model=TryoutConfigResponse,
|
||||
summary="Update tryout configuration",
|
||||
description="Update editable tryout configuration fields.",
|
||||
)
|
||||
async def update_tryout_config(
|
||||
tryout_id: str,
|
||||
request: TryoutConfigUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> TryoutConfigResponse:
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
|
||||
query = select(Tryout).options(selectinload(Tryout.stats)).where(Tryout.tryout_id == tryout_id)
|
||||
if website_id is not None:
|
||||
query = query.where(Tryout.website_id == website_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
tryout = result.scalar_one_or_none()
|
||||
if tryout is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tryout {tryout_id} not found for website {website_id}",
|
||||
)
|
||||
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(tryout, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(tryout)
|
||||
|
||||
current_stats = None
|
||||
if tryout.stats:
|
||||
current_stats = TryoutStatsResponse(
|
||||
participant_count=tryout.stats.participant_count,
|
||||
rataan=tryout.stats.rataan,
|
||||
sb=tryout.stats.sb,
|
||||
min_nm=tryout.stats.min_nm,
|
||||
max_nm=tryout.stats.max_nm,
|
||||
last_calculated=tryout.stats.last_calculated,
|
||||
)
|
||||
|
||||
return TryoutConfigResponse(
|
||||
id=tryout.id,
|
||||
website_id=tryout.website_id,
|
||||
tryout_id=tryout.tryout_id,
|
||||
name=tryout.name,
|
||||
description=tryout.description,
|
||||
scoring_mode=tryout.scoring_mode,
|
||||
selection_mode=tryout.selection_mode,
|
||||
normalization_mode=tryout.normalization_mode,
|
||||
min_sample_for_dynamic=tryout.min_sample_for_dynamic,
|
||||
static_rataan=tryout.static_rataan,
|
||||
static_sb=tryout.static_sb,
|
||||
ai_generation_enabled=tryout.ai_generation_enabled,
|
||||
hybrid_transition_slot=tryout.hybrid_transition_slot,
|
||||
min_calibration_sample=tryout.min_calibration_sample,
|
||||
theta_estimation_method=tryout.theta_estimation_method,
|
||||
fallback_to_ctt_on_error=tryout.fallback_to_ctt_on_error,
|
||||
current_stats=current_stats,
|
||||
created_at=tryout.created_at,
|
||||
updated_at=tryout.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{tryout_id}/normalization",
|
||||
response_model=NormalizationUpdateResponse,
|
||||
summary="Update normalization settings",
|
||||
description="Update normalization mode and static values for a tryout.",
|
||||
)
|
||||
async def update_normalization(
|
||||
tryout_id: str,
|
||||
request: NormalizationUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> NormalizationUpdateResponse:
|
||||
"""
|
||||
Update normalization settings for a tryout.
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
request: Normalization update request
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
NormalizationUpdateResponse with updated settings
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout not found or validation fails
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
|
||||
# Get tryout
|
||||
query = select(Tryout).where(Tryout.tryout_id == tryout_id)
|
||||
if website_id is not None:
|
||||
query = query.where(Tryout.website_id == website_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
tryout = result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tryout {tryout_id} not found for website {website_id}",
|
||||
)
|
||||
|
||||
# Update normalization mode if provided
|
||||
if request.normalization_mode is not None:
|
||||
tryout.normalization_mode = request.normalization_mode
|
||||
|
||||
# Update static values if provided
|
||||
if request.static_rataan is not None:
|
||||
tryout.static_rataan = request.static_rataan
|
||||
|
||||
if request.static_sb is not None:
|
||||
tryout.static_sb = request.static_sb
|
||||
|
||||
# Get current stats for participant count
|
||||
stats_query = select(TryoutStats).where(TryoutStats.tryout_id == tryout_id)
|
||||
if website_id is not None:
|
||||
stats_query = stats_query.where(TryoutStats.website_id == website_id)
|
||||
|
||||
stats_result = await db.execute(stats_query)
|
||||
stats = stats_result.scalar_one_or_none()
|
||||
current_participant_count = stats.participant_count if stats else 0
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(tryout)
|
||||
|
||||
return NormalizationUpdateResponse(
|
||||
tryout_id=tryout.tryout_id,
|
||||
normalization_mode=tryout.normalization_mode,
|
||||
static_rataan=tryout.static_rataan,
|
||||
static_sb=tryout.static_sb,
|
||||
will_switch_to_dynamic_at=tryout.min_sample_for_dynamic,
|
||||
current_participant_count=current_participant_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=List[TryoutConfigBrief],
|
||||
summary="List tryouts",
|
||||
description="List all tryouts for a website.",
|
||||
)
|
||||
async def list_tryouts(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> List[TryoutConfigBrief]:
|
||||
"""
|
||||
List all tryouts for a website.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
List of TryoutConfigBrief
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||
|
||||
# Get tryouts with stats and items
|
||||
query = select(Tryout).options(selectinload(Tryout.stats), selectinload(Tryout.items))
|
||||
if website_id is not None:
|
||||
query = query.where(Tryout.website_id == website_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
tryouts = result.scalars().all()
|
||||
|
||||
# Get snapshot counts for tryouts to show accurate item_count for JSON imports
|
||||
snapshot_counts = {}
|
||||
if tryouts:
|
||||
tryout_ids = [t.tryout_id for t in tryouts]
|
||||
count_query = (
|
||||
select(TryoutSnapshotQuestion.source_tryout_id, func.count(TryoutSnapshotQuestion.id))
|
||||
.where(TryoutSnapshotQuestion.source_tryout_id.in_(tryout_ids))
|
||||
)
|
||||
if website_id is not None:
|
||||
count_query = count_query.where(TryoutSnapshotQuestion.website_id == website_id)
|
||||
|
||||
count_query = count_query.group_by(TryoutSnapshotQuestion.source_tryout_id)
|
||||
count_result = await db.execute(count_query)
|
||||
snapshot_counts = dict(count_result.all())
|
||||
|
||||
return [
|
||||
TryoutConfigBrief(
|
||||
website_id=t.website_id,
|
||||
tryout_id=t.tryout_id,
|
||||
name=t.name,
|
||||
scoring_mode=t.scoring_mode,
|
||||
selection_mode=t.selection_mode,
|
||||
normalization_mode=t.normalization_mode,
|
||||
participant_count=t.stats.participant_count if t.stats else 0,
|
||||
rataan=t.stats.rataan if t.stats else None,
|
||||
sb=t.stats.sb if t.stats else None,
|
||||
item_count=len(t.items) or snapshot_counts.get(t.tryout_id, 0),
|
||||
calibrated_item_count=sum(1 for i in t.items if i.calibrated),
|
||||
)
|
||||
for t in tryouts
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{tryout_id}/calibration-status",
|
||||
summary="Get calibration status",
|
||||
description="Get IRT calibration status for items in this tryout.",
|
||||
)
|
||||
async def get_calibration_status(
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
"""
|
||||
Get calibration status for items in a tryout.
|
||||
|
||||
Returns statistics on how many items are calibrated and ready for IRT.
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
Calibration status summary
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout not found
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
|
||||
# Verify tryout exists
|
||||
query = select(Tryout).where(Tryout.tryout_id == tryout_id)
|
||||
if website_id is not None:
|
||||
query = query.where(Tryout.website_id == website_id)
|
||||
|
||||
tryout_result = await db.execute(query)
|
||||
tryout = tryout_result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tryout {tryout_id} not found for website {website_id}",
|
||||
)
|
||||
|
||||
# Get calibration statistics
|
||||
stats_query = select(
|
||||
func.count().label("total_items"),
|
||||
func.sum(cast(Item.calibrated, Integer)).label("calibrated_items"),
|
||||
func.avg(Item.calibration_sample_size).label("avg_sample_size"),
|
||||
).where(Item.tryout_id == tryout_id)
|
||||
|
||||
if website_id is not None:
|
||||
stats_query = stats_query.where(Item.website_id == website_id)
|
||||
|
||||
stats_result = await db.execute(stats_query)
|
||||
stats = stats_result.first()
|
||||
|
||||
total_items = stats.total_items or 0
|
||||
calibrated_items = stats.calibrated_items or 0
|
||||
calibration_percentage = (calibrated_items / total_items * 100) if total_items > 0 else 0
|
||||
|
||||
return {
|
||||
"tryout_id": tryout_id,
|
||||
"total_items": total_items,
|
||||
"calibrated_items": calibrated_items,
|
||||
"calibration_percentage": round(calibration_percentage, 2),
|
||||
"avg_sample_size": round(stats.avg_sample_size, 2) if stats.avg_sample_size else 0,
|
||||
"min_calibration_sample": tryout.min_calibration_sample,
|
||||
"ready_for_irt": calibration_percentage >= 90,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{tryout_id}/calibrate",
|
||||
summary="Trigger IRT calibration",
|
||||
description="Trigger IRT calibration for all items in this tryout with sufficient response data.",
|
||||
)
|
||||
async def trigger_calibration(
|
||||
tryout_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
"""
|
||||
Trigger IRT calibration for all items in a tryout.
|
||||
|
||||
Runs calibration for items with >= min_calibration_sample responses.
|
||||
Updates item.irt_b, item.irt_se, and item.calibrated status.
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
Calibration results summary
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout not found or calibration fails
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
|
||||
from app.services.irt_calibration import (
|
||||
calibrate_all,
|
||||
CALIBRATION_SAMPLE_THRESHOLD,
|
||||
)
|
||||
|
||||
# Verify tryout exists
|
||||
query = select(Tryout).where(Tryout.tryout_id == tryout_id)
|
||||
if website_id is not None:
|
||||
query = query.where(Tryout.website_id == website_id)
|
||||
|
||||
tryout_result = await db.execute(query)
|
||||
tryout = tryout_result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tryout {tryout_id} not found for website {website_id}",
|
||||
)
|
||||
|
||||
# Run calibration
|
||||
result = await calibrate_all(
|
||||
tryout_id=tryout_id,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
min_sample_size=tryout.min_calibration_sample or CALIBRATION_SAMPLE_THRESHOLD,
|
||||
)
|
||||
|
||||
return {
|
||||
"tryout_id": tryout_id,
|
||||
"total_items": result.total_items,
|
||||
"calibrated_items": result.calibrated_items,
|
||||
"failed_items": result.failed_items,
|
||||
"calibration_percentage": round(result.calibration_percentage * 100, 2),
|
||||
"ready_for_irt": result.ready_for_irt,
|
||||
"message": f"Calibration complete: {result.calibrated_items}/{result.total_items} items calibrated",
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{tryout_id}/calibrate/{item_id}",
|
||||
summary="Trigger IRT calibration for single item",
|
||||
description="Trigger IRT calibration for a specific item.",
|
||||
)
|
||||
async def trigger_item_calibration(
|
||||
tryout_id: str,
|
||||
item_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
"""
|
||||
Trigger IRT calibration for a single item.
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
item_id: Item ID to calibrate
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
Calibration result for the item
|
||||
|
||||
Raises:
|
||||
HTTPException: If tryout or item not found
|
||||
"""
|
||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
|
||||
from app.services.irt_calibration import calibrate_item, CALIBRATION_SAMPLE_THRESHOLD
|
||||
|
||||
# Verify tryout exists
|
||||
query = select(Tryout).where(Tryout.tryout_id == tryout_id)
|
||||
if website_id is not None:
|
||||
query = query.where(Tryout.website_id == website_id)
|
||||
|
||||
tryout_result = await db.execute(query)
|
||||
tryout = tryout_result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tryout {tryout_id} not found for website {website_id}",
|
||||
)
|
||||
|
||||
# Verify item belongs to this tryout
|
||||
item_query = select(Item).where(
|
||||
Item.id == item_id,
|
||||
Item.tryout_id == tryout_id,
|
||||
)
|
||||
if website_id is not None:
|
||||
item_query = item_query.where(Item.website_id == website_id)
|
||||
|
||||
item_result = await db.execute(item_query)
|
||||
item = item_result.scalar_one_or_none()
|
||||
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Item {item_id} not found in tryout {tryout_id}",
|
||||
)
|
||||
|
||||
# Run calibration
|
||||
result = await calibrate_item(
|
||||
item_id=item_id,
|
||||
db=db,
|
||||
min_sample_size=tryout.min_calibration_sample or CALIBRATION_SAMPLE_THRESHOLD,
|
||||
)
|
||||
|
||||
return {
|
||||
"item_id": result.item_id,
|
||||
"status": result.status.value,
|
||||
"irt_b": result.irt_b,
|
||||
"irt_se": result.irt_se,
|
||||
"sample_size": result.sample_size,
|
||||
"message": result.message,
|
||||
}
|
||||
84
backend/app/routers/websites.py
Normal file
84
backend/app/routers/websites.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Website
|
||||
from app.core.auth import AuthContext, get_auth_context, require_website_auth
|
||||
|
||||
router = APIRouter(tags=["websites"])
|
||||
|
||||
class WebsiteBase(BaseModel):
|
||||
name: str
|
||||
domain: str
|
||||
|
||||
class WebsiteResponse(WebsiteBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@router.get("/websites", response_model=List[WebsiteResponse])
|
||||
async def get_websites(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
result = await db.execute(select(Website).order_by(Website.id.asc()))
|
||||
websites = result.scalars().all()
|
||||
# Map old columns (site_name, site_url) to new response format
|
||||
return [
|
||||
WebsiteResponse(
|
||||
id=w.id,
|
||||
name=w.site_name,
|
||||
domain=w.site_url
|
||||
) for w in websites
|
||||
]
|
||||
|
||||
@router.post("/websites", response_model=WebsiteResponse)
|
||||
async def create_website(
|
||||
payload: WebsiteBase,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
website = Website(site_name=payload.name, site_url=payload.domain)
|
||||
db.add(website)
|
||||
await db.commit()
|
||||
await db.refresh(website)
|
||||
return WebsiteResponse(id=website.id, name=website.site_name, domain=website.site_url)
|
||||
|
||||
@router.put("/websites/{website_id}", response_model=WebsiteResponse)
|
||||
async def update_website(
|
||||
website_id: int,
|
||||
payload: WebsiteBase,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
website = await db.get(Website, website_id)
|
||||
if not website:
|
||||
raise HTTPException(status_code=404, detail="Website not found")
|
||||
|
||||
website.site_name = payload.name
|
||||
website.site_url = payload.domain
|
||||
await db.commit()
|
||||
await db.refresh(website)
|
||||
return WebsiteResponse(id=website.id, name=website.site_name, domain=website.site_url)
|
||||
|
||||
@router.delete("/websites/{website_id}")
|
||||
async def delete_website(
|
||||
website_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
):
|
||||
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
website = await db.get(Website, website_id)
|
||||
if not website:
|
||||
raise HTTPException(status_code=404, detail="Website not found")
|
||||
|
||||
await db.delete(website)
|
||||
await db.commit()
|
||||
return {"status": "success", "message": "Website deleted"}
|
||||
439
backend/app/routers/wordpress.py
Normal file
439
backend/app/routers/wordpress.py
Normal file
@@ -0,0 +1,439 @@
|
||||
"""
|
||||
WordPress Integration API Router.
|
||||
|
||||
Endpoints:
|
||||
- POST /wordpress/sync_users: Synchronize users from WordPress
|
||||
- POST /wordpress/verify_session: Verify WordPress session/token
|
||||
- GET /wordpress/website/{website_id}/users: Get all users for a website
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Request, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.auth import (
|
||||
AuthContext,
|
||||
ensure_website_scope_matches,
|
||||
get_auth_context,
|
||||
issue_access_token,
|
||||
require_website_auth,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.models.website import Website
|
||||
from app.schemas.wordpress import (
|
||||
SyncUsersResponse,
|
||||
SyncStatsResponse,
|
||||
UserListResponse,
|
||||
VerifySessionRequest,
|
||||
VerifySessionResponse,
|
||||
WordPressUserResponse,
|
||||
)
|
||||
from app.services.wordpress_auth import (
|
||||
get_wordpress_user,
|
||||
sync_wordpress_users,
|
||||
verify_website_exists,
|
||||
verify_wordpress_token,
|
||||
get_or_create_user,
|
||||
WordPressAPIError,
|
||||
WordPressRateLimitError,
|
||||
WordPressTokenInvalidError,
|
||||
WebsiteNotFoundError,
|
||||
)
|
||||
from app.core.rate_limit import enforce_rate_limit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/wordpress", tags=["wordpress"])
|
||||
|
||||
|
||||
def _api_role_from_wordpress_roles(roles: list[str]) -> str:
|
||||
"""Map WordPress roles to API roles used by route authorization."""
|
||||
normalized_roles = {str(role).strip().lower() for role in roles}
|
||||
if normalized_roles & {"super_admin", "system_admin"}:
|
||||
return "system_admin"
|
||||
if normalized_roles & {"administrator", "admin"}:
|
||||
return "admin"
|
||||
return "student"
|
||||
|
||||
|
||||
def get_website_id_from_header(
|
||||
x_website_id: Optional[str] = Header(None, alias="X-Website-ID"),
|
||||
) -> int:
|
||||
"""
|
||||
Extract and validate website_id from request header.
|
||||
|
||||
Args:
|
||||
x_website_id: Website ID from header
|
||||
|
||||
Returns:
|
||||
Validated website ID as integer
|
||||
|
||||
Raises:
|
||||
HTTPException: If header is missing or invalid
|
||||
"""
|
||||
if x_website_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="X-Website-ID header is required",
|
||||
)
|
||||
try:
|
||||
return int(x_website_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="X-Website-ID must be a valid integer",
|
||||
)
|
||||
|
||||
|
||||
async def get_valid_website(
|
||||
website_id: int,
|
||||
db: AsyncSession,
|
||||
) -> Website:
|
||||
"""
|
||||
Validate website_id exists and return Website model.
|
||||
|
||||
Args:
|
||||
website_id: Website identifier
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Website model instance
|
||||
|
||||
Raises:
|
||||
HTTPException: If website not found
|
||||
"""
|
||||
try:
|
||||
return await verify_website_exists(website_id, db)
|
||||
except WebsiteNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Website {website_id} not found",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sync_users",
|
||||
response_model=SyncUsersResponse,
|
||||
summary="Synchronize users from WordPress",
|
||||
description="Fetch all users from WordPress API and sync to local database. Requires admin WordPress token.",
|
||||
)
|
||||
async def sync_users_endpoint(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
website_id: int = Depends(get_website_id_from_header),
|
||||
authorization: Optional[str] = Header(None, alias="Authorization"),
|
||||
) -> SyncUsersResponse:
|
||||
"""
|
||||
Synchronize users from WordPress to local database.
|
||||
|
||||
Process:
|
||||
1. Validate website_id exists
|
||||
2. Extract admin token from Authorization header
|
||||
3. Fetch all users from WordPress API
|
||||
4. Upsert: Update existing users, insert new users
|
||||
5. Return sync statistics
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
website_id: Website ID from header
|
||||
authorization: Authorization header with Bearer token
|
||||
|
||||
Returns:
|
||||
SyncUsersResponse with sync statistics
|
||||
|
||||
Raises:
|
||||
HTTPException: If website not found, token invalid, or API error
|
||||
"""
|
||||
await enforce_rate_limit(
|
||||
request,
|
||||
scope="wordpress.sync_users",
|
||||
max_requests=20,
|
||||
window_seconds=300,
|
||||
)
|
||||
|
||||
# Validate website exists
|
||||
await get_valid_website(website_id, db)
|
||||
|
||||
# Extract token from Authorization header
|
||||
if authorization is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authorization header is required",
|
||||
)
|
||||
|
||||
# Parse Bearer token
|
||||
parts = authorization.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Authorization header format. Use: Bearer {token}",
|
||||
)
|
||||
|
||||
admin_token = parts[1]
|
||||
|
||||
try:
|
||||
sync_stats = await sync_wordpress_users(
|
||||
website_id=website_id,
|
||||
admin_token=admin_token,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return SyncUsersResponse(
|
||||
synced=SyncStatsResponse(
|
||||
inserted=sync_stats.inserted,
|
||||
updated=sync_stats.updated,
|
||||
total=sync_stats.total,
|
||||
errors=sync_stats.errors,
|
||||
),
|
||||
website_id=website_id,
|
||||
message=f"Sync completed: {sync_stats.inserted} inserted, {sync_stats.updated} updated",
|
||||
)
|
||||
|
||||
except WordPressTokenInvalidError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e),
|
||||
)
|
||||
except WordPressRateLimitError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=str(e),
|
||||
)
|
||||
except WordPressAPIError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(e),
|
||||
)
|
||||
except WebsiteNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/verify_session",
|
||||
response_model=VerifySessionResponse,
|
||||
summary="Verify WordPress session",
|
||||
description="Verify WordPress JWT token and user identity.",
|
||||
)
|
||||
async def verify_session_endpoint(
|
||||
http_request: Request,
|
||||
request: VerifySessionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> VerifySessionResponse:
|
||||
"""
|
||||
Verify WordPress session/token.
|
||||
|
||||
Process:
|
||||
1. Validate website_id exists
|
||||
2. Call WordPress API to verify token
|
||||
3. Verify wp_user_id matches token owner
|
||||
4. Get or create local user
|
||||
5. Return validation result
|
||||
|
||||
Args:
|
||||
request: VerifySessionRequest with wp_user_id, token, website_id
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
VerifySessionResponse with validation result
|
||||
|
||||
Raises:
|
||||
HTTPException: If website not found or API error
|
||||
"""
|
||||
await enforce_rate_limit(
|
||||
http_request,
|
||||
scope="wordpress.verify_session",
|
||||
max_requests=60,
|
||||
window_seconds=300,
|
||||
)
|
||||
|
||||
# Validate website exists
|
||||
await get_valid_website(request.website_id, db)
|
||||
|
||||
try:
|
||||
# Verify token with WordPress
|
||||
wp_user_info = await verify_wordpress_token(
|
||||
token=request.token,
|
||||
website_id=request.website_id,
|
||||
wp_user_id=request.wp_user_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if wp_user_info is None:
|
||||
return VerifySessionResponse(
|
||||
valid=False,
|
||||
error="User ID mismatch or invalid credentials",
|
||||
)
|
||||
|
||||
# Get or create local user
|
||||
user = await get_or_create_user(
|
||||
wp_user_id=request.wp_user_id,
|
||||
website_id=request.website_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return VerifySessionResponse(
|
||||
valid=True,
|
||||
user=WordPressUserResponse.model_validate(user),
|
||||
wp_user_info={
|
||||
"username": wp_user_info.username,
|
||||
"email": wp_user_info.email,
|
||||
"display_name": wp_user_info.display_name,
|
||||
"roles": wp_user_info.roles,
|
||||
},
|
||||
access_token=issue_access_token(
|
||||
website_id=request.website_id,
|
||||
role=_api_role_from_wordpress_roles(wp_user_info.roles),
|
||||
wp_user_id=request.wp_user_id,
|
||||
expires_in_seconds=3600 * 24,
|
||||
),
|
||||
)
|
||||
|
||||
except WordPressTokenInvalidError as e:
|
||||
return VerifySessionResponse(
|
||||
valid=False,
|
||||
error=f"Invalid credentials: {str(e)}",
|
||||
)
|
||||
except WordPressRateLimitError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=str(e),
|
||||
)
|
||||
except WordPressAPIError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(e),
|
||||
)
|
||||
except WebsiteNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/website/{website_id}/users",
|
||||
response_model=UserListResponse,
|
||||
summary="Get users for website",
|
||||
description="Retrieve all users for a specific website from local database with pagination.",
|
||||
)
|
||||
async def get_website_users(
|
||||
website_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> UserListResponse:
|
||||
"""
|
||||
Get all users for a website.
|
||||
|
||||
Args:
|
||||
website_id: Website identifier
|
||||
db: Database session
|
||||
page: Page number (default: 1)
|
||||
page_size: Number of users per page (default: 50, max: 100)
|
||||
|
||||
Returns:
|
||||
UserListResponse with paginated user list
|
||||
|
||||
Raises:
|
||||
HTTPException: If website not found
|
||||
"""
|
||||
auth_website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||
ensure_website_scope_matches(auth_website_id, website_id)
|
||||
|
||||
# Validate website exists
|
||||
await get_valid_website(website_id, db)
|
||||
|
||||
# Clamp page_size
|
||||
page_size = min(max(1, page_size), 100)
|
||||
page = max(1, page)
|
||||
|
||||
# Get total count
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(User).where(User.website_id == website_id)
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# Calculate pagination
|
||||
offset = (page - 1) * page_size
|
||||
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
|
||||
|
||||
# Get users
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.website_id == website_id)
|
||||
.order_by(User.id)
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
|
||||
return UserListResponse(
|
||||
users=[WordPressUserResponse.model_validate(user) for user in users],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/website/{website_id}/user/{wp_user_id}",
|
||||
response_model=WordPressUserResponse,
|
||||
summary="Get specific user",
|
||||
description="Retrieve a specific user by WordPress user ID.",
|
||||
)
|
||||
async def get_user_endpoint(
|
||||
website_id: int,
|
||||
wp_user_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> WordPressUserResponse:
|
||||
"""
|
||||
Get a specific user by WordPress user ID.
|
||||
|
||||
Args:
|
||||
website_id: Website identifier
|
||||
wp_user_id: WordPress user ID
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
WordPressUserResponse with user data
|
||||
|
||||
Raises:
|
||||
HTTPException: If website or user not found
|
||||
"""
|
||||
auth_website_id = require_website_auth(
|
||||
auth, allowed_roles={"student", "admin", "system_admin"}
|
||||
)
|
||||
ensure_website_scope_matches(auth_website_id, website_id)
|
||||
if auth.role == "student" and auth.wp_user_id != wp_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User does not belong to this authenticated user",
|
||||
)
|
||||
|
||||
# Validate website exists
|
||||
await get_valid_website(website_id, db)
|
||||
|
||||
# Get user
|
||||
user = await get_wordpress_user(
|
||||
wp_user_id=wp_user_id,
|
||||
website_id=website_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User {wp_user_id} not found for website {website_id}",
|
||||
)
|
||||
|
||||
return WordPressUserResponse.model_validate(user)
|
||||
65
backend/app/schemas/__init__.py
Normal file
65
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Pydantic schemas package.
|
||||
"""
|
||||
|
||||
from app.schemas.ai import (
|
||||
AIGeneratePreviewRequest,
|
||||
AIGeneratePreviewResponse,
|
||||
AISaveRequest,
|
||||
AISaveResponse,
|
||||
AIStatsResponse,
|
||||
GeneratedQuestion,
|
||||
)
|
||||
from app.schemas.session import (
|
||||
SessionCompleteRequest,
|
||||
SessionCompleteResponse,
|
||||
SessionCreateRequest,
|
||||
SessionResponse,
|
||||
UserAnswerInput,
|
||||
UserAnswerOutput,
|
||||
)
|
||||
from app.schemas.tryout import (
|
||||
NormalizationUpdateRequest,
|
||||
NormalizationUpdateResponse,
|
||||
TryoutConfigBrief,
|
||||
TryoutConfigResponse,
|
||||
TryoutStatsResponse,
|
||||
)
|
||||
from app.schemas.wordpress import (
|
||||
SyncStatsResponse,
|
||||
SyncUsersResponse,
|
||||
UserListResponse,
|
||||
VerifySessionRequest,
|
||||
VerifySessionResponse,
|
||||
WordPressUserResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# AI schemas
|
||||
"AIGeneratePreviewRequest",
|
||||
"AIGeneratePreviewResponse",
|
||||
"AISaveRequest",
|
||||
"AISaveResponse",
|
||||
"AIStatsResponse",
|
||||
"GeneratedQuestion",
|
||||
# Session schemas
|
||||
"UserAnswerInput",
|
||||
"UserAnswerOutput",
|
||||
"SessionCompleteRequest",
|
||||
"SessionCompleteResponse",
|
||||
"SessionCreateRequest",
|
||||
"SessionResponse",
|
||||
# Tryout schemas
|
||||
"TryoutConfigResponse",
|
||||
"TryoutStatsResponse",
|
||||
"TryoutConfigBrief",
|
||||
"NormalizationUpdateRequest",
|
||||
"NormalizationUpdateResponse",
|
||||
# WordPress schemas
|
||||
"SyncStatsResponse",
|
||||
"SyncUsersResponse",
|
||||
"UserListResponse",
|
||||
"VerifySessionRequest",
|
||||
"VerifySessionResponse",
|
||||
"WordPressUserResponse",
|
||||
]
|
||||
180
backend/app/schemas/ai.py
Normal file
180
backend/app/schemas/ai.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Pydantic schemas for AI generation endpoints.
|
||||
|
||||
Request/response models for admin AI generation playground.
|
||||
"""
|
||||
|
||||
from typing import Dict, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
OPTION_LABELS = tuple("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
|
||||
|
||||
class AIGeneratePreviewRequest(BaseModel):
|
||||
basis_item_id: int = Field(
|
||||
..., description="ID of the basis item (must be sedang level)"
|
||||
)
|
||||
target_level: Literal["mudah", "sulit"] = Field(
|
||||
..., description="Target difficulty level for generated question"
|
||||
)
|
||||
ai_model: str = Field(
|
||||
default="qwen/qwen2.5-32b-instruct",
|
||||
description="AI model to use for generation",
|
||||
)
|
||||
|
||||
|
||||
class AIModelPricing(BaseModel):
|
||||
prompt: Optional[float] = Field(
|
||||
default=None, description="Input token price in USD per token"
|
||||
)
|
||||
completion: Optional[float] = Field(
|
||||
default=None, description="Output token price in USD per token"
|
||||
)
|
||||
prompt_per_million: Optional[float] = Field(
|
||||
default=None, description="Input token price in USD per 1M tokens"
|
||||
)
|
||||
completion_per_million: Optional[float] = Field(
|
||||
default=None, description="Output token price in USD per 1M tokens"
|
||||
)
|
||||
currency: str = "USD"
|
||||
source: str = "openrouter"
|
||||
|
||||
|
||||
class AIUsageInfo(BaseModel):
|
||||
prompt_tokens: Optional[int] = None
|
||||
completion_tokens: Optional[int] = None
|
||||
total_tokens: Optional[int] = None
|
||||
cost_usd: Optional[float] = None
|
||||
|
||||
|
||||
class AIGeneratePreviewResponse(BaseModel):
|
||||
success: bool = Field(..., description="Whether generation was successful")
|
||||
stem: Optional[str] = None
|
||||
options: Optional[Dict[str, str]] = None
|
||||
correct: Optional[str] = None
|
||||
explanation: Optional[str] = None
|
||||
ai_model: Optional[str] = None
|
||||
basis_item_id: Optional[int] = None
|
||||
target_level: Optional[str] = None
|
||||
usage: Optional[AIUsageInfo] = None
|
||||
error: Optional[str] = None
|
||||
cached: bool = False
|
||||
|
||||
|
||||
class AISaveRequest(BaseModel):
|
||||
stem: str = Field(..., description="Question stem")
|
||||
options: Dict[str, str] = Field(
|
||||
..., description="Answer options. Labels must match the basis item exactly."
|
||||
)
|
||||
correct: str = Field(..., description="Correct answer option label")
|
||||
explanation: Optional[str] = None
|
||||
tryout_id: str = Field(..., description="Tryout identifier")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
basis_item_id: int = Field(..., description="Basis item ID")
|
||||
slot: int = Field(..., description="Question slot position")
|
||||
level: Literal["mudah", "sedang", "sulit"] = Field(
|
||||
..., description="Difficulty level"
|
||||
)
|
||||
variant_status: Literal["active", "draft"] = Field(
|
||||
default="active",
|
||||
description="Lifecycle status for the saved variant. Workspace approvals save active variants.",
|
||||
)
|
||||
ai_model: str = Field(
|
||||
default="qwen/qwen2.5-32b-instruct",
|
||||
description="AI model used for generation",
|
||||
)
|
||||
|
||||
@field_validator("correct")
|
||||
@classmethod
|
||||
def validate_correct(cls, v: str) -> str:
|
||||
label = v.upper()
|
||||
if label not in OPTION_LABELS:
|
||||
raise ValueError("Correct answer must be an option label A-Z")
|
||||
return label
|
||||
|
||||
@field_validator("options")
|
||||
@classmethod
|
||||
def validate_options(cls, v: Dict[str, str]) -> Dict[str, str]:
|
||||
normalized = {
|
||||
str(key).strip().upper(): str(value).strip()
|
||||
for key, value in v.items()
|
||||
if str(key).strip() and str(value).strip()
|
||||
}
|
||||
if len(normalized) < 2:
|
||||
raise ValueError("Options must contain at least two non-empty choices")
|
||||
invalid_keys = sorted(set(normalized) - set(OPTION_LABELS))
|
||||
if invalid_keys:
|
||||
raise ValueError(f"Options contain invalid labels: {', '.join(invalid_keys)}")
|
||||
return normalized
|
||||
|
||||
|
||||
class AISaveResponse(BaseModel):
|
||||
success: bool = Field(..., description="Whether save was successful")
|
||||
item_id: Optional[int] = None
|
||||
run_id: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class AIGenerateBatchRequest(BaseModel):
|
||||
basis_item_id: int = Field(
|
||||
..., description="ID of the basis item (must be sedang level)"
|
||||
)
|
||||
target_level: Literal["mudah", "sulit"] = Field(
|
||||
..., description="Target difficulty level for generated questions"
|
||||
)
|
||||
ai_model: str = Field(
|
||||
default="qwen/qwen2.5-32b-instruct",
|
||||
description="AI model to use for generation",
|
||||
)
|
||||
count: int = Field(default=3, ge=1, le=10, description="Number of variants to generate")
|
||||
operator_notes: Optional[str] = None
|
||||
|
||||
|
||||
class AIBatchGeneratedItem(BaseModel):
|
||||
item_id: int
|
||||
stem: str
|
||||
options: Dict[str, str]
|
||||
correct: str
|
||||
explanation: Optional[str] = None
|
||||
level: str
|
||||
variant_status: str
|
||||
usage: Optional[AIUsageInfo] = None
|
||||
|
||||
|
||||
class AIGenerateBatchResponse(BaseModel):
|
||||
success: bool
|
||||
run_id: Optional[int] = None
|
||||
item_ids: list[int] = Field(default_factory=list)
|
||||
items: list[AIBatchGeneratedItem] = Field(default_factory=list)
|
||||
generated_count: int = 0
|
||||
usage: Optional[AIUsageInfo] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class AIStatsResponse(BaseModel):
|
||||
total_ai_items: int = Field(..., description="Total AI-generated items")
|
||||
items_by_model: Dict[str, int] = Field(
|
||||
default_factory=dict, description="Items count by AI model"
|
||||
)
|
||||
cache_hit_rate: float = Field(
|
||||
default=0.0, description="Cache hit rate (0.0 to 1.0)"
|
||||
)
|
||||
total_cache_hits: int = Field(default=0, description="Total cache hits")
|
||||
total_requests: int = Field(default=0, description="Total generation requests")
|
||||
|
||||
|
||||
class GeneratedQuestion(BaseModel):
|
||||
stem: str
|
||||
options: Dict[str, str]
|
||||
correct: str
|
||||
explanation: Optional[str] = None
|
||||
usage: Optional[AIUsageInfo] = None
|
||||
|
||||
@field_validator("correct")
|
||||
@classmethod
|
||||
def validate_correct(cls, v: str) -> str:
|
||||
label = v.upper()
|
||||
if label not in OPTION_LABELS:
|
||||
raise ValueError("Correct answer must be an option label A-Z")
|
||||
return label
|
||||
264
backend/app/schemas/report.py
Normal file
264
backend/app/schemas/report.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
Pydantic schemas for Report API endpoints.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Student Performance Report Schemas
|
||||
# =============================================================================
|
||||
|
||||
class StudentPerformanceRecordOutput(BaseModel):
|
||||
"""Individual student performance record output."""
|
||||
|
||||
session_id: str
|
||||
wp_user_id: str
|
||||
tryout_id: str
|
||||
NM: Optional[int] = None
|
||||
NN: Optional[int] = None
|
||||
theta: Optional[float] = None
|
||||
theta_se: Optional[float] = None
|
||||
total_benar: int
|
||||
time_spent: int # Total time in seconds
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
scoring_mode_used: str
|
||||
rataan_used: Optional[float] = None
|
||||
sb_used: Optional[float] = None
|
||||
|
||||
|
||||
class AggregatePerformanceStatsOutput(BaseModel):
|
||||
"""Aggregate statistics for student performance output."""
|
||||
|
||||
tryout_id: str
|
||||
participant_count: int
|
||||
avg_nm: Optional[float] = None
|
||||
std_nm: Optional[float] = None
|
||||
min_nm: Optional[int] = None
|
||||
max_nm: Optional[int] = None
|
||||
median_nm: Optional[float] = None
|
||||
avg_nn: Optional[float] = None
|
||||
std_nn: Optional[float] = None
|
||||
avg_theta: Optional[float] = None
|
||||
pass_rate: float # Percentage with NN >= 500
|
||||
avg_time_spent: float # Average time in seconds
|
||||
|
||||
|
||||
class StudentPerformanceReportOutput(BaseModel):
|
||||
"""Complete student performance report output."""
|
||||
|
||||
generated_at: datetime
|
||||
tryout_id: str
|
||||
website_id: int
|
||||
date_range: Optional[Dict[str, str]] = None
|
||||
aggregate: AggregatePerformanceStatsOutput
|
||||
individual_records: List[StudentPerformanceRecordOutput] = []
|
||||
|
||||
|
||||
class StudentPerformanceReportRequest(BaseModel):
|
||||
"""Request schema for student performance report."""
|
||||
|
||||
tryout_id: str = Field(..., description="Tryout identifier")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
date_start: Optional[datetime] = Field(None, description="Filter by start date")
|
||||
date_end: Optional[datetime] = Field(None, description="Filter by end date")
|
||||
format_type: Literal["individual", "aggregate", "both"] = Field(
|
||||
default="both", description="Report format"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Item Analysis Report Schemas
|
||||
# =============================================================================
|
||||
|
||||
class ItemAnalysisRecordOutput(BaseModel):
|
||||
"""Item analysis record output for a single item."""
|
||||
|
||||
item_id: int
|
||||
slot: int
|
||||
level: str
|
||||
ctt_p: Optional[float] = None
|
||||
ctt_bobot: Optional[float] = None
|
||||
ctt_category: Optional[str] = None
|
||||
irt_b: Optional[float] = None
|
||||
irt_se: Optional[float] = None
|
||||
calibrated: bool
|
||||
calibration_sample_size: int
|
||||
correctness_rate: float
|
||||
item_total_correlation: Optional[float] = None
|
||||
information_values: Dict[float, float] = Field(default_factory=dict)
|
||||
optimal_theta_range: str = "N/A"
|
||||
|
||||
|
||||
class ItemAnalysisReportOutput(BaseModel):
|
||||
"""Complete item analysis report output."""
|
||||
|
||||
generated_at: datetime
|
||||
tryout_id: str
|
||||
website_id: int
|
||||
total_items: int
|
||||
items: List[ItemAnalysisRecordOutput]
|
||||
summary: Dict[str, Any]
|
||||
|
||||
|
||||
class ItemAnalysisReportRequest(BaseModel):
|
||||
"""Request schema for item analysis report."""
|
||||
|
||||
tryout_id: str = Field(..., description="Tryout identifier")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
filter_by: Optional[Literal["difficulty", "calibrated", "discrimination"]] = Field(
|
||||
None, description="Filter items by category"
|
||||
)
|
||||
difficulty_level: Optional[Literal["mudah", "sedang", "sulit"]] = Field(
|
||||
None, description="Filter by difficulty level (only when filter_by='difficulty')"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Calibration Status Report Schemas
|
||||
# =============================================================================
|
||||
|
||||
class CalibrationItemStatusOutput(BaseModel):
|
||||
"""Calibration status for a single item output."""
|
||||
|
||||
item_id: int
|
||||
slot: int
|
||||
level: str
|
||||
sample_size: int
|
||||
calibrated: bool
|
||||
irt_b: Optional[float] = None
|
||||
irt_se: Optional[float] = None
|
||||
ctt_p: Optional[float] = None
|
||||
|
||||
|
||||
class CalibrationStatusReportOutput(BaseModel):
|
||||
"""Complete calibration status report output."""
|
||||
|
||||
generated_at: datetime
|
||||
tryout_id: str
|
||||
website_id: int
|
||||
total_items: int
|
||||
calibrated_items: int
|
||||
calibration_percentage: float
|
||||
items_awaiting_calibration: List[CalibrationItemStatusOutput]
|
||||
avg_calibration_sample_size: float
|
||||
estimated_time_to_90_percent: Optional[str] = None
|
||||
ready_for_irt_rollout: bool
|
||||
items: List[CalibrationItemStatusOutput]
|
||||
|
||||
|
||||
class CalibrationStatusReportRequest(BaseModel):
|
||||
"""Request schema for calibration status report."""
|
||||
|
||||
tryout_id: str = Field(..., description="Tryout identifier")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tryout Comparison Report Schemas
|
||||
# =============================================================================
|
||||
|
||||
class TryoutComparisonRecordOutput(BaseModel):
|
||||
"""Tryout comparison data point output."""
|
||||
|
||||
tryout_id: str
|
||||
date: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
participant_count: int
|
||||
avg_nm: Optional[float] = None
|
||||
avg_nn: Optional[float] = None
|
||||
avg_theta: Optional[float] = None
|
||||
std_nm: Optional[float] = None
|
||||
calibration_percentage: float
|
||||
|
||||
|
||||
class TryoutComparisonReportOutput(BaseModel):
|
||||
"""Complete tryout comparison report output."""
|
||||
|
||||
generated_at: datetime
|
||||
comparison_type: Literal["date", "subject"]
|
||||
tryouts: List[TryoutComparisonRecordOutput]
|
||||
trends: Optional[Dict[str, Any]] = None
|
||||
normalization_impact: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class TryoutComparisonReportRequest(BaseModel):
|
||||
"""Request schema for tryout comparison report."""
|
||||
|
||||
tryout_ids: List[str] = Field(..., min_length=2, description="List of tryout IDs to compare")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
group_by: Literal["date", "subject"] = Field(
|
||||
default="date", description="Group comparison by date or subject"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Report Scheduling Schemas
|
||||
# =============================================================================
|
||||
|
||||
class ReportScheduleRequest(BaseModel):
|
||||
"""Request schema for scheduling a report."""
|
||||
|
||||
report_type: Literal["student_performance", "item_analysis", "calibration_status", "tryout_comparison"] = Field(
|
||||
..., description="Type of report to generate"
|
||||
)
|
||||
schedule: Literal["daily", "weekly", "monthly"] = Field(
|
||||
..., description="Schedule frequency"
|
||||
)
|
||||
tryout_ids: List[str] = Field(..., description="List of tryout IDs for the report")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
recipients: List[str] = Field(..., description="List of email addresses to send report to")
|
||||
export_format: Literal["csv", "xlsx", "pdf"] = Field(
|
||||
default="xlsx", description="Export format for the report"
|
||||
)
|
||||
|
||||
|
||||
class ReportScheduleOutput(BaseModel):
|
||||
"""Output schema for scheduled report."""
|
||||
|
||||
schedule_id: str
|
||||
report_type: str
|
||||
schedule: str
|
||||
tryout_ids: List[str]
|
||||
website_id: int
|
||||
recipients: List[str]
|
||||
format: str
|
||||
created_at: datetime
|
||||
last_run: Optional[datetime] = None
|
||||
next_run: Optional[datetime] = None
|
||||
is_active: bool
|
||||
|
||||
|
||||
class ReportScheduleResponse(BaseModel):
|
||||
"""Response schema for schedule creation."""
|
||||
|
||||
schedule_id: str
|
||||
message: str
|
||||
next_run: Optional[datetime] = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Export Schemas
|
||||
# =============================================================================
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
"""Request schema for exporting a report."""
|
||||
|
||||
schedule_id: str = Field(..., description="Schedule ID to generate report for")
|
||||
export_format: Literal["csv", "xlsx", "pdf"] = Field(
|
||||
default="xlsx", description="Export format"
|
||||
)
|
||||
|
||||
|
||||
class ExportResponse(BaseModel):
|
||||
"""Response schema for export request."""
|
||||
|
||||
file_path: str
|
||||
file_name: str
|
||||
format: str
|
||||
generated_at: datetime
|
||||
download_url: Optional[str] = None
|
||||
121
backend/app/schemas/session.py
Normal file
121
backend/app/schemas/session.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Pydantic schemas for Session API endpoints.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UserAnswerInput(BaseModel):
|
||||
"""Input schema for a single user answer."""
|
||||
|
||||
item_id: int = Field(..., description="Item/question ID")
|
||||
response: str = Field(..., min_length=1, max_length=10, description="User's answer (A, B, C, D)")
|
||||
time_spent: int = Field(default=0, ge=0, description="Time spent on this question (seconds)")
|
||||
|
||||
|
||||
class SessionCompleteRequest(BaseModel):
|
||||
"""Request schema for completing a session."""
|
||||
|
||||
end_time: datetime = Field(..., description="Session end timestamp")
|
||||
user_answers: List[UserAnswerInput] = Field(..., description="List of user answers")
|
||||
|
||||
|
||||
class UserAnswerOutput(BaseModel):
|
||||
"""Output schema for a single user answer."""
|
||||
|
||||
id: int
|
||||
item_id: int
|
||||
response: str
|
||||
time_spent: int
|
||||
bobot_earned: float
|
||||
scoring_mode_used: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserAnswerReviewOutput(UserAnswerOutput):
|
||||
"""Review output for a single answer."""
|
||||
|
||||
is_correct: bool
|
||||
|
||||
|
||||
class SessionCompleteResponse(BaseModel):
|
||||
"""Response schema for completed session with CTT scores."""
|
||||
|
||||
id: int
|
||||
session_id: str
|
||||
wp_user_id: str
|
||||
website_id: int
|
||||
tryout_id: str
|
||||
start_time: datetime
|
||||
end_time: Optional[datetime]
|
||||
expires_at: Optional[datetime] = None
|
||||
is_completed: bool
|
||||
scoring_mode_used: str
|
||||
|
||||
# CTT scores
|
||||
total_benar: int = Field(description="Total correct answers")
|
||||
total_bobot_earned: float = Field(description="Total weight earned")
|
||||
NM: Optional[int] = Field(description="Nilai Mentah (raw score) [0, 1000]")
|
||||
NN: Optional[int] = Field(description="Nilai Nasional (normalized score) [0, 1000]")
|
||||
|
||||
# Normalization metadata
|
||||
rataan_used: Optional[float] = Field(description="Mean value used for normalization")
|
||||
sb_used: Optional[float] = Field(description="Standard deviation used for normalization")
|
||||
|
||||
# User answers
|
||||
user_answers: List[UserAnswerOutput]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SessionCompleteAdminResponse(SessionCompleteResponse):
|
||||
"""Completed session response with answer correctness for admin/review contexts."""
|
||||
|
||||
user_answers: List[UserAnswerReviewOutput]
|
||||
|
||||
|
||||
class SessionCreateRequest(BaseModel):
|
||||
"""Request schema for creating a new session."""
|
||||
|
||||
session_id: str = Field(..., description="Unique session identifier")
|
||||
wp_user_id: str = Field(..., description="WordPress user ID")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
tryout_id: str = Field(..., description="Tryout identifier")
|
||||
scoring_mode: Literal["ctt", "irt", "hybrid"] = Field(
|
||||
default="ctt", description="Scoring mode for this session"
|
||||
)
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
"""Response schema for session data."""
|
||||
|
||||
id: int
|
||||
session_id: str
|
||||
wp_user_id: str
|
||||
website_id: int
|
||||
tryout_id: str
|
||||
start_time: datetime
|
||||
end_time: Optional[datetime]
|
||||
expires_at: Optional[datetime] = None
|
||||
is_completed: bool
|
||||
scoring_mode_used: str
|
||||
|
||||
# CTT scores (populated after completion)
|
||||
total_benar: int
|
||||
total_bobot_earned: float
|
||||
NM: Optional[int]
|
||||
NN: Optional[int]
|
||||
|
||||
# IRT scores (populated after completion)
|
||||
theta: Optional[float]
|
||||
theta_se: Optional[float]
|
||||
|
||||
# Normalization metadata
|
||||
rataan_used: Optional[float]
|
||||
sb_used: Optional[float]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
120
backend/app/schemas/tryout.py
Normal file
120
backend/app/schemas/tryout.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Pydantic schemas for Tryout API endpoints.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TryoutConfigResponse(BaseModel):
|
||||
"""Response schema for tryout configuration."""
|
||||
|
||||
id: int
|
||||
website_id: int
|
||||
tryout_id: str
|
||||
name: str
|
||||
description: Optional[str]
|
||||
|
||||
# Scoring configuration
|
||||
scoring_mode: Literal["ctt", "irt", "hybrid"]
|
||||
selection_mode: Literal["fixed", "adaptive", "hybrid"]
|
||||
normalization_mode: Literal["static", "dynamic", "hybrid"]
|
||||
|
||||
# Normalization settings
|
||||
min_sample_for_dynamic: int
|
||||
static_rataan: float
|
||||
static_sb: float
|
||||
|
||||
# AI generation
|
||||
ai_generation_enabled: bool
|
||||
|
||||
# Hybrid mode settings
|
||||
hybrid_transition_slot: Optional[int]
|
||||
|
||||
# IRT settings
|
||||
min_calibration_sample: int
|
||||
theta_estimation_method: Literal["mle", "map", "eap"]
|
||||
fallback_to_ctt_on_error: bool
|
||||
|
||||
# Current stats
|
||||
current_stats: Optional["TryoutStatsResponse"]
|
||||
|
||||
# Timestamps
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TryoutStatsResponse(BaseModel):
|
||||
"""Response schema for tryout statistics."""
|
||||
|
||||
participant_count: int
|
||||
rataan: Optional[float]
|
||||
sb: Optional[float]
|
||||
min_nm: Optional[int]
|
||||
max_nm: Optional[int]
|
||||
last_calculated: Optional[datetime]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TryoutConfigBrief(BaseModel):
|
||||
"""Brief tryout config for list responses."""
|
||||
|
||||
website_id: int
|
||||
tryout_id: str
|
||||
name: str
|
||||
scoring_mode: str
|
||||
selection_mode: str
|
||||
normalization_mode: str
|
||||
participant_count: Optional[int] = None
|
||||
rataan: Optional[float] = None
|
||||
sb: Optional[float] = None
|
||||
item_count: int = 0
|
||||
calibrated_item_count: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TryoutConfigUpdateRequest(BaseModel):
|
||||
"""Request schema for updating editable tryout configuration."""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
scoring_mode: Optional[Literal["ctt", "irt", "hybrid"]] = None
|
||||
selection_mode: Optional[Literal["fixed", "adaptive", "hybrid"]] = None
|
||||
normalization_mode: Optional[Literal["static", "dynamic", "hybrid"]] = None
|
||||
min_sample_for_dynamic: Optional[int] = Field(None, ge=1)
|
||||
static_rataan: Optional[float] = Field(None, ge=0)
|
||||
static_sb: Optional[float] = Field(None, gt=0)
|
||||
ai_generation_enabled: Optional[bool] = None
|
||||
hybrid_transition_slot: Optional[int] = Field(None, ge=1)
|
||||
min_calibration_sample: Optional[int] = Field(None, ge=1)
|
||||
theta_estimation_method: Optional[Literal["mle", "map", "eap"]] = None
|
||||
fallback_to_ctt_on_error: Optional[bool] = None
|
||||
|
||||
|
||||
class NormalizationUpdateRequest(BaseModel):
|
||||
"""Request schema for updating normalization settings."""
|
||||
|
||||
normalization_mode: Optional[Literal["static", "dynamic", "hybrid"]] = None
|
||||
static_rataan: Optional[float] = Field(None, ge=0)
|
||||
static_sb: Optional[float] = Field(None, gt=0)
|
||||
|
||||
|
||||
class NormalizationUpdateResponse(BaseModel):
|
||||
"""Response schema for normalization update."""
|
||||
|
||||
tryout_id: str
|
||||
normalization_mode: str
|
||||
static_rataan: float
|
||||
static_sb: float
|
||||
will_switch_to_dynamic_at: int
|
||||
current_participant_count: int
|
||||
|
||||
|
||||
# Update forward reference
|
||||
TryoutConfigResponse.model_rebuild()
|
||||
90
backend/app/schemas/wordpress.py
Normal file
90
backend/app/schemas/wordpress.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Pydantic schemas for WordPress Integration API endpoints.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class VerifySessionRequest(BaseModel):
|
||||
"""Request schema for verifying WordPress session."""
|
||||
|
||||
wp_user_id: str = Field(..., description="WordPress user ID")
|
||||
token: str = Field(..., description="WordPress JWT authentication token")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
|
||||
|
||||
class WordPressUserResponse(BaseModel):
|
||||
"""Response schema for WordPress user data."""
|
||||
|
||||
id: int = Field(..., description="Local database user ID")
|
||||
wp_user_id: str = Field(..., description="WordPress user ID")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
created_at: datetime = Field(..., description="User creation timestamp")
|
||||
updated_at: datetime = Field(..., description="User last update timestamp")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class VerifySessionResponse(BaseModel):
|
||||
"""Response schema for session verification."""
|
||||
|
||||
valid: bool = Field(..., description="Whether the session is valid")
|
||||
user: Optional[WordPressUserResponse] = Field(
|
||||
default=None, description="User data if session is valid"
|
||||
)
|
||||
error: Optional[str] = Field(
|
||||
default=None, description="Error message if session is invalid"
|
||||
)
|
||||
wp_user_info: Optional[dict[str, Any]] = Field(
|
||||
default=None, description="WordPress user info from API"
|
||||
)
|
||||
access_token: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Signed API access token for authenticated website-scoped calls",
|
||||
)
|
||||
|
||||
|
||||
class SyncUsersRequest(BaseModel):
|
||||
"""Request schema for user synchronization (optional body)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SyncStatsResponse(BaseModel):
|
||||
"""Response schema for user synchronization statistics."""
|
||||
|
||||
inserted: int = Field(..., description="Number of users inserted")
|
||||
updated: int = Field(..., description="Number of users updated")
|
||||
total: int = Field(..., description="Total users processed")
|
||||
errors: int = Field(default=0, description="Number of errors during sync")
|
||||
|
||||
|
||||
class SyncUsersResponse(BaseModel):
|
||||
"""Response schema for user synchronization."""
|
||||
|
||||
synced: SyncStatsResponse = Field(..., description="Synchronization statistics")
|
||||
website_id: int = Field(..., description="Website identifier")
|
||||
message: str = Field(default="Sync completed", description="Status message")
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
"""Response schema for paginated user list."""
|
||||
|
||||
users: List[WordPressUserResponse] = Field(..., description="List of users")
|
||||
total: int = Field(..., description="Total number of users")
|
||||
page: int = Field(default=1, description="Current page number")
|
||||
page_size: int = Field(default=50, description="Number of users per page")
|
||||
total_pages: int = Field(default=1, description="Total number of pages")
|
||||
|
||||
|
||||
class WordPressErrorDetail(BaseModel):
|
||||
"""Detail schema for WordPress errors."""
|
||||
|
||||
code: str = Field(..., description="Error code")
|
||||
message: str = Field(..., description="Error message")
|
||||
details: Optional[dict[str, Any]] = Field(
|
||||
default=None, description="Additional error details"
|
||||
)
|
||||
155
backend/app/services/__init__.py
Normal file
155
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Services module for IRT Bank Soal.
|
||||
|
||||
Contains business logic services for:
|
||||
- IRT calibration
|
||||
- CAT selection
|
||||
- WordPress authentication
|
||||
- AI question generation
|
||||
- Reporting
|
||||
"""
|
||||
|
||||
from app.services.irt_calibration import (
|
||||
IRTCalibrationError,
|
||||
calculate_fisher_information,
|
||||
calculate_item_information,
|
||||
calculate_probability,
|
||||
calculate_theta_se,
|
||||
estimate_b_from_ctt_p,
|
||||
estimate_theta_mle,
|
||||
get_session_responses,
|
||||
nn_to_theta,
|
||||
theta_to_nn,
|
||||
update_session_theta,
|
||||
update_theta_after_response,
|
||||
)
|
||||
from app.services.cat_selection import (
|
||||
CATSelectionError,
|
||||
NextItemResult,
|
||||
TerminationCheck,
|
||||
check_user_level_reuse,
|
||||
get_available_levels_for_slot,
|
||||
get_next_item,
|
||||
get_next_item_adaptive,
|
||||
get_next_item_fixed,
|
||||
get_next_item_hybrid,
|
||||
should_terminate,
|
||||
simulate_cat_selection,
|
||||
update_theta,
|
||||
)
|
||||
from app.services.wordpress_auth import (
|
||||
WordPressAPIError,
|
||||
WordPressAuthError,
|
||||
WordPressRateLimitError,
|
||||
WordPressTokenInvalidError,
|
||||
WordPressUserInfo,
|
||||
WebsiteNotFoundError,
|
||||
SyncStats,
|
||||
fetch_wordpress_users,
|
||||
get_or_create_user,
|
||||
get_wordpress_user,
|
||||
sync_wordpress_users,
|
||||
verify_website_exists,
|
||||
verify_wordpress_token,
|
||||
)
|
||||
from app.services.ai_generation import (
|
||||
call_openrouter_api,
|
||||
check_cache_reuse,
|
||||
generate_question,
|
||||
generate_with_cache_check,
|
||||
get_ai_stats,
|
||||
get_prompt_template,
|
||||
parse_ai_response,
|
||||
save_ai_question,
|
||||
validate_ai_model,
|
||||
SUPPORTED_MODELS,
|
||||
)
|
||||
from app.services.reporting import (
|
||||
generate_student_performance_report,
|
||||
generate_item_analysis_report,
|
||||
generate_calibration_status_report,
|
||||
generate_tryout_comparison_report,
|
||||
export_report_to_csv,
|
||||
export_report_to_excel,
|
||||
export_report_to_pdf,
|
||||
schedule_report,
|
||||
get_scheduled_report,
|
||||
list_scheduled_reports,
|
||||
cancel_scheduled_report,
|
||||
StudentPerformanceReport,
|
||||
ItemAnalysisReport,
|
||||
CalibrationStatusReport,
|
||||
TryoutComparisonReport,
|
||||
ReportSchedule,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# IRT Calibration
|
||||
"IRTCalibrationError",
|
||||
"calculate_fisher_information",
|
||||
"calculate_item_information",
|
||||
"calculate_probability",
|
||||
"calculate_theta_se",
|
||||
"estimate_b_from_ctt_p",
|
||||
"estimate_theta_mle",
|
||||
"get_session_responses",
|
||||
"nn_to_theta",
|
||||
"theta_to_nn",
|
||||
"update_session_theta",
|
||||
"update_theta_after_response",
|
||||
# CAT Selection
|
||||
"CATSelectionError",
|
||||
"NextItemResult",
|
||||
"TerminationCheck",
|
||||
"check_user_level_reuse",
|
||||
"get_available_levels_for_slot",
|
||||
"get_next_item",
|
||||
"get_next_item_adaptive",
|
||||
"get_next_item_fixed",
|
||||
"get_next_item_hybrid",
|
||||
"should_terminate",
|
||||
"simulate_cat_selection",
|
||||
"update_theta",
|
||||
# WordPress Auth
|
||||
"WordPressAPIError",
|
||||
"WordPressAuthError",
|
||||
"WordPressRateLimitError",
|
||||
"WordPressTokenInvalidError",
|
||||
"WordPressUserInfo",
|
||||
"WebsiteNotFoundError",
|
||||
"SyncStats",
|
||||
"fetch_wordpress_users",
|
||||
"get_or_create_user",
|
||||
"get_wordpress_user",
|
||||
"sync_wordpress_users",
|
||||
"verify_website_exists",
|
||||
"verify_wordpress_token",
|
||||
# AI Generation
|
||||
"call_openrouter_api",
|
||||
"check_cache_reuse",
|
||||
"generate_question",
|
||||
"generate_with_cache_check",
|
||||
"get_ai_stats",
|
||||
"get_prompt_template",
|
||||
"parse_ai_response",
|
||||
"save_ai_question",
|
||||
"validate_ai_model",
|
||||
"SUPPORTED_MODELS",
|
||||
# Reporting
|
||||
"generate_student_performance_report",
|
||||
"generate_item_analysis_report",
|
||||
"generate_calibration_status_report",
|
||||
"generate_tryout_comparison_report",
|
||||
"export_report_to_csv",
|
||||
"export_report_to_excel",
|
||||
"export_report_to_pdf",
|
||||
"schedule_report",
|
||||
"get_scheduled_report",
|
||||
"list_scheduled_reports",
|
||||
"cancel_scheduled_report",
|
||||
"StudentPerformanceReport",
|
||||
"ItemAnalysisReport",
|
||||
"CalibrationStatusReport",
|
||||
"TryoutComparisonReport",
|
||||
"ReportSchedule",
|
||||
]
|
||||
950
backend/app/services/ai_generation.py
Normal file
950
backend/app/services/ai_generation.py
Normal file
@@ -0,0 +1,950 @@
|
||||
"""
|
||||
AI Question Generation Service.
|
||||
|
||||
Handles OpenRouter API integration for generating question variants.
|
||||
Implements caching, user-level reuse checking, and prompt engineering.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import ast
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Literal, Optional, Union
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.item import Item
|
||||
from app.models.ai_generation_run import AIGenerationRun
|
||||
from app.models.tryout import Tryout
|
||||
from app.models.user_answer import UserAnswer
|
||||
from app.schemas.ai import AIModelPricing, AIUsageInfo, GeneratedQuestion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
# OpenRouter API configuration
|
||||
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||
OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"
|
||||
|
||||
# Supported AI models
|
||||
SUPPORTED_MODELS = {
|
||||
settings.OPENROUTER_MODEL_CHEAP: "Mistral Small 4 (Cheap / Fast)",
|
||||
settings.OPENROUTER_MODEL_QWEN: "Qwen 2.5 32B Instruct (Balanced)",
|
||||
settings.OPENROUTER_MODEL_LLAMA: "Llama 3.3 70B (Premium)",
|
||||
}
|
||||
|
||||
# Level mapping for prompts
|
||||
LEVEL_DESCRIPTIONS = {
|
||||
"mudah": "easier (simpler concepts, more straightforward calculations)",
|
||||
"sedang": "medium difficulty",
|
||||
"sulit": "harder (more complex concepts, multi-step reasoning)",
|
||||
}
|
||||
|
||||
OPTION_LABELS = tuple("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
MODEL_PRICING_CACHE_TTL_SECONDS = 60 * 30
|
||||
_model_pricing_cache: dict[str, tuple[float, AIModelPricing | None]] = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenRouterCallResult:
|
||||
content: str
|
||||
usage: AIUsageInfo | None = None
|
||||
|
||||
|
||||
def get_option_labels(options: Dict[str, str] | None) -> list[str]:
|
||||
labels = {
|
||||
str(key).strip().upper()
|
||||
for key, value in (options or {}).items()
|
||||
if str(key).strip() and str(value).strip()
|
||||
}
|
||||
return [label for label in OPTION_LABELS if label in labels]
|
||||
|
||||
|
||||
def _parse_openrouter_price(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
price = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return price if price >= 0 else None
|
||||
|
||||
|
||||
def _build_pricing(raw_pricing: dict[str, Any] | None) -> AIModelPricing | None:
|
||||
if not raw_pricing:
|
||||
return None
|
||||
prompt = _parse_openrouter_price(raw_pricing.get("prompt"))
|
||||
completion = _parse_openrouter_price(raw_pricing.get("completion"))
|
||||
if prompt is None and completion is None:
|
||||
return None
|
||||
return AIModelPricing(
|
||||
prompt=prompt,
|
||||
completion=completion,
|
||||
prompt_per_million=prompt * 1_000_000 if prompt is not None else None,
|
||||
completion_per_million=completion * 1_000_000 if completion is not None else None,
|
||||
)
|
||||
|
||||
|
||||
async def get_model_pricing(model_id: str) -> AIModelPricing | None:
|
||||
cached = _model_pricing_cache.get(model_id)
|
||||
now = time.monotonic()
|
||||
if cached and now - cached[0] < MODEL_PRICING_CACHE_TTL_SECONDS:
|
||||
return cached[1]
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if settings.OPENROUTER_API_KEY:
|
||||
headers["Authorization"] = f"Bearer {settings.OPENROUTER_API_KEY}"
|
||||
|
||||
try:
|
||||
timeout = httpx.Timeout(min(settings.OPENROUTER_TIMEOUT, 5))
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.get(OPENROUTER_MODELS_URL, headers=headers)
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"OpenRouter models pricing request failed: %s - %s",
|
||||
response.status_code,
|
||||
response.text[:240],
|
||||
)
|
||||
_model_pricing_cache[model_id] = (now, None)
|
||||
return None
|
||||
|
||||
for model in response.json().get("data", []):
|
||||
if model.get("id") == model_id:
|
||||
pricing = _build_pricing(model.get("pricing"))
|
||||
_model_pricing_cache[model_id] = (now, pricing)
|
||||
return pricing
|
||||
except Exception as exc:
|
||||
logger.warning("OpenRouter model pricing lookup failed for %s: %s", model_id, exc)
|
||||
|
||||
_model_pricing_cache[model_id] = (now, None)
|
||||
return None
|
||||
|
||||
|
||||
def _calculate_usage_cost(
|
||||
prompt_tokens: int | None,
|
||||
completion_tokens: int | None,
|
||||
pricing: AIModelPricing | None,
|
||||
provider_cost: Any = None,
|
||||
) -> float | None:
|
||||
provider_cost_value = _parse_openrouter_price(provider_cost)
|
||||
if provider_cost_value is not None:
|
||||
return provider_cost_value
|
||||
if pricing is None:
|
||||
return None
|
||||
cost = 0.0
|
||||
has_cost_component = False
|
||||
if prompt_tokens is not None and pricing.prompt is not None:
|
||||
cost += prompt_tokens * pricing.prompt
|
||||
has_cost_component = True
|
||||
if completion_tokens is not None and pricing.completion is not None:
|
||||
cost += completion_tokens * pricing.completion
|
||||
has_cost_component = True
|
||||
return cost if has_cost_component else None
|
||||
|
||||
|
||||
async def build_usage_info(raw_usage: dict[str, Any] | None, model_id: str) -> AIUsageInfo | None:
|
||||
if not raw_usage:
|
||||
return None
|
||||
|
||||
def token_count(key: str) -> int | None:
|
||||
value = raw_usage.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
prompt_tokens = token_count("prompt_tokens")
|
||||
completion_tokens = token_count("completion_tokens")
|
||||
total_tokens = token_count("total_tokens")
|
||||
if total_tokens is None and (prompt_tokens is not None or completion_tokens is not None):
|
||||
total_tokens = (prompt_tokens or 0) + (completion_tokens or 0)
|
||||
|
||||
pricing = await get_model_pricing(model_id)
|
||||
cost_usd = _calculate_usage_cost(
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
pricing,
|
||||
provider_cost=raw_usage.get("cost"),
|
||||
)
|
||||
return AIUsageInfo(
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total_tokens,
|
||||
cost_usd=cost_usd,
|
||||
)
|
||||
|
||||
|
||||
def combine_usage(usages: list[AIUsageInfo | None]) -> AIUsageInfo | None:
|
||||
filtered = [usage for usage in usages if usage is not None]
|
||||
if not filtered:
|
||||
return None
|
||||
|
||||
def summed(field: str) -> int | float | None:
|
||||
values = [getattr(usage, field) for usage in filtered]
|
||||
present = [value for value in values if value is not None]
|
||||
return sum(present) if present else None
|
||||
|
||||
return AIUsageInfo(
|
||||
prompt_tokens=summed("prompt_tokens"),
|
||||
completion_tokens=summed("completion_tokens"),
|
||||
total_tokens=summed("total_tokens"),
|
||||
cost_usd=summed("cost_usd"),
|
||||
)
|
||||
|
||||
|
||||
def get_prompt_template(
|
||||
basis_stem: str,
|
||||
basis_options: Dict[str, str],
|
||||
basis_correct: str,
|
||||
basis_explanation: Optional[str],
|
||||
target_level: Literal["mudah", "sulit"],
|
||||
operator_notes: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate standardized prompt for AI question generation.
|
||||
|
||||
Args:
|
||||
basis_stem: The basis question stem
|
||||
basis_options: The basis question options
|
||||
basis_correct: The basis correct answer
|
||||
basis_explanation: The basis explanation
|
||||
target_level: Target difficulty level
|
||||
|
||||
Returns:
|
||||
Formatted prompt string
|
||||
"""
|
||||
level_desc = LEVEL_DESCRIPTIONS.get(target_level, target_level)
|
||||
option_labels = get_option_labels(basis_options) or ["A", "B", "C", "D"]
|
||||
option_count = len(option_labels)
|
||||
option_label_text = ", ".join(option_labels)
|
||||
example_options = {label: f"Option {label} text" for label in option_labels}
|
||||
|
||||
options_text = "\n".join(
|
||||
[f" {key}: {value}" for key, value in basis_options.items()]
|
||||
)
|
||||
|
||||
explanation_text = (
|
||||
f"Explanation: {basis_explanation}"
|
||||
if basis_explanation
|
||||
else "Explanation: (not provided)"
|
||||
)
|
||||
|
||||
notes_block = ""
|
||||
if operator_notes and operator_notes.strip():
|
||||
notes_block = f"""
|
||||
ADDITIONAL OPERATOR NOTES:
|
||||
{operator_notes.strip()}
|
||||
|
||||
Apply these notes as style constraints as long as they do not conflict with correctness.
|
||||
"""
|
||||
|
||||
prompt = f"""You are an educational content creator specializing in creating assessment questions.
|
||||
|
||||
Given a "Sedang" (medium difficulty) question, generate a new question at a different difficulty level.
|
||||
|
||||
BASIS QUESTION (Sedang level):
|
||||
Question: {basis_stem}
|
||||
Options:
|
||||
{options_text}
|
||||
Correct Answer: {basis_correct}
|
||||
{explanation_text}
|
||||
|
||||
TASK:
|
||||
Generate 1 new question that is {level_desc} than the basis question above.
|
||||
{notes_block}
|
||||
|
||||
REQUIREMENTS:
|
||||
1. Keep the SAME topic/subject matter as the basis question
|
||||
2. Use similar context and terminology
|
||||
3. Create exactly {option_count} answer options with labels exactly: {option_label_text}
|
||||
4. Preserve the basis option count and option labels. Do not omit, add, rename, or merge answer options.
|
||||
5. Only ONE correct answer, and it must be one of: {option_label_text}
|
||||
6. Include a clear explanation of why the correct answer is correct
|
||||
7. Make the question noticeably {level_desc} - not just a minor variation
|
||||
8. Follow and preserve the basis question's inline HTML style. Keep structural and inline tags such as <p>, <br>, <strong>, <b>, <em>, <i>, <u>, <sub>, <sup>, and simple inline attributes such as text alignment when the basis uses them.
|
||||
9. Do not escape HTML tags as text. Return HTML markup in the JSON string values exactly as markup.
|
||||
|
||||
OUTPUT FORMAT:
|
||||
Return ONLY a valid JSON object with this exact structure (no markdown, no code blocks):
|
||||
{{"stem": "Your question text here", "options": {json.dumps(example_options, ensure_ascii=False)}, "correct": "{option_labels[0]}", "explanation": "Explanation text here"}}
|
||||
|
||||
Remember: The correct field must be exactly one of: {option_label_text}."""
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
def parse_ai_response(response_text: str) -> Optional[GeneratedQuestion]:
|
||||
"""
|
||||
Parse AI response to extract question data.
|
||||
|
||||
Handles various response formats including JSON code blocks.
|
||||
|
||||
Args:
|
||||
response_text: Raw AI response text
|
||||
|
||||
Returns:
|
||||
GeneratedQuestion if parsing successful, None otherwise
|
||||
"""
|
||||
if not response_text:
|
||||
return None
|
||||
|
||||
cleaned = response_text.strip()
|
||||
candidates = _extract_json_candidates(cleaned)
|
||||
for candidate in candidates:
|
||||
candidate_clean = _sanitize_json_candidate(candidate)
|
||||
parsed = _try_parse_json_like(candidate_clean)
|
||||
if isinstance(parsed, dict):
|
||||
question = validate_and_create_question(parsed)
|
||||
if question:
|
||||
return question
|
||||
|
||||
logger.warning(f"Failed to parse AI response: {cleaned[:240]}...")
|
||||
return None
|
||||
|
||||
|
||||
def validate_and_create_question(data: Dict[str, Any]) -> Optional[GeneratedQuestion]:
|
||||
"""
|
||||
Validate parsed data and create GeneratedQuestion.
|
||||
|
||||
Args:
|
||||
data: Parsed JSON data
|
||||
|
||||
Returns:
|
||||
GeneratedQuestion if valid, None otherwise
|
||||
"""
|
||||
stem = str(data.get("stem") or data.get("question") or "").strip()
|
||||
if not stem:
|
||||
logger.warning(f"Missing question stem in AI response: {data.keys()}")
|
||||
return None
|
||||
|
||||
options = _normalize_options(data.get("options"))
|
||||
if not options:
|
||||
logger.warning("Options cannot be normalized to a labeled option map")
|
||||
return None
|
||||
|
||||
correct = _normalize_correct_answer(
|
||||
data.get("correct") or data.get("correct_answer") or data.get("answer")
|
||||
)
|
||||
if correct not in set(options.keys()):
|
||||
logger.warning(f"Invalid correct answer: {correct}")
|
||||
return None
|
||||
|
||||
return GeneratedQuestion(
|
||||
stem=stem,
|
||||
options=options,
|
||||
correct=correct,
|
||||
explanation=str(data.get("explanation") or data.get("rationale") or "").strip() or None,
|
||||
)
|
||||
|
||||
|
||||
def _extract_json_candidates(text: str) -> list[str]:
|
||||
candidates: list[str] = []
|
||||
|
||||
code_blocks = re.findall(r"```(?:json)?\s*([\s\S]*?)\s*```", text)
|
||||
candidates.extend(block.strip() for block in code_blocks if block.strip())
|
||||
|
||||
balanced = _extract_first_balanced_object(text)
|
||||
if balanced:
|
||||
candidates.append(balanced)
|
||||
|
||||
candidates.append(text.strip())
|
||||
deduped: list[str] = []
|
||||
seen = set()
|
||||
for candidate in candidates:
|
||||
if candidate and candidate not in seen:
|
||||
deduped.append(candidate)
|
||||
seen.add(candidate)
|
||||
return deduped
|
||||
|
||||
|
||||
def _extract_first_balanced_object(text: str) -> str | None:
|
||||
start = text.find("{")
|
||||
if start == -1:
|
||||
return None
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
for idx in range(start, len(text)):
|
||||
ch = text[idx]
|
||||
if escape_next:
|
||||
escape_next = False
|
||||
continue
|
||||
if ch == "\\" and in_string:
|
||||
escape_next = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return text[start: idx + 1]
|
||||
return None
|
||||
|
||||
|
||||
def _sanitize_json_candidate(candidate: str) -> str:
|
||||
cleaned = candidate.strip().lstrip("\ufeff")
|
||||
cleaned = cleaned.replace("“", '"').replace("”", '"').replace("’", "'")
|
||||
cleaned = re.sub(r",\s*([}\]])", r"\1", cleaned)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _try_parse_json_like(candidate: str) -> Any:
|
||||
try:
|
||||
return json.loads(candidate)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
try:
|
||||
# Fallback for Python-like dict outputs using single quotes.
|
||||
return ast.literal_eval(candidate)
|
||||
except (ValueError, SyntaxError):
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_options(raw_options: Any) -> dict[str, str]:
|
||||
if isinstance(raw_options, dict):
|
||||
normalized = {str(k).strip().upper(): str(v).strip() for k, v in raw_options.items()}
|
||||
return {k: normalized[k] for k in OPTION_LABELS if normalized.get(k, "")}
|
||||
|
||||
if isinstance(raw_options, list):
|
||||
mapped: dict[str, str] = {}
|
||||
for idx, opt in enumerate(raw_options):
|
||||
if isinstance(opt, dict):
|
||||
key = str(opt.get("increment") or opt.get("key") or "").strip().upper()
|
||||
text = str(opt.get("text") or opt.get("label") or opt.get("value") or "").strip()
|
||||
else:
|
||||
key = ""
|
||||
text = str(opt).strip()
|
||||
if not key and idx < len(OPTION_LABELS):
|
||||
key = OPTION_LABELS[idx]
|
||||
if key in OPTION_LABELS and text:
|
||||
mapped[key] = text
|
||||
return mapped
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def _normalize_correct_answer(raw_correct: Any) -> str:
|
||||
if raw_correct is None:
|
||||
return ""
|
||||
raw_text = str(raw_correct).strip().upper()
|
||||
if raw_text in OPTION_LABELS:
|
||||
return raw_text
|
||||
if raw_text.isdigit():
|
||||
idx = int(raw_text)
|
||||
if 1 <= idx <= len(OPTION_LABELS):
|
||||
return OPTION_LABELS[idx - 1]
|
||||
if 0 <= idx < len(OPTION_LABELS):
|
||||
return OPTION_LABELS[idx]
|
||||
if raw_text.startswith("OPTION ") and raw_text[-1:] in OPTION_LABELS:
|
||||
return raw_text[-1]
|
||||
return raw_text[:1]
|
||||
|
||||
|
||||
def generated_matches_basis_options(generated: GeneratedQuestion, basis_item: Item) -> bool:
|
||||
basis_labels = get_option_labels(basis_item.options)
|
||||
generated_labels = get_option_labels(generated.options)
|
||||
if basis_labels != generated_labels:
|
||||
logger.warning(
|
||||
"Generated option labels do not match basis: basis=%s generated=%s",
|
||||
basis_labels,
|
||||
generated_labels,
|
||||
)
|
||||
return False
|
||||
if generated.correct not in set(basis_labels):
|
||||
logger.warning(
|
||||
"Generated correct answer %s is outside basis labels %s",
|
||||
generated.correct,
|
||||
basis_labels,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def call_openrouter_api(
|
||||
prompt: str,
|
||||
model: str,
|
||||
max_retries: int = 3,
|
||||
) -> Optional[OpenRouterCallResult]:
|
||||
"""
|
||||
Call OpenRouter API to generate question.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to send
|
||||
model: AI model to use
|
||||
max_retries: Maximum retry attempts
|
||||
|
||||
Returns:
|
||||
OpenRouterCallResult with response text and usage, or None if failed
|
||||
"""
|
||||
if not settings.OPENROUTER_API_KEY:
|
||||
logger.error("OPENROUTER_API_KEY not configured")
|
||||
return None
|
||||
|
||||
if model not in SUPPORTED_MODELS:
|
||||
logger.error(f"Unsupported AI model: {model}")
|
||||
return None
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://github.com/irt-bank-soal",
|
||||
"X-Title": "IRT Bank Soal",
|
||||
}
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
}
|
||||
],
|
||||
"max_tokens": 2000,
|
||||
"temperature": 0.7,
|
||||
}
|
||||
provider_order = [
|
||||
provider for provider in settings.OPENROUTER_PROVIDER_ORDER if provider.strip()
|
||||
]
|
||||
if provider_order:
|
||||
payload["provider"] = {
|
||||
"order": provider_order,
|
||||
"allow_fallbacks": settings.OPENROUTER_ALLOW_PROVIDER_FALLBACKS,
|
||||
}
|
||||
|
||||
timeout = httpx.Timeout(settings.OPENROUTER_TIMEOUT)
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(
|
||||
OPENROUTER_API_URL,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
choices = data.get("choices", [])
|
||||
if choices:
|
||||
message = choices[0].get("message", {})
|
||||
content = message.get("content")
|
||||
if not content:
|
||||
logger.warning("OpenRouter response had no message content")
|
||||
return None
|
||||
usage = await build_usage_info(data.get("usage"), model)
|
||||
return OpenRouterCallResult(content=content, usage=usage)
|
||||
logger.warning("No choices in OpenRouter response")
|
||||
return None
|
||||
|
||||
elif response.status_code == 429:
|
||||
# Rate limited - wait and retry
|
||||
logger.warning(f"Rate limited, attempt {attempt + 1}/{max_retries}")
|
||||
if attempt < max_retries - 1:
|
||||
import asyncio
|
||||
await asyncio.sleep(2 ** attempt)
|
||||
continue
|
||||
return None
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
f"OpenRouter API error: {response.status_code} - {response.text}"
|
||||
)
|
||||
return None
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(f"OpenRouter timeout, attempt {attempt + 1}/{max_retries}")
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"OpenRouter API call failed: {e}")
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def generate_question(
|
||||
basis_item: Item,
|
||||
target_level: Literal["mudah", "sulit"],
|
||||
ai_model: str = settings.OPENROUTER_MODEL_QWEN,
|
||||
operator_notes: Optional[str] = None,
|
||||
) -> Optional[GeneratedQuestion]:
|
||||
"""
|
||||
Generate a new question based on a basis item.
|
||||
|
||||
Args:
|
||||
basis_item: The basis item (must be sedang level)
|
||||
target_level: Target difficulty level
|
||||
ai_model: AI model to use
|
||||
|
||||
Returns:
|
||||
GeneratedQuestion if successful, None otherwise
|
||||
"""
|
||||
# Build prompt
|
||||
prompt = get_prompt_template(
|
||||
basis_stem=basis_item.stem,
|
||||
basis_options=basis_item.options,
|
||||
basis_correct=basis_item.correct_answer,
|
||||
basis_explanation=basis_item.explanation,
|
||||
target_level=target_level,
|
||||
operator_notes=operator_notes,
|
||||
)
|
||||
|
||||
max_generation_attempts = 3
|
||||
for attempt in range(1, max_generation_attempts + 1):
|
||||
api_result = await call_openrouter_api(prompt, ai_model)
|
||||
if not api_result:
|
||||
logger.error("No response from OpenRouter API")
|
||||
continue
|
||||
|
||||
generated = parse_ai_response(api_result.content)
|
||||
if generated and generated_matches_basis_options(generated, basis_item):
|
||||
generated = generated.model_copy(update={"usage": api_result.usage})
|
||||
return generated
|
||||
|
||||
logger.warning(
|
||||
"Failed to parse or validate AI response (attempt %s/%s), retrying",
|
||||
attempt,
|
||||
max_generation_attempts,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def check_cache_reuse(
|
||||
tryout_id: str,
|
||||
slot: int,
|
||||
level: str,
|
||||
wp_user_id: str,
|
||||
website_id: int,
|
||||
db: AsyncSession,
|
||||
) -> Optional[Item]:
|
||||
"""
|
||||
Check if there's a cached item that the user hasn't answered yet.
|
||||
|
||||
Query DB for existing item matching (tryout_id, slot, level).
|
||||
Check if user already answered this item at this difficulty level.
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
slot: Question slot
|
||||
level: Difficulty level
|
||||
wp_user_id: WordPress user ID
|
||||
website_id: Website identifier
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Cached item if found and user hasn't answered, None otherwise
|
||||
"""
|
||||
# Find existing items at this slot/level
|
||||
result = await db.execute(
|
||||
select(Item).where(
|
||||
and_(
|
||||
Item.tryout_id == tryout_id,
|
||||
Item.website_id == website_id,
|
||||
Item.slot == slot,
|
||||
Item.level == level,
|
||||
)
|
||||
)
|
||||
)
|
||||
existing_items = result.scalars().all()
|
||||
|
||||
if not existing_items:
|
||||
return None
|
||||
|
||||
# Check each item to find one the user hasn't answered
|
||||
for item in existing_items:
|
||||
# Check if user has answered this item
|
||||
answer_result = await db.execute(
|
||||
select(UserAnswer).where(
|
||||
and_(
|
||||
UserAnswer.item_id == item.id,
|
||||
UserAnswer.wp_user_id == wp_user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
user_answer = answer_result.scalar_one_or_none()
|
||||
|
||||
if user_answer is None:
|
||||
# User hasn't answered this item - can reuse
|
||||
logger.info(
|
||||
f"Cache hit for tryout={tryout_id}, slot={slot}, level={level}, "
|
||||
f"item_id={item.id}, user={wp_user_id}"
|
||||
)
|
||||
return item
|
||||
|
||||
# All items have been answered by this user
|
||||
logger.info(
|
||||
f"Cache miss (user answered all) for tryout={tryout_id}, slot={slot}, "
|
||||
f"level={level}, user={wp_user_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def generate_with_cache_check(
|
||||
tryout_id: str,
|
||||
slot: int,
|
||||
level: Literal["mudah", "sulit"],
|
||||
wp_user_id: str,
|
||||
website_id: int,
|
||||
db: AsyncSession,
|
||||
ai_model: str = settings.OPENROUTER_MODEL_QWEN,
|
||||
) -> tuple[Optional[Union[Item, GeneratedQuestion]], bool]:
|
||||
"""
|
||||
Generate question with cache checking.
|
||||
|
||||
First checks if AI generation is enabled for the tryout.
|
||||
Then checks for cached items the user hasn't answered.
|
||||
If cache miss, generates new question via AI.
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
slot: Question slot
|
||||
level: Target difficulty level
|
||||
wp_user_id: WordPress user ID
|
||||
website_id: Website identifier
|
||||
db: Database session
|
||||
ai_model: AI model to use
|
||||
|
||||
Returns:
|
||||
Tuple of (item/question or None, is_cached)
|
||||
"""
|
||||
# Check if AI generation is enabled for this tryout
|
||||
tryout_result = await db.execute(
|
||||
select(Tryout).where(
|
||||
and_(
|
||||
Tryout.tryout_id == tryout_id,
|
||||
Tryout.website_id == website_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
tryout = tryout_result.scalar_one_or_none()
|
||||
|
||||
if tryout and not tryout.ai_generation_enabled:
|
||||
logger.info(f"AI generation disabled for tryout={tryout_id}")
|
||||
# Still check cache even if AI disabled
|
||||
cached_item = await check_cache_reuse(
|
||||
tryout_id, slot, level, wp_user_id, website_id, db
|
||||
)
|
||||
if cached_item:
|
||||
return cached_item, True
|
||||
return None, False
|
||||
|
||||
# Check cache for reusable item
|
||||
cached_item = await check_cache_reuse(
|
||||
tryout_id, slot, level, wp_user_id, website_id, db
|
||||
)
|
||||
|
||||
if cached_item:
|
||||
return cached_item, True
|
||||
|
||||
# Cache miss - need to generate
|
||||
# Get basis item (sedang level at same slot)
|
||||
basis_result = await db.execute(
|
||||
select(Item).where(
|
||||
and_(
|
||||
Item.tryout_id == tryout_id,
|
||||
Item.website_id == website_id,
|
||||
Item.slot == slot,
|
||||
Item.level == "sedang",
|
||||
)
|
||||
).limit(1)
|
||||
)
|
||||
basis_item = basis_result.scalar_one_or_none()
|
||||
|
||||
if not basis_item:
|
||||
logger.error(
|
||||
f"No basis item found for tryout={tryout_id}, slot={slot}"
|
||||
)
|
||||
return None, False
|
||||
|
||||
# Generate new question
|
||||
generated = await generate_question(basis_item, level, ai_model)
|
||||
|
||||
if not generated:
|
||||
logger.error(
|
||||
f"Failed to generate question for tryout={tryout_id}, slot={slot}, level={level}"
|
||||
)
|
||||
return None, False
|
||||
|
||||
return generated, False
|
||||
|
||||
|
||||
async def save_ai_question(
|
||||
generated_data: GeneratedQuestion,
|
||||
tryout_id: str,
|
||||
website_id: int,
|
||||
basis_item_id: int,
|
||||
slot: int,
|
||||
level: Literal["mudah", "sedang", "sulit"],
|
||||
ai_model: str,
|
||||
db: AsyncSession,
|
||||
generation_run_id: int | None = None,
|
||||
source_snapshot_question_id: int | None = None,
|
||||
variant_status: str = "draft",
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Save AI-generated question to database.
|
||||
|
||||
Args:
|
||||
generated_data: Generated question data
|
||||
tryout_id: Tryout identifier
|
||||
website_id: Website identifier
|
||||
basis_item_id: Basis item ID
|
||||
slot: Question slot
|
||||
level: Difficulty level
|
||||
ai_model: AI model used
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Created item ID or None if failed
|
||||
"""
|
||||
try:
|
||||
new_item = Item(
|
||||
tryout_id=tryout_id,
|
||||
website_id=website_id,
|
||||
slot=slot,
|
||||
level=level,
|
||||
stem=generated_data.stem,
|
||||
options=generated_data.options,
|
||||
correct_answer=generated_data.correct,
|
||||
explanation=generated_data.explanation,
|
||||
generated_by="ai",
|
||||
ai_model=ai_model,
|
||||
basis_item_id=basis_item_id,
|
||||
generation_run_id=generation_run_id,
|
||||
source_snapshot_question_id=source_snapshot_question_id,
|
||||
variant_status=variant_status,
|
||||
calibrated=False,
|
||||
ctt_p=None,
|
||||
ctt_bobot=None,
|
||||
ctt_category=None,
|
||||
irt_b=None,
|
||||
irt_se=None,
|
||||
calibration_sample_size=0,
|
||||
)
|
||||
|
||||
db.add(new_item)
|
||||
await db.flush() # Get the ID without committing
|
||||
|
||||
logger.info(
|
||||
f"Saved AI-generated item: id={new_item.id}, tryout={tryout_id}, "
|
||||
f"slot={slot}, level={level}, model={ai_model}"
|
||||
)
|
||||
|
||||
return new_item.id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save AI-generated question: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def create_generation_run(
|
||||
basis_item_id: int,
|
||||
target_level: Literal["mudah", "sulit"],
|
||||
requested_count: int,
|
||||
model: str,
|
||||
created_by: str,
|
||||
db: AsyncSession,
|
||||
source_snapshot_question_id: int | None = None,
|
||||
operator_notes: str | None = None,
|
||||
prompt_version: str = "v1",
|
||||
) -> int:
|
||||
run = AIGenerationRun(
|
||||
basis_item_id=basis_item_id,
|
||||
source_snapshot_question_id=source_snapshot_question_id,
|
||||
target_level=target_level,
|
||||
requested_count=requested_count,
|
||||
model=model,
|
||||
prompt_version=prompt_version,
|
||||
operator_notes=operator_notes,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(run)
|
||||
await db.flush()
|
||||
return int(run.id)
|
||||
|
||||
|
||||
async def generate_questions_batch(
|
||||
basis_item: Item,
|
||||
target_level: Literal["mudah", "sulit"],
|
||||
ai_model: str,
|
||||
count: int,
|
||||
operator_notes: Optional[str] = None,
|
||||
) -> list[GeneratedQuestion]:
|
||||
generated_items: list[GeneratedQuestion] = []
|
||||
for _ in range(count):
|
||||
generated = await generate_question(
|
||||
basis_item=basis_item,
|
||||
target_level=target_level,
|
||||
ai_model=ai_model,
|
||||
operator_notes=operator_notes,
|
||||
)
|
||||
if generated is not None:
|
||||
generated_items.append(generated)
|
||||
return generated_items
|
||||
|
||||
|
||||
async def get_ai_stats(db: AsyncSession, website_id: int | None = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get AI generation statistics.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Statistics dictionary
|
||||
"""
|
||||
filters = [Item.generated_by == "ai"]
|
||||
if website_id is not None:
|
||||
filters.append(Item.website_id == website_id)
|
||||
|
||||
# Total AI-generated items
|
||||
total_result = await db.execute(select(func.count(Item.id)).where(*filters))
|
||||
total_ai_items = total_result.scalar() or 0
|
||||
|
||||
# Items by model
|
||||
model_result = await db.execute(
|
||||
select(Item.ai_model, func.count(Item.id))
|
||||
.where(*filters)
|
||||
.where(Item.ai_model.isnot(None))
|
||||
.group_by(Item.ai_model)
|
||||
)
|
||||
items_by_model = {row[0]: row[1] for row in model_result.all()}
|
||||
|
||||
# Note: Cache hit rate would need to be tracked separately
|
||||
# This is a placeholder for now
|
||||
return {
|
||||
"total_ai_items": total_ai_items,
|
||||
"items_by_model": items_by_model,
|
||||
"cache_hit_rate": 0.0,
|
||||
"total_cache_hits": 0,
|
||||
"total_requests": 0,
|
||||
}
|
||||
|
||||
|
||||
def validate_ai_model(model: str) -> bool:
|
||||
"""
|
||||
Validate that the AI model is supported.
|
||||
|
||||
Args:
|
||||
model: AI model identifier
|
||||
|
||||
Returns:
|
||||
True if model is supported
|
||||
"""
|
||||
return model in SUPPORTED_MODELS
|
||||
748
backend/app/services/cat_selection.py
Normal file
748
backend/app/services/cat_selection.py
Normal file
@@ -0,0 +1,748 @@
|
||||
"""
|
||||
CAT (Computerized Adaptive Testing) Selection Service.
|
||||
|
||||
Implements adaptive item selection algorithms for IRT-based testing.
|
||||
Supports three modes: CTT (fixed), IRT (adaptive), and hybrid.
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from sqlalchemy import and_, not_, or_, select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import Item, Session, Tryout, UserAnswer
|
||||
from app.services.irt_calibration import (
|
||||
calculate_item_information,
|
||||
estimate_b_from_ctt_p,
|
||||
estimate_theta_mle,
|
||||
update_theta_after_response,
|
||||
)
|
||||
|
||||
|
||||
class CATSelectionError(Exception):
|
||||
"""Exception raised for CAT selection errors."""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class NextItemResult:
|
||||
"""Result of next item selection."""
|
||||
item: Optional[Item]
|
||||
selection_method: str # 'fixed', 'adaptive', 'hybrid'
|
||||
slot: Optional[int]
|
||||
level: Optional[str]
|
||||
reason: str # Why this item was selected
|
||||
|
||||
|
||||
@dataclass
|
||||
class TerminationCheck:
|
||||
"""Result of termination condition check."""
|
||||
should_terminate: bool
|
||||
reason: str
|
||||
items_answered: int
|
||||
current_se: Optional[float]
|
||||
max_items: Optional[int]
|
||||
se_threshold_met: bool
|
||||
|
||||
|
||||
# Default SE threshold for termination
|
||||
DEFAULT_SE_THRESHOLD = 0.5
|
||||
# Default max items if not configured
|
||||
DEFAULT_MAX_ITEMS = 50
|
||||
SERVABLE_VARIANT_STATUSES = ("active", "approved")
|
||||
|
||||
|
||||
def _servable_item_filter():
|
||||
return Item.variant_status.in_(SERVABLE_VARIANT_STATUSES)
|
||||
|
||||
|
||||
async def _get_user_answered_slot_levels(
|
||||
db: AsyncSession,
|
||||
wp_user_id: str,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> set[tuple[int, str]]:
|
||||
"""Return slot/level pairs this user has already seen for this tryout."""
|
||||
result = await db.execute(
|
||||
select(Item.slot, Item.level)
|
||||
.join(UserAnswer, UserAnswer.item_id == Item.id)
|
||||
.where(
|
||||
UserAnswer.wp_user_id == wp_user_id,
|
||||
UserAnswer.website_id == website_id,
|
||||
UserAnswer.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
return {(int(slot), str(level)) for slot, level in result.all()}
|
||||
|
||||
|
||||
async def get_next_item_fixed(
|
||||
db: AsyncSession,
|
||||
session_id: str,
|
||||
tryout_id: str,
|
||||
website_id: int,
|
||||
level_filter: Optional[str] = None
|
||||
) -> NextItemResult:
|
||||
"""
|
||||
Get next item in fixed order (CTT mode).
|
||||
|
||||
Returns items in slot order (1, 2, 3, ...).
|
||||
Filters by level if specified.
|
||||
Checks if student already answered this item.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session_id: Session identifier
|
||||
tryout_id: Tryout identifier
|
||||
website_id: Website identifier
|
||||
level_filter: Optional difficulty level filter ('mudah', 'sedang', 'sulit')
|
||||
|
||||
Returns:
|
||||
NextItemResult with selected item or None if no more items
|
||||
"""
|
||||
# Get session to find current position and answered items
|
||||
session_query = select(Session).where(Session.session_id == session_id)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise CATSelectionError(f"Session {session_id} not found")
|
||||
|
||||
# Get all item IDs already answered by this user in this session
|
||||
answered_query = select(UserAnswer.item_id).where(
|
||||
UserAnswer.session_id == session_id
|
||||
)
|
||||
answered_result = await db.execute(answered_query)
|
||||
answered_item_ids = [row[0] for row in answered_result.all()]
|
||||
|
||||
# Build query for available items
|
||||
query = (
|
||||
select(Item)
|
||||
.where(
|
||||
Item.tryout_id == tryout_id,
|
||||
Item.website_id == website_id,
|
||||
_servable_item_filter(),
|
||||
)
|
||||
.order_by(Item.slot, Item.level)
|
||||
)
|
||||
|
||||
# Apply level filter if specified
|
||||
if level_filter:
|
||||
query = query.where(Item.level == level_filter)
|
||||
|
||||
# Exclude already answered items
|
||||
if answered_item_ids:
|
||||
query = query.where(not_(Item.id.in_(answered_item_ids)))
|
||||
|
||||
result = await db.execute(query)
|
||||
items = list(result.scalars().all())
|
||||
user_answered_slot_levels = await _get_user_answered_slot_levels(
|
||||
db, session.wp_user_id, website_id, tryout_id
|
||||
)
|
||||
if user_answered_slot_levels:
|
||||
items = [
|
||||
item
|
||||
for item in items
|
||||
if (item.slot, item.level) not in user_answered_slot_levels
|
||||
]
|
||||
|
||||
if not items:
|
||||
return NextItemResult(
|
||||
item=None,
|
||||
selection_method="fixed",
|
||||
slot=None,
|
||||
level=None,
|
||||
reason="No more items available"
|
||||
)
|
||||
|
||||
# Return first available item (lowest slot)
|
||||
next_item = items[0]
|
||||
|
||||
return NextItemResult(
|
||||
item=next_item,
|
||||
selection_method="fixed",
|
||||
slot=next_item.slot,
|
||||
level=next_item.level,
|
||||
reason=f"Fixed order selection - slot {next_item.slot}"
|
||||
)
|
||||
|
||||
|
||||
async def get_next_item_adaptive(
|
||||
db: AsyncSession,
|
||||
session_id: str,
|
||||
tryout_id: str,
|
||||
website_id: int,
|
||||
ai_generation_enabled: bool = False,
|
||||
level_filter: Optional[str] = None
|
||||
) -> NextItemResult:
|
||||
"""
|
||||
Get next item using adaptive selection (IRT mode).
|
||||
|
||||
Finds item where b ≈ current theta.
|
||||
Only uses calibrated items (calibrated=True).
|
||||
Filters: student hasn't answered this item.
|
||||
Filters: AI-generated items only if AI generation is enabled.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session_id: Session identifier
|
||||
tryout_id: Tryout identifier
|
||||
website_id: Website identifier
|
||||
ai_generation_enabled: Whether to include AI-generated items
|
||||
level_filter: Optional difficulty level filter
|
||||
|
||||
Returns:
|
||||
NextItemResult with selected item or None if no suitable items
|
||||
"""
|
||||
# Get session for current theta
|
||||
session_query = select(Session).where(Session.session_id == session_id)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise CATSelectionError(f"Session {session_id} not found")
|
||||
|
||||
# Get current theta (default to 0.0 for first item)
|
||||
current_theta = session.theta if session.theta is not None else 0.0
|
||||
|
||||
# Get all item IDs already answered by this user in this session
|
||||
answered_query = select(UserAnswer.item_id).where(
|
||||
UserAnswer.session_id == session_id
|
||||
)
|
||||
answered_result = await db.execute(answered_query)
|
||||
answered_item_ids = [row[0] for row in answered_result.all()]
|
||||
|
||||
# Build query for available calibrated items
|
||||
query = (
|
||||
select(Item)
|
||||
.where(
|
||||
Item.tryout_id == tryout_id,
|
||||
Item.website_id == website_id,
|
||||
_servable_item_filter(),
|
||||
Item.calibrated == True # Only calibrated items for IRT
|
||||
)
|
||||
)
|
||||
|
||||
# Apply level filter if specified
|
||||
if level_filter:
|
||||
query = query.where(Item.level == level_filter)
|
||||
|
||||
# Exclude already answered items
|
||||
if answered_item_ids:
|
||||
query = query.where(not_(Item.id.in_(answered_item_ids)))
|
||||
|
||||
# Filter AI-generated items if AI generation is disabled
|
||||
if not ai_generation_enabled:
|
||||
query = query.where(Item.generated_by == 'manual')
|
||||
|
||||
result = await db.execute(query)
|
||||
items = list(result.scalars().all())
|
||||
user_answered_slot_levels = await _get_user_answered_slot_levels(
|
||||
db, session.wp_user_id, website_id, tryout_id
|
||||
)
|
||||
if user_answered_slot_levels:
|
||||
items = [
|
||||
item
|
||||
for item in items
|
||||
if (item.slot, item.level) not in user_answered_slot_levels
|
||||
]
|
||||
|
||||
if not items:
|
||||
return NextItemResult(
|
||||
item=None,
|
||||
selection_method="adaptive",
|
||||
slot=None,
|
||||
level=None,
|
||||
reason="No calibrated items available"
|
||||
)
|
||||
|
||||
# Find item with b closest to current theta
|
||||
# Also consider item information (prefer items with higher information at current theta)
|
||||
best_item = None
|
||||
best_score = float('inf')
|
||||
|
||||
for item in items:
|
||||
if item.irt_b is None:
|
||||
# Skip items without b parameter (shouldn't happen with calibrated=True)
|
||||
continue
|
||||
|
||||
# Calculate distance from theta
|
||||
b_distance = abs(item.irt_b - current_theta)
|
||||
|
||||
# Calculate item information at current theta
|
||||
information = calculate_item_information(current_theta, item.irt_b)
|
||||
|
||||
# Score: minimize distance, maximize information
|
||||
# Use weighted combination: lower score is better
|
||||
# Add small penalty for lower information
|
||||
score = b_distance - (0.1 * information)
|
||||
|
||||
if score < best_score:
|
||||
best_score = score
|
||||
best_item = item
|
||||
|
||||
if not best_item:
|
||||
return NextItemResult(
|
||||
item=None,
|
||||
selection_method="adaptive",
|
||||
slot=None,
|
||||
level=None,
|
||||
reason="No items with valid IRT parameters available"
|
||||
)
|
||||
|
||||
return NextItemResult(
|
||||
item=best_item,
|
||||
selection_method="adaptive",
|
||||
slot=best_item.slot,
|
||||
level=best_item.level,
|
||||
reason=f"Adaptive selection - b={best_item.irt_b:.3f} ≈ θ={current_theta:.3f}"
|
||||
)
|
||||
|
||||
|
||||
async def get_next_item_hybrid(
|
||||
db: AsyncSession,
|
||||
session_id: str,
|
||||
tryout_id: str,
|
||||
website_id: int,
|
||||
hybrid_transition_slot: int = 10,
|
||||
ai_generation_enabled: bool = False,
|
||||
level_filter: Optional[str] = None
|
||||
) -> NextItemResult:
|
||||
"""
|
||||
Get next item using hybrid selection.
|
||||
|
||||
Uses fixed order for first N items, then switches to adaptive.
|
||||
Falls back to CTT if no calibrated items available.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session_id: Session identifier
|
||||
tryout_id: Tryout identifier
|
||||
website_id: Website identifier
|
||||
hybrid_transition_slot: Slot number to transition from fixed to adaptive
|
||||
ai_generation_enabled: Whether to include AI-generated items
|
||||
level_filter: Optional difficulty level filter
|
||||
|
||||
Returns:
|
||||
NextItemResult with selected item or None if no items available
|
||||
"""
|
||||
# Get session to check current position
|
||||
session_query = select(Session).where(Session.session_id == session_id)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise CATSelectionError(f"Session {session_id} not found")
|
||||
|
||||
# Count answered items to determine current position
|
||||
count_query = select(func.count(UserAnswer.id)).where(
|
||||
UserAnswer.session_id == session_id
|
||||
)
|
||||
count_result = await db.execute(count_query)
|
||||
items_answered = count_result.scalar() or 0
|
||||
|
||||
# Determine current slot (next slot to fill)
|
||||
current_slot = items_answered + 1
|
||||
|
||||
# Check if we're still in fixed phase
|
||||
if current_slot <= hybrid_transition_slot:
|
||||
# Use fixed selection for initial items
|
||||
result = await get_next_item_fixed(
|
||||
db, session_id, tryout_id, website_id, level_filter
|
||||
)
|
||||
result.selection_method = "hybrid_fixed"
|
||||
result.reason = f"Hybrid mode (fixed phase) - slot {current_slot}"
|
||||
return result
|
||||
|
||||
# Try adaptive selection
|
||||
adaptive_result = await get_next_item_adaptive(
|
||||
db, session_id, tryout_id, website_id, ai_generation_enabled, level_filter
|
||||
)
|
||||
|
||||
if adaptive_result.item is not None:
|
||||
adaptive_result.selection_method = "hybrid_adaptive"
|
||||
adaptive_result.reason = f"Hybrid mode (adaptive phase) - {adaptive_result.reason}"
|
||||
return adaptive_result
|
||||
|
||||
# Fallback to fixed selection if no calibrated items available
|
||||
fixed_result = await get_next_item_fixed(
|
||||
db, session_id, tryout_id, website_id, level_filter
|
||||
)
|
||||
fixed_result.selection_method = "hybrid_fallback"
|
||||
fixed_result.reason = f"Hybrid mode (CTT fallback) - {fixed_result.reason}"
|
||||
return fixed_result
|
||||
|
||||
|
||||
async def update_theta(
|
||||
db: AsyncSession,
|
||||
session_id: str,
|
||||
item_id: int,
|
||||
is_correct: bool
|
||||
) -> tuple[float, float]:
|
||||
"""
|
||||
Update session theta estimate based on response.
|
||||
|
||||
Calls estimate_theta from irt_calibration.py.
|
||||
Updates session.theta and session.theta_se.
|
||||
Handles initial theta (uses 0.0 for first item).
|
||||
Clamps theta to [-3, +3].
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session_id: Session identifier
|
||||
item_id: Item that was answered
|
||||
is_correct: Whether the answer was correct
|
||||
|
||||
Returns:
|
||||
Tuple of (theta, theta_se)
|
||||
"""
|
||||
return await update_theta_after_response(db, session_id, item_id, is_correct)
|
||||
|
||||
|
||||
async def should_terminate(
|
||||
db: AsyncSession,
|
||||
session_id: str,
|
||||
max_items: Optional[int] = None,
|
||||
se_threshold: float = DEFAULT_SE_THRESHOLD
|
||||
) -> TerminationCheck:
|
||||
"""
|
||||
Check if session should terminate.
|
||||
|
||||
Termination conditions:
|
||||
- Reached max_items
|
||||
- Reached SE threshold (theta_se < se_threshold)
|
||||
- No more items available
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session_id: Session identifier
|
||||
max_items: Maximum items allowed (None = no limit)
|
||||
se_threshold: SE threshold for termination
|
||||
|
||||
Returns:
|
||||
TerminationCheck with termination status and reason
|
||||
"""
|
||||
# Get session
|
||||
session_query = select(Session).where(Session.session_id == session_id)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise CATSelectionError(f"Session {session_id} not found")
|
||||
|
||||
# Count answered items
|
||||
count_query = select(func.count(UserAnswer.id)).where(
|
||||
UserAnswer.session_id == session_id
|
||||
)
|
||||
count_result = await db.execute(count_query)
|
||||
items_answered = count_result.scalar() or 0
|
||||
|
||||
# Check max items
|
||||
max_items_reached = False
|
||||
if max_items is not None and items_answered >= max_items:
|
||||
max_items_reached = True
|
||||
|
||||
# Check SE threshold
|
||||
current_se = session.theta_se
|
||||
se_threshold_met = False
|
||||
if current_se is not None and current_se < se_threshold:
|
||||
se_threshold_met = True
|
||||
|
||||
# Check if we have enough items for SE threshold (at least 15 items per PRD)
|
||||
min_items_for_se = 15
|
||||
se_threshold_met = se_threshold_met and items_answered >= min_items_for_se
|
||||
|
||||
# Determine termination
|
||||
should_term = max_items_reached or se_threshold_met
|
||||
|
||||
# Build reason
|
||||
reasons = []
|
||||
if max_items_reached:
|
||||
reasons.append(f"max items reached ({items_answered}/{max_items})")
|
||||
if se_threshold_met:
|
||||
reasons.append(f"SE threshold met ({current_se:.3f} < {se_threshold})")
|
||||
|
||||
if not reasons:
|
||||
reasons.append("continuing")
|
||||
|
||||
return TerminationCheck(
|
||||
should_terminate=should_term,
|
||||
reason="; ".join(reasons),
|
||||
items_answered=items_answered,
|
||||
current_se=current_se,
|
||||
max_items=max_items,
|
||||
se_threshold_met=se_threshold_met
|
||||
)
|
||||
|
||||
|
||||
async def get_next_item(
|
||||
db: AsyncSession,
|
||||
session_id: str,
|
||||
selection_mode: Literal["fixed", "adaptive", "hybrid"] = "fixed",
|
||||
hybrid_transition_slot: int = 10,
|
||||
ai_generation_enabled: bool = False,
|
||||
level_filter: Optional[str] = None
|
||||
) -> NextItemResult:
|
||||
"""
|
||||
Get next item based on selection mode.
|
||||
|
||||
Main entry point for item selection.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session_id: Session identifier
|
||||
selection_mode: Selection mode ('fixed', 'adaptive', 'hybrid')
|
||||
hybrid_transition_slot: Slot to transition in hybrid mode
|
||||
ai_generation_enabled: Whether AI generation is enabled
|
||||
level_filter: Optional difficulty level filter
|
||||
|
||||
Returns:
|
||||
NextItemResult with selected item
|
||||
"""
|
||||
# Get session for tryout info
|
||||
session_query = select(Session).where(Session.session_id == session_id)
|
||||
session_result = await db.execute(session_query)
|
||||
session = session_result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise CATSelectionError(f"Session {session_id} not found")
|
||||
|
||||
tryout_id = session.tryout_id
|
||||
website_id = session.website_id
|
||||
|
||||
if selection_mode == "fixed":
|
||||
return await get_next_item_fixed(
|
||||
db, session_id, tryout_id, website_id, level_filter
|
||||
)
|
||||
elif selection_mode == "adaptive":
|
||||
return await get_next_item_adaptive(
|
||||
db, session_id, tryout_id, website_id, ai_generation_enabled, level_filter
|
||||
)
|
||||
elif selection_mode == "hybrid":
|
||||
return await get_next_item_hybrid(
|
||||
db, session_id, tryout_id, website_id,
|
||||
hybrid_transition_slot, ai_generation_enabled, level_filter
|
||||
)
|
||||
else:
|
||||
raise CATSelectionError(f"Unknown selection mode: {selection_mode}")
|
||||
|
||||
|
||||
async def check_user_level_reuse(
|
||||
db: AsyncSession,
|
||||
wp_user_id: str,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
slot: int,
|
||||
level: str
|
||||
) -> bool:
|
||||
"""
|
||||
Check if user has already answered a question at this difficulty level.
|
||||
|
||||
Per PRD FR-5.3: Check if student user_id already answered question
|
||||
at specific difficulty level.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
wp_user_id: WordPress user ID
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
slot: Question slot
|
||||
level: Difficulty level
|
||||
|
||||
Returns:
|
||||
True if user has answered at this level, False otherwise
|
||||
"""
|
||||
# Check if user has answered any item at this slot/level combination
|
||||
query = (
|
||||
select(func.count(UserAnswer.id))
|
||||
.join(Item, UserAnswer.item_id == Item.id)
|
||||
.where(
|
||||
UserAnswer.wp_user_id == wp_user_id,
|
||||
UserAnswer.website_id == website_id,
|
||||
UserAnswer.tryout_id == tryout_id,
|
||||
Item.slot == slot,
|
||||
Item.level == level
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
count = result.scalar() or 0
|
||||
|
||||
return count > 0
|
||||
|
||||
|
||||
async def get_available_levels_for_slot(
|
||||
db: AsyncSession,
|
||||
tryout_id: str,
|
||||
website_id: int,
|
||||
slot: int
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get available difficulty levels for a specific slot.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tryout_id: Tryout identifier
|
||||
website_id: Website identifier
|
||||
slot: Question slot
|
||||
|
||||
Returns:
|
||||
List of available levels
|
||||
"""
|
||||
query = (
|
||||
select(Item.level)
|
||||
.where(
|
||||
Item.tryout_id == tryout_id,
|
||||
Item.website_id == website_id,
|
||||
Item.slot == slot,
|
||||
_servable_item_filter(),
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
levels = [row[0] for row in result.all()]
|
||||
|
||||
return levels
|
||||
|
||||
|
||||
# Admin playground functions for testing CAT behavior
|
||||
|
||||
async def simulate_cat_selection(
|
||||
db: AsyncSession,
|
||||
tryout_id: str,
|
||||
website_id: int,
|
||||
initial_theta: float = 0.0,
|
||||
selection_mode: Literal["fixed", "adaptive", "hybrid"] = "adaptive",
|
||||
max_items: int = 15,
|
||||
se_threshold: float = DEFAULT_SE_THRESHOLD,
|
||||
hybrid_transition_slot: int = 10
|
||||
) -> dict:
|
||||
"""
|
||||
Simulate CAT selection for admin testing.
|
||||
|
||||
Returns sequence of selected items with b values and theta progression.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tryout_id: Tryout identifier
|
||||
website_id: Website identifier
|
||||
initial_theta: Starting theta value
|
||||
selection_mode: Selection mode to use
|
||||
max_items: Maximum items to simulate
|
||||
se_threshold: SE threshold for termination
|
||||
hybrid_transition_slot: Slot to transition in hybrid mode
|
||||
|
||||
Returns:
|
||||
Dict with simulation results
|
||||
"""
|
||||
# Get all items for this tryout
|
||||
items_query = (
|
||||
select(Item)
|
||||
.where(
|
||||
Item.tryout_id == tryout_id,
|
||||
Item.website_id == website_id,
|
||||
_servable_item_filter(),
|
||||
)
|
||||
.order_by(Item.slot)
|
||||
)
|
||||
|
||||
items_result = await db.execute(items_query)
|
||||
all_items = list(items_result.scalars().all())
|
||||
|
||||
if not all_items:
|
||||
return {
|
||||
"error": "No items found for this tryout",
|
||||
"tryout_id": tryout_id,
|
||||
"website_id": website_id
|
||||
}
|
||||
|
||||
# Simulate selection
|
||||
selected_items = []
|
||||
current_theta = initial_theta
|
||||
current_se = 3.0 # Start with high uncertainty
|
||||
used_item_ids = set()
|
||||
|
||||
for i in range(max_items):
|
||||
# Get available items
|
||||
available_items = [item for item in all_items if item.id not in used_item_ids]
|
||||
|
||||
if not available_items:
|
||||
break
|
||||
|
||||
# Select based on mode
|
||||
if selection_mode == "adaptive":
|
||||
# Filter to calibrated items only
|
||||
calibrated_items = [item for item in available_items if item.calibrated and item.irt_b is not None]
|
||||
|
||||
if not calibrated_items:
|
||||
# Fallback to any available item
|
||||
calibrated_items = available_items
|
||||
|
||||
# Find item closest to current theta
|
||||
best_item = min(
|
||||
calibrated_items,
|
||||
key=lambda item: abs((item.irt_b or 0) - current_theta)
|
||||
)
|
||||
elif selection_mode == "fixed":
|
||||
# Select in slot order
|
||||
best_item = min(available_items, key=lambda item: item.slot)
|
||||
else: # hybrid
|
||||
if i < hybrid_transition_slot:
|
||||
best_item = min(available_items, key=lambda item: item.slot)
|
||||
else:
|
||||
calibrated_items = [item for item in available_items if item.calibrated and item.irt_b is not None]
|
||||
if calibrated_items:
|
||||
best_item = min(
|
||||
calibrated_items,
|
||||
key=lambda item: abs((item.irt_b or 0) - current_theta)
|
||||
)
|
||||
else:
|
||||
best_item = min(available_items, key=lambda item: item.slot)
|
||||
|
||||
used_item_ids.add(best_item.id)
|
||||
|
||||
# Simulate response (random based on probability)
|
||||
import random
|
||||
b = best_item.irt_b or estimate_b_from_ctt_p(best_item.ctt_p) if best_item.ctt_p else 0.0
|
||||
p_correct = 1.0 / (1.0 + math.exp(-(current_theta - b)))
|
||||
is_correct = random.random() < p_correct
|
||||
|
||||
# Update theta (simplified)
|
||||
responses = [1 if item.get('is_correct', True) else 0 for item in selected_items]
|
||||
responses.append(1 if is_correct else 0)
|
||||
b_params = [item['b'] for item in selected_items]
|
||||
b_params.append(b)
|
||||
|
||||
new_theta, new_se = estimate_theta_mle(responses, b_params, current_theta)
|
||||
current_theta = new_theta
|
||||
current_se = new_se
|
||||
|
||||
selected_items.append({
|
||||
"slot": best_item.slot,
|
||||
"level": best_item.level,
|
||||
"b": b,
|
||||
"is_correct": is_correct,
|
||||
"theta_after": current_theta,
|
||||
"se_after": current_se,
|
||||
"calibrated": best_item.calibrated
|
||||
})
|
||||
|
||||
# Check SE threshold
|
||||
if current_se < se_threshold and i >= 14: # At least 15 items
|
||||
break
|
||||
|
||||
return {
|
||||
"tryout_id": tryout_id,
|
||||
"website_id": website_id,
|
||||
"initial_theta": initial_theta,
|
||||
"selection_mode": selection_mode,
|
||||
"total_items": len(selected_items),
|
||||
"final_theta": current_theta,
|
||||
"final_se": current_se,
|
||||
"se_threshold_met": current_se < se_threshold,
|
||||
"items": selected_items
|
||||
}
|
||||
431
backend/app/services/config_management.py
Normal file
431
backend/app/services/config_management.py
Normal file
@@ -0,0 +1,431 @@
|
||||
"""
|
||||
Configuration Management Service.
|
||||
|
||||
Provides functions to retrieve and update tryout configurations.
|
||||
Handles configuration changes for scoring, selection, and normalization modes.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Literal, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.tryout import Tryout
|
||||
from app.models.tryout_stats import TryoutStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_config(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> Tryout:
|
||||
"""
|
||||
Fetch tryout configuration for a specific tryout.
|
||||
|
||||
Returns all configuration fields including scoring_mode, selection_mode,
|
||||
normalization_mode, and other settings.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
Tryout model with all configuration fields
|
||||
|
||||
Raises:
|
||||
ValueError: If tryout not found
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Tryout).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
tryout = result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise ValueError(
|
||||
f"Tryout {tryout_id} not found for website {website_id}"
|
||||
)
|
||||
|
||||
return tryout
|
||||
|
||||
|
||||
async def update_config(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
config_updates: Dict[str, Any],
|
||||
) -> Tryout:
|
||||
"""
|
||||
Update tryout configuration with provided fields.
|
||||
|
||||
Accepts a dictionary of configuration updates and applies them to the
|
||||
tryout configuration. Only provided fields are updated.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
config_updates: Dictionary of configuration fields to update
|
||||
|
||||
Returns:
|
||||
Updated Tryout model
|
||||
|
||||
Raises:
|
||||
ValueError: If tryout not found or invalid field provided
|
||||
"""
|
||||
# Fetch tryout
|
||||
result = await db.execute(
|
||||
select(Tryout).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
tryout = result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise ValueError(
|
||||
f"Tryout {tryout_id} not found for website {website_id}"
|
||||
)
|
||||
|
||||
# Valid configuration fields
|
||||
valid_fields = {
|
||||
"name", "description",
|
||||
"scoring_mode", "selection_mode", "normalization_mode",
|
||||
"min_sample_for_dynamic", "static_rataan", "static_sb",
|
||||
"ai_generation_enabled",
|
||||
"hybrid_transition_slot",
|
||||
"min_calibration_sample", "theta_estimation_method", "fallback_to_ctt_on_error",
|
||||
}
|
||||
|
||||
# Update only valid fields
|
||||
updated_fields = []
|
||||
for field, value in config_updates.items():
|
||||
if field not in valid_fields:
|
||||
logger.warning(f"Skipping invalid config field: {field}")
|
||||
continue
|
||||
|
||||
setattr(tryout, field, value)
|
||||
updated_fields.append(field)
|
||||
|
||||
if not updated_fields:
|
||||
logger.warning(f"No valid config fields to update for tryout {tryout_id}")
|
||||
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Updated config for tryout {tryout_id}, website {website_id}: "
|
||||
f"{', '.join(updated_fields)}"
|
||||
)
|
||||
|
||||
return tryout
|
||||
|
||||
|
||||
async def toggle_normalization_mode(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
new_mode: Literal["static", "dynamic", "hybrid"],
|
||||
) -> Tryout:
|
||||
"""
|
||||
Toggle normalization mode for a tryout.
|
||||
|
||||
Updates the normalization_mode field. If switching to "auto" (dynamic mode),
|
||||
checks if threshold is met and logs appropriate warnings.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
new_mode: New normalization mode ("static", "dynamic", "hybrid")
|
||||
|
||||
Returns:
|
||||
Updated Tryout model
|
||||
|
||||
Raises:
|
||||
ValueError: If tryout not found or invalid mode provided
|
||||
"""
|
||||
if new_mode not in ["static", "dynamic", "hybrid"]:
|
||||
raise ValueError(
|
||||
f"Invalid normalization_mode: {new_mode}. "
|
||||
"Must be 'static', 'dynamic', or 'hybrid'"
|
||||
)
|
||||
|
||||
# Fetch tryout with stats
|
||||
result = await db.execute(
|
||||
select(Tryout).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
tryout = result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise ValueError(
|
||||
f"Tryout {tryout_id} not found for website {website_id}"
|
||||
)
|
||||
|
||||
old_mode = tryout.normalization_mode
|
||||
tryout.normalization_mode = new_mode
|
||||
|
||||
# Fetch stats for participant count
|
||||
stats_result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = stats_result.scalar_one_or_none()
|
||||
participant_count = stats.participant_count if stats else 0
|
||||
min_sample = tryout.min_sample_for_dynamic
|
||||
|
||||
# Log warnings and suggestions based on mode change
|
||||
if new_mode == "dynamic":
|
||||
if participant_count < min_sample:
|
||||
logger.warning(
|
||||
f"Switching to dynamic normalization with only {participant_count} "
|
||||
f"participants (threshold: {min_sample}). "
|
||||
"Dynamic normalization may produce unreliable results."
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Switching to dynamic normalization with {participant_count} "
|
||||
f"participants (threshold: {min_sample}). "
|
||||
"Ready for dynamic normalization."
|
||||
)
|
||||
|
||||
elif new_mode == "hybrid":
|
||||
if participant_count >= min_sample:
|
||||
logger.info(
|
||||
f"Switching to hybrid normalization with {participant_count} "
|
||||
f"participants (threshold: {min_sample}). "
|
||||
"Will use dynamic normalization immediately."
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Switching to hybrid normalization with {participant_count} "
|
||||
f"participants (threshold: {min_sample}). "
|
||||
f"Will use static normalization until {min_sample} participants reached."
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Toggled normalization mode for tryout {tryout_id}, "
|
||||
f"website {website_id}: {old_mode} -> {new_mode}"
|
||||
)
|
||||
|
||||
return tryout
|
||||
|
||||
|
||||
async def get_normalization_config(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get normalization configuration summary.
|
||||
|
||||
Returns current normalization mode, static values, dynamic values,
|
||||
participant count, and threshold status.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
Dictionary with normalization configuration summary
|
||||
|
||||
Raises:
|
||||
ValueError: If tryout not found
|
||||
"""
|
||||
# Fetch tryout config
|
||||
tryout = await get_config(db, website_id, tryout_id)
|
||||
|
||||
# Fetch stats
|
||||
stats_result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = stats_result.scalar_one_or_none()
|
||||
|
||||
# Determine threshold status
|
||||
participant_count = stats.participant_count if stats else 0
|
||||
min_sample = tryout.min_sample_for_dynamic
|
||||
threshold_ready = participant_count >= min_sample
|
||||
participants_needed = max(0, min_sample - participant_count)
|
||||
|
||||
# Determine current effective mode
|
||||
current_mode = tryout.normalization_mode
|
||||
if current_mode == "hybrid":
|
||||
effective_mode = "dynamic" if threshold_ready else "static"
|
||||
else:
|
||||
effective_mode = current_mode
|
||||
|
||||
return {
|
||||
"tryout_id": tryout_id,
|
||||
"normalization_mode": current_mode,
|
||||
"effective_mode": effective_mode,
|
||||
"static_rataan": tryout.static_rataan,
|
||||
"static_sb": tryout.static_sb,
|
||||
"dynamic_rataan": stats.rataan if stats else None,
|
||||
"dynamic_sb": stats.sb if stats else None,
|
||||
"participant_count": participant_count,
|
||||
"min_sample_for_dynamic": min_sample,
|
||||
"threshold_ready": threshold_ready,
|
||||
"participants_needed": participants_needed,
|
||||
}
|
||||
|
||||
|
||||
async def reset_normalization_stats(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> TryoutStats:
|
||||
"""
|
||||
Reset TryoutStats to initial values.
|
||||
|
||||
Resets participant_count to 0 and clears running sums.
|
||||
Switches normalization_mode to "static" temporarily.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
Reset TryoutStats record
|
||||
|
||||
Raises:
|
||||
ValueError: If tryout not found
|
||||
"""
|
||||
# Fetch tryout
|
||||
tryout_result = await db.execute(
|
||||
select(Tryout).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
tryout = tryout_result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise ValueError(
|
||||
f"Tryout {tryout_id} not found for website {website_id}"
|
||||
)
|
||||
|
||||
# Switch to static mode temporarily
|
||||
tryout.normalization_mode = "static"
|
||||
|
||||
# Fetch or create stats
|
||||
stats_result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = stats_result.scalar_one_or_none()
|
||||
|
||||
if stats is None:
|
||||
# Create new empty stats record
|
||||
stats = TryoutStats(
|
||||
website_id=website_id,
|
||||
tryout_id=tryout_id,
|
||||
participant_count=0,
|
||||
total_nm_sum=0.0,
|
||||
total_nm_sq_sum=0.0,
|
||||
rataan=None,
|
||||
sb=None,
|
||||
min_nm=None,
|
||||
max_nm=None,
|
||||
)
|
||||
db.add(stats)
|
||||
else:
|
||||
# Reset existing stats
|
||||
stats.participant_count = 0
|
||||
stats.total_nm_sum = 0.0
|
||||
stats.total_nm_sq_sum = 0.0
|
||||
stats.rataan = None
|
||||
stats.sb = None
|
||||
stats.min_nm = None
|
||||
stats.max_nm = None
|
||||
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Reset normalization stats for tryout {tryout_id}, "
|
||||
f"website {website_id}. Normalization mode switched to static."
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
async def get_full_config(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get full tryout configuration including stats.
|
||||
|
||||
Returns all configuration fields plus current statistics.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
Dictionary with full configuration and stats
|
||||
|
||||
Raises:
|
||||
ValueError: If tryout not found
|
||||
"""
|
||||
# Fetch tryout config
|
||||
tryout = await get_config(db, website_id, tryout_id)
|
||||
|
||||
# Fetch stats
|
||||
stats_result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = stats_result.scalar_one_or_none()
|
||||
|
||||
# Build config dictionary
|
||||
config = {
|
||||
"tryout_id": tryout.tryout_id,
|
||||
"name": tryout.name,
|
||||
"description": tryout.description,
|
||||
"scoring_mode": tryout.scoring_mode,
|
||||
"selection_mode": tryout.selection_mode,
|
||||
"normalization_mode": tryout.normalization_mode,
|
||||
"min_sample_for_dynamic": tryout.min_sample_for_dynamic,
|
||||
"static_rataan": tryout.static_rataan,
|
||||
"static_sb": tryout.static_sb,
|
||||
"ai_generation_enabled": tryout.ai_generation_enabled,
|
||||
"hybrid_transition_slot": tryout.hybrid_transition_slot,
|
||||
"min_calibration_sample": tryout.min_calibration_sample,
|
||||
"theta_estimation_method": tryout.theta_estimation_method,
|
||||
"fallback_to_ctt_on_error": tryout.fallback_to_ctt_on_error,
|
||||
"stats": {
|
||||
"participant_count": stats.participant_count if stats else 0,
|
||||
"rataan": stats.rataan if stats else None,
|
||||
"sb": stats.sb if stats else None,
|
||||
"min_nm": stats.min_nm if stats else None,
|
||||
"max_nm": stats.max_nm if stats else None,
|
||||
"last_calculated": stats.last_calculated if stats else None,
|
||||
},
|
||||
"created_at": tryout.created_at,
|
||||
"updated_at": tryout.updated_at,
|
||||
}
|
||||
|
||||
return config
|
||||
385
backend/app/services/ctt_scoring.py
Normal file
385
backend/app/services/ctt_scoring.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
CTT (Classical Test Theory) Scoring Engine.
|
||||
|
||||
Implements exact Excel formulas for:
|
||||
- p-value (Tingkat Kesukaran): p = Σ Benar / Total Peserta
|
||||
- Bobot (Weight): Bobot = 1 - p
|
||||
- NM (Nilai Mentah): NM = (Total_Bobot_Siswa / Total_Bobot_Max) × 1000
|
||||
- NN (Nilai Nasional): NN = 500 + 100 × ((NM - Rataan) / SB)
|
||||
|
||||
All formulas match PRD Section 13.1 exactly.
|
||||
"""
|
||||
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Integer, cast, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.item import Item
|
||||
from app.models.tryout_stats import TryoutStats
|
||||
from app.models.user_answer import UserAnswer
|
||||
|
||||
|
||||
def calculate_ctt_p(total_correct: int, total_participants: int) -> float:
|
||||
"""
|
||||
Calculate CTT p-value (Tingkat Kesukaran / Difficulty).
|
||||
|
||||
Formula: p = Σ Benar / Total Peserta
|
||||
|
||||
Args:
|
||||
total_correct: Number of correct answers (Σ Benar)
|
||||
total_participants: Total number of participants (Total Peserta)
|
||||
|
||||
Returns:
|
||||
p-value in range [0.0, 1.0]
|
||||
|
||||
Raises:
|
||||
ValueError: If total_participants is 0 or values are invalid
|
||||
"""
|
||||
if total_participants <= 0:
|
||||
raise ValueError("total_participants must be greater than 0")
|
||||
if total_correct < 0:
|
||||
raise ValueError("total_correct cannot be negative")
|
||||
if total_correct > total_participants:
|
||||
raise ValueError("total_correct cannot exceed total_participants")
|
||||
|
||||
p = total_correct / total_participants
|
||||
|
||||
# Clamp to valid range [0, 1]
|
||||
return max(0.0, min(1.0, p))
|
||||
|
||||
|
||||
def calculate_ctt_bobot(p_value: float) -> float:
|
||||
"""
|
||||
Calculate CTT bobot (weight) from p-value.
|
||||
|
||||
Formula: Bobot = 1 - p
|
||||
|
||||
Interpretation:
|
||||
- Easy questions (p > 0.70) have low bobot (< 0.30)
|
||||
- Difficult questions (p < 0.30) have high bobot (> 0.70)
|
||||
- Medium questions (0.30 ≤ p ≤ 0.70) have moderate bobot
|
||||
|
||||
Args:
|
||||
p_value: CTT p-value in range [0.0, 1.0]
|
||||
|
||||
Returns:
|
||||
bobot (weight) in range [0.0, 1.0]
|
||||
|
||||
Raises:
|
||||
ValueError: If p_value is outside [0, 1] range
|
||||
"""
|
||||
if not 0.0 <= p_value <= 1.0:
|
||||
raise ValueError(f"p_value must be in range [0, 1], got {p_value}")
|
||||
|
||||
bobot = 1.0 - p_value
|
||||
|
||||
# Clamp to valid range [0, 1]
|
||||
return max(0.0, min(1.0, bobot))
|
||||
|
||||
|
||||
def calculate_ctt_nm(total_bobot_siswa: float, total_bobot_max: float) -> int:
|
||||
"""
|
||||
Calculate CTT NM (Nilai Mentah / Raw Score).
|
||||
|
||||
Formula: NM = (Total_Bobot_Siswa / Total_Bobot_Max) × 1000
|
||||
|
||||
This is equivalent to Excel's SUMPRODUCT calculation where:
|
||||
- Total_Bobot_Siswa = Σ(bobot_earned for each correct answer)
|
||||
- Total_Bobot_Max = Σ(bobot for all questions)
|
||||
|
||||
Args:
|
||||
total_bobot_siswa: Total weight earned by student
|
||||
total_bobot_max: Maximum possible weight (sum of all item bobots)
|
||||
|
||||
Returns:
|
||||
NM (raw score) in range [0, 1000]
|
||||
|
||||
Raises:
|
||||
ValueError: If total_bobot_max is 0 or values are invalid
|
||||
"""
|
||||
if total_bobot_max <= 0:
|
||||
raise ValueError("total_bobot_max must be greater than 0")
|
||||
if total_bobot_siswa < 0:
|
||||
raise ValueError("total_bobot_siswa cannot be negative")
|
||||
|
||||
nm = (total_bobot_siswa / total_bobot_max) * 1000
|
||||
|
||||
# Round to integer and clamp to valid range [0, 1000]
|
||||
nm_int = round(nm)
|
||||
return max(0, min(1000, nm_int))
|
||||
|
||||
|
||||
def calculate_ctt_nn(nm: int, rataan: float, sb: float) -> int:
|
||||
"""
|
||||
Calculate CTT NN (Nilai Nasional / Normalized Score).
|
||||
|
||||
Formula: NN = 500 + 100 × ((NM - Rataan) / SB)
|
||||
|
||||
Normalizes scores to mean=500, SD=100 distribution.
|
||||
|
||||
Args:
|
||||
nm: Nilai Mentah (raw score) in range [0, 1000]
|
||||
rataan: Mean of NM scores
|
||||
sb: Standard deviation of NM scores (Simpangan Baku)
|
||||
|
||||
Returns:
|
||||
NN (normalized score) in range [0, 1000]
|
||||
|
||||
Raises:
|
||||
ValueError: If nm is out of range or sb is invalid
|
||||
"""
|
||||
if not 0 <= nm <= 1000:
|
||||
raise ValueError(f"nm must be in range [0, 1000], got {nm}")
|
||||
if sb <= 0:
|
||||
# If SD is 0 or negative, return default normalized score
|
||||
# This handles edge case where all scores are identical
|
||||
return 500
|
||||
|
||||
# Calculate normalized score
|
||||
z_score = (nm - rataan) / sb
|
||||
nn = 500 + 100 * z_score
|
||||
|
||||
# Round to integer and clamp to valid range [0, 1000]
|
||||
nn_int = round(nn)
|
||||
return max(0, min(1000, nn_int))
|
||||
|
||||
|
||||
def categorize_difficulty(p_value: float) -> str:
|
||||
"""
|
||||
Categorize question difficulty based on CTT p-value.
|
||||
|
||||
Categories per CTT standards (PRD Section 13.2):
|
||||
- p < 0.30 → Sukar (Sulit)
|
||||
- 0.30 ≤ p ≤ 0.70 → Sedang
|
||||
- p > 0.70 → Mudah
|
||||
|
||||
Args:
|
||||
p_value: CTT p-value in range [0.0, 1.0]
|
||||
|
||||
Returns:
|
||||
Difficulty category: "mudah", "sedang", or "sulit"
|
||||
"""
|
||||
if p_value > 0.70:
|
||||
return "mudah"
|
||||
elif p_value >= 0.30:
|
||||
return "sedang"
|
||||
else:
|
||||
return "sulit"
|
||||
|
||||
|
||||
async def calculate_ctt_p_for_item(
|
||||
db: AsyncSession, item_id: int
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Calculate CTT p-value for a specific item from existing responses.
|
||||
|
||||
Queries all UserAnswer records for the item to calculate:
|
||||
p = Σ Benar / Total Peserta
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
item_id: Item ID to calculate p-value for
|
||||
|
||||
Returns:
|
||||
p-value in range [0.0, 1.0], or None if no responses exist
|
||||
"""
|
||||
# Count total responses and correct responses
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.count().label("total"),
|
||||
func.sum(cast(UserAnswer.is_correct, Integer)).label("correct"),
|
||||
).where(UserAnswer.item_id == item_id)
|
||||
)
|
||||
row = result.first()
|
||||
|
||||
if row is None or row.total == 0:
|
||||
return None
|
||||
|
||||
return calculate_ctt_p(row.correct or 0, row.total)
|
||||
|
||||
|
||||
async def update_tryout_stats(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
nm: int,
|
||||
) -> TryoutStats:
|
||||
"""
|
||||
Incrementally update TryoutStats with new NM score.
|
||||
|
||||
Updates:
|
||||
- participant_count += 1
|
||||
- total_nm_sum += nm
|
||||
- total_nm_sq_sum += nm²
|
||||
- Recalculates rataan (mean) and sb (standard deviation)
|
||||
- Updates min_nm and max_nm if applicable
|
||||
|
||||
Uses Welford's online algorithm for numerically stable variance calculation.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
nm: New NM score to add
|
||||
|
||||
Returns:
|
||||
Updated TryoutStats record
|
||||
"""
|
||||
# Get or create TryoutStats
|
||||
result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = result.scalar_one_or_none()
|
||||
|
||||
if stats is None:
|
||||
# Create new stats record
|
||||
stats = TryoutStats(
|
||||
website_id=website_id,
|
||||
tryout_id=tryout_id,
|
||||
participant_count=1,
|
||||
total_nm_sum=float(nm),
|
||||
total_nm_sq_sum=float(nm * nm),
|
||||
rataan=float(nm),
|
||||
sb=0.0, # SD is 0 for single data point
|
||||
min_nm=nm,
|
||||
max_nm=nm,
|
||||
last_calculated=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(stats)
|
||||
else:
|
||||
# Incrementally update existing stats
|
||||
stats.participant_count += 1
|
||||
stats.total_nm_sum += nm
|
||||
stats.total_nm_sq_sum += nm * nm
|
||||
|
||||
# Update min/max
|
||||
if stats.min_nm is None or nm < stats.min_nm:
|
||||
stats.min_nm = nm
|
||||
if stats.max_nm is None or nm > stats.max_nm:
|
||||
stats.max_nm = nm
|
||||
|
||||
# Recalculate mean and SD
|
||||
n = stats.participant_count
|
||||
sum_nm = stats.total_nm_sum
|
||||
sum_nm_sq = stats.total_nm_sq_sum
|
||||
|
||||
# Mean = Σ NM / n
|
||||
stats.rataan = sum_nm / n
|
||||
|
||||
# Variance = (Σ NM² / n) - (mean)²
|
||||
# Using population standard deviation
|
||||
if n > 1:
|
||||
variance = (sum_nm_sq / n) - (stats.rataan ** 2)
|
||||
# Clamp variance to non-negative (handles floating point errors)
|
||||
variance = max(0.0, variance)
|
||||
stats.sb = math.sqrt(variance)
|
||||
else:
|
||||
stats.sb = 0.0
|
||||
|
||||
stats.last_calculated = datetime.now(timezone.utc)
|
||||
|
||||
await db.flush()
|
||||
return stats
|
||||
|
||||
|
||||
async def get_total_bobot_max(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
level: str = "sedang",
|
||||
) -> float:
|
||||
"""
|
||||
Calculate total maximum bobot for a tryout.
|
||||
|
||||
Total_Bobot_Max = Σ bobot for all questions in the tryout
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
level: Difficulty level to filter by (default: "sedang")
|
||||
|
||||
Returns:
|
||||
Sum of all item bobots
|
||||
|
||||
Raises:
|
||||
ValueError: If no items found or items have no bobot values
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(func.sum(Item.ctt_bobot)).where(
|
||||
Item.website_id == website_id,
|
||||
Item.tryout_id == tryout_id,
|
||||
Item.level == level,
|
||||
)
|
||||
)
|
||||
total_bobot = result.scalar()
|
||||
|
||||
if total_bobot is None or total_bobot == 0:
|
||||
raise ValueError(
|
||||
f"No items with bobot found for tryout {tryout_id}, level {level}"
|
||||
)
|
||||
|
||||
return float(total_bobot)
|
||||
|
||||
|
||||
def convert_ctt_p_to_irt_b(p_value: float) -> float:
|
||||
"""
|
||||
Convert CTT p-value to IRT difficulty parameter (b).
|
||||
|
||||
Formula: b ≈ -ln((1-p)/p)
|
||||
|
||||
This provides an initial estimate for IRT calibration.
|
||||
Maps p ∈ (0, 1) to b ∈ (-∞, +∞), typically [-3, +3].
|
||||
|
||||
Args:
|
||||
p_value: CTT p-value in range (0.0, 1.0)
|
||||
|
||||
Returns:
|
||||
IRT b-parameter estimate
|
||||
|
||||
Raises:
|
||||
ValueError: If p_value is at boundaries (0 or 1)
|
||||
"""
|
||||
if p_value <= 0.0 or p_value >= 1.0:
|
||||
# Handle edge cases by clamping
|
||||
if p_value <= 0.0:
|
||||
return 3.0 # Very difficult
|
||||
else:
|
||||
return -3.0 # Very easy
|
||||
|
||||
# b ≈ -ln((1-p)/p)
|
||||
odds_ratio = (1 - p_value) / p_value
|
||||
b = -math.log(odds_ratio)
|
||||
|
||||
# Clamp to valid IRT range [-3, +3]
|
||||
return max(-3.0, min(3.0, b))
|
||||
|
||||
|
||||
def map_theta_to_nn(theta: float) -> int:
|
||||
"""
|
||||
Map IRT theta (ability) to NN score for comparison.
|
||||
|
||||
Formula: NN = 500 + (θ / 3) × 500
|
||||
|
||||
Maps θ ∈ [-3, +3] to NN ∈ [0, 1000].
|
||||
|
||||
Args:
|
||||
theta: IRT ability estimate in range [-3.0, +3.0]
|
||||
|
||||
Returns:
|
||||
NN score in range [0, 1000]
|
||||
"""
|
||||
# Clamp theta to valid range
|
||||
theta_clamped = max(-3.0, min(3.0, theta))
|
||||
|
||||
# Map to NN
|
||||
nn = 500 + (theta_clamped / 3) * 500
|
||||
|
||||
# Round and clamp to valid range
|
||||
return max(0, min(1000, round(nn)))
|
||||
521
backend/app/services/excel_import.py
Normal file
521
backend/app/services/excel_import.py
Normal file
@@ -0,0 +1,521 @@
|
||||
"""
|
||||
Excel Import/Export Service for Question Migration.
|
||||
|
||||
Handles import from standardized Excel format with:
|
||||
- Row 2: KUNCI (answer key)
|
||||
- Row 4: TK (tingkat kesukaran p-value)
|
||||
- Row 5: BOBOT (weight 1-p)
|
||||
- Rows 6+: Individual question data
|
||||
|
||||
Ensures 100% data integrity with comprehensive validation.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import openpyxl
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.item import Item
|
||||
from app.services.ctt_scoring import (
|
||||
convert_ctt_p_to_irt_b,
|
||||
categorize_difficulty,
|
||||
)
|
||||
|
||||
|
||||
def validate_excel_structure(file_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate Excel file structure against required format.
|
||||
|
||||
Checks:
|
||||
- File exists and is valid Excel (.xlsx)
|
||||
- Sheet "CONTOH" exists
|
||||
- Required rows exist (Row 2 KUNCI, Row 4 TK, Row 5 BOBOT)
|
||||
- Question data rows have required columns
|
||||
|
||||
Args:
|
||||
file_path: Path to Excel file
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- valid: bool - Whether structure is valid
|
||||
- errors: List[str] - Validation errors if any
|
||||
"""
|
||||
errors: List[str] = []
|
||||
|
||||
# Check file exists
|
||||
if not os.path.exists(file_path):
|
||||
return {"valid": False, "errors": [f"File not found: {file_path}"]}
|
||||
|
||||
# Check file extension
|
||||
if not file_path.lower().endswith('.xlsx'):
|
||||
return {"valid": False, "errors": ["File must be .xlsx format"]}
|
||||
|
||||
try:
|
||||
wb = openpyxl.load_workbook(file_path, data_only=False)
|
||||
except Exception as e:
|
||||
return {"valid": False, "errors": [f"Failed to load Excel file: {str(e)}"]}
|
||||
|
||||
# Check sheet "CONTOH" exists
|
||||
if "CONTOH" not in wb.sheetnames:
|
||||
return {
|
||||
"valid": False,
|
||||
"errors": ['Sheet "CONTOH" not found. Available sheets: ' + ", ".join(wb.sheetnames)]
|
||||
}
|
||||
|
||||
ws = wb["CONTOH"]
|
||||
|
||||
# Check minimum rows exist
|
||||
if ws.max_row < 6:
|
||||
errors.append(f"Excel file must have at least 6 rows (found {ws.max_row})")
|
||||
|
||||
# Check Row 2 exists (KUNCI)
|
||||
if ws.max_row < 2:
|
||||
errors.append("Row 2 (KUNCI - answer key) is required")
|
||||
|
||||
# Check Row 4 exists (TK - p-values)
|
||||
if ws.max_row < 4:
|
||||
errors.append("Row 4 (TK - p-values) is required")
|
||||
|
||||
# Check Row 5 exists (BOBOT - weights)
|
||||
if ws.max_row < 5:
|
||||
errors.append("Row 5 (BOBOT - weights) is required")
|
||||
|
||||
# Check question data rows exist (6+)
|
||||
if ws.max_row < 6:
|
||||
errors.append("Question data rows (6+) are required")
|
||||
|
||||
# Check minimum columns (at least slot, level, soal_text, options, correct_answer)
|
||||
if ws.max_column < 8:
|
||||
errors.append(
|
||||
f"Excel file must have at least 8 columns (found {ws.max_column}). "
|
||||
"Expected: slot, level, soal_text, options_A, options_B, options_C, options_D, correct_answer"
|
||||
)
|
||||
|
||||
# Check KUNCI row has values
|
||||
if ws.max_row >= 2:
|
||||
kunce_row_values = [ws.cell(2, col).value for col in range(4, ws.max_column + 1)]
|
||||
if not any(v for v in kunce_row_values if v and v != "KUNCI"):
|
||||
errors.append("Row 2 (KUNCI) must contain answer key values")
|
||||
|
||||
# Check TK row has numeric values
|
||||
if ws.max_row >= 4:
|
||||
wb_data = openpyxl.load_workbook(file_path, data_only=True)
|
||||
ws_data = wb_data["CONTOH"]
|
||||
tk_row_values = [ws_data.cell(4, col).value for col in range(4, ws.max_column + 1)]
|
||||
if not any(v for v in tk_row_values if isinstance(v, (int, float))):
|
||||
errors.append("Row 4 (TK) must contain numeric p-values")
|
||||
|
||||
# Check BOBOT row has numeric values
|
||||
if ws.max_row >= 5:
|
||||
wb_data = openpyxl.load_workbook(file_path, data_only=True)
|
||||
ws_data = wb_data["CONTOH"]
|
||||
bobot_row_values = [ws_data.cell(5, col).value for col in range(4, ws.max_column + 1)]
|
||||
if not any(v for v in bobot_row_values if isinstance(v, (int, float))):
|
||||
errors.append("Row 5 (BOBOT) must contain numeric weight values")
|
||||
|
||||
return {"valid": len(errors) == 0, "errors": errors}
|
||||
|
||||
|
||||
def parse_excel_import(
|
||||
file_path: str,
|
||||
website_id: int,
|
||||
tryout_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse Excel file and extract items with full validation.
|
||||
|
||||
Excel structure:
|
||||
- Sheet name: "CONTOH"
|
||||
- Row 2: KUNCI (answer key) - extract correct answers per slot
|
||||
- Row 4: TK (tingkat kesukaran p-value) - extract p-values per slot
|
||||
- Row 5: BOBOT (weight 1-p) - extract bobot per slot
|
||||
- Rows 6+: Individual question data
|
||||
|
||||
Args:
|
||||
file_path: Path to Excel file
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- items: List[Dict] - Parsed items ready for database
|
||||
- validation_errors: List[str] - Any validation errors
|
||||
- items_count: int - Number of items parsed
|
||||
"""
|
||||
# First validate structure
|
||||
validation = validate_excel_structure(file_path)
|
||||
if not validation["valid"]:
|
||||
return {
|
||||
"items": [],
|
||||
"validation_errors": validation["errors"],
|
||||
"items_count": 0
|
||||
}
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
errors: List[str] = []
|
||||
|
||||
try:
|
||||
# Load workbook twice: once with formulas, once with data_only
|
||||
wb = openpyxl.load_workbook(file_path, data_only=False)
|
||||
ws = wb["CONTOH"]
|
||||
|
||||
wb_data = openpyxl.load_workbook(file_path, data_only=True)
|
||||
ws_data = wb_data["CONTOH"]
|
||||
|
||||
# Extract answer key from Row 2
|
||||
answer_key: Dict[int, str] = {}
|
||||
for col in range(4, ws.max_column + 1):
|
||||
key_cell = ws.cell(2, col).value
|
||||
if key_cell and key_cell != "KUNCI":
|
||||
slot_num = col - 3 # Column 4 -> slot 1
|
||||
answer_key[slot_num] = str(key_cell).strip().upper()
|
||||
|
||||
# Extract p-values from Row 4
|
||||
p_values: Dict[int, float] = {}
|
||||
for col in range(4, ws.max_column + 1):
|
||||
slot_num = col - 3
|
||||
if slot_num in answer_key:
|
||||
p_cell = ws_data.cell(4, col).value
|
||||
if p_cell and isinstance(p_cell, (int, float)):
|
||||
p_values[slot_num] = float(p_cell)
|
||||
|
||||
# Extract bobot from Row 5
|
||||
bobot_values: Dict[int, float] = {}
|
||||
for col in range(4, ws.max_column + 1):
|
||||
slot_num = col - 3
|
||||
if slot_num in answer_key:
|
||||
bobot_cell = ws_data.cell(5, col).value
|
||||
if bobot_cell and isinstance(bobot_cell, (int, float)):
|
||||
bobot_values[slot_num] = float(bobot_cell)
|
||||
|
||||
# Parse question data rows (6+)
|
||||
for row_idx in range(6, ws.max_row + 1):
|
||||
# Column mapping (based on project-brief):
|
||||
# Column 1 (A): slot (question number)
|
||||
# Column 2 (B): level (mudah/sedang/sulit)
|
||||
# Column 3 (C): soal_text (question stem)
|
||||
# Column 4 (D): options_A
|
||||
# Column 5 (E): options_B
|
||||
# Column 6 (F): options_C
|
||||
# Column 7 (G): options_D
|
||||
# Column 8 (H): correct_answer
|
||||
|
||||
slot_cell = ws.cell(row_idx, 1).value
|
||||
level_cell = ws.cell(row_idx, 2).value
|
||||
soal_text_cell = ws.cell(row_idx, 3).value
|
||||
option_a = ws.cell(row_idx, 4).value
|
||||
option_b = ws.cell(row_idx, 5).value
|
||||
option_c = ws.cell(row_idx, 6).value
|
||||
option_d = ws.cell(row_idx, 7).value
|
||||
correct_cell = ws.cell(row_idx, 8).value
|
||||
|
||||
# Skip empty rows
|
||||
if not slot_cell and not soal_text_cell:
|
||||
continue
|
||||
|
||||
# Validate required fields
|
||||
if not slot_cell:
|
||||
errors.append(f"Row {row_idx}: Missing slot value")
|
||||
continue
|
||||
|
||||
slot_num = int(slot_cell) if isinstance(slot_cell, (int, float)) else None
|
||||
if slot_num is None:
|
||||
try:
|
||||
slot_num = int(str(slot_cell).strip())
|
||||
except (ValueError, AttributeError):
|
||||
errors.append(f"Row {row_idx}: Invalid slot value: {slot_cell}")
|
||||
continue
|
||||
|
||||
# Get or infer level
|
||||
if not level_cell:
|
||||
# Use p-value from Row 4 to determine level
|
||||
p_val = p_values.get(slot_num, 0.5)
|
||||
level_val = categorize_difficulty(p_val)
|
||||
else:
|
||||
level_val = str(level_cell).strip().lower()
|
||||
if level_val not in ["mudah", "sedang", "sulit"]:
|
||||
errors.append(
|
||||
f"Row {row_idx}: Invalid level '{level_cell}'. Must be 'mudah', 'sedang', or 'sulit'"
|
||||
)
|
||||
continue
|
||||
|
||||
# Validate soal_text
|
||||
if not soal_text_cell:
|
||||
errors.append(f"Row {row_idx} (slot {slot_num}): Missing soal_text (question stem)")
|
||||
continue
|
||||
|
||||
# Build options JSON
|
||||
options: Dict[str, str] = {}
|
||||
if option_a:
|
||||
options["A"] = str(option_a).strip()
|
||||
if option_b:
|
||||
options["B"] = str(option_b).strip()
|
||||
if option_c:
|
||||
options["C"] = str(option_c).strip()
|
||||
if option_d:
|
||||
options["D"] = str(option_d).strip()
|
||||
|
||||
if len(options) < 4:
|
||||
errors.append(
|
||||
f"Row {row_idx} (slot {slot_num}): Missing options. Expected 4 options (A, B, C, D)"
|
||||
)
|
||||
continue
|
||||
|
||||
# Get correct answer
|
||||
if not correct_cell:
|
||||
# Fall back to answer key from Row 2
|
||||
correct_ans = answer_key.get(slot_num)
|
||||
if not correct_ans:
|
||||
errors.append(
|
||||
f"Row {row_idx} (slot {slot_num}): Missing correct_answer and no answer key found"
|
||||
)
|
||||
continue
|
||||
else:
|
||||
correct_ans = str(correct_cell).strip().upper()
|
||||
|
||||
if correct_ans not in ["A", "B", "C", "D"]:
|
||||
errors.append(
|
||||
f"Row {row_idx} (slot {slot_num}): Invalid correct_answer '{correct_ans}'. Must be A, B, C, or D"
|
||||
)
|
||||
continue
|
||||
|
||||
# Get CTT parameters
|
||||
p_val = p_values.get(slot_num, 0.5)
|
||||
bobot_val = bobot_values.get(slot_num, 1.0 - p_val)
|
||||
|
||||
# Validate p-value range
|
||||
if p_val < 0 or p_val > 1:
|
||||
errors.append(
|
||||
f"Slot {slot_num}: Invalid p-value {p_val}. Must be in range [0, 1]"
|
||||
)
|
||||
continue
|
||||
|
||||
# Validate bobot range
|
||||
if bobot_val < 0 or bobot_val > 1:
|
||||
errors.append(
|
||||
f"Slot {slot_num}: Invalid bobot {bobot_val}. Must be in range [0, 1]"
|
||||
)
|
||||
continue
|
||||
|
||||
# Calculate CTT category and IRT b parameter
|
||||
ctt_cat = categorize_difficulty(p_val)
|
||||
irt_b = convert_ctt_p_to_irt_b(p_val)
|
||||
|
||||
# Build item dict
|
||||
item = {
|
||||
"tryout_id": tryout_id,
|
||||
"website_id": website_id,
|
||||
"slot": slot_num,
|
||||
"level": level_val,
|
||||
"stem": str(soal_text_cell).strip(),
|
||||
"options": options,
|
||||
"correct_answer": correct_ans,
|
||||
"explanation": None,
|
||||
"ctt_p": p_val,
|
||||
"ctt_bobot": bobot_val,
|
||||
"ctt_category": ctt_cat,
|
||||
"irt_b": irt_b,
|
||||
"irt_se": None,
|
||||
"calibrated": False,
|
||||
"calibration_sample_size": 0,
|
||||
"generated_by": "manual",
|
||||
"ai_model": None,
|
||||
"basis_item_id": None,
|
||||
}
|
||||
|
||||
items.append(item)
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"validation_errors": errors,
|
||||
"items_count": len(items)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"items": [],
|
||||
"validation_errors": [f"Parsing error: {str(e)}"],
|
||||
"items_count": 0
|
||||
}
|
||||
|
||||
|
||||
async def bulk_insert_items(
|
||||
items_list: List[Dict[str, Any]],
|
||||
db: AsyncSession
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Bulk insert items with duplicate detection.
|
||||
|
||||
Skips duplicates based on (tryout_id, website_id, slot).
|
||||
|
||||
Args:
|
||||
items_list: List of item dictionaries to insert
|
||||
db: Async SQLAlchemy database session
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- inserted_count: int - Number of items inserted
|
||||
- duplicate_count: int - Number of duplicates skipped
|
||||
- errors: List[str] - Any errors during insertion
|
||||
"""
|
||||
inserted_count = 0
|
||||
duplicate_count = 0
|
||||
errors: List[str] = []
|
||||
|
||||
try:
|
||||
for item_data in items_list:
|
||||
# Check for duplicate
|
||||
result = await db.execute(
|
||||
select(Item).where(
|
||||
Item.tryout_id == item_data["tryout_id"],
|
||||
Item.website_id == item_data["website_id"],
|
||||
Item.slot == item_data["slot"]
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
duplicate_count += 1
|
||||
continue
|
||||
|
||||
# Create new item
|
||||
item = Item(**item_data)
|
||||
db.add(item)
|
||||
inserted_count += 1
|
||||
|
||||
# Commit all inserts
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"inserted_count": inserted_count,
|
||||
"duplicate_count": duplicate_count,
|
||||
"errors": errors
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
return {
|
||||
"inserted_count": 0,
|
||||
"duplicate_count": duplicate_count,
|
||||
"errors": [f"Insertion failed: {str(e)}"]
|
||||
}
|
||||
|
||||
|
||||
async def export_questions_to_excel(
|
||||
tryout_id: str,
|
||||
website_id: int,
|
||||
db: AsyncSession,
|
||||
output_path: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Export questions to Excel in standardized format.
|
||||
|
||||
Creates Excel workbook with:
|
||||
- Sheet "CONTOH"
|
||||
- Row 2: KUNCI (answer key)
|
||||
- Row 4: TK (p-values)
|
||||
- Row 5: BOBOT (weights)
|
||||
- Rows 6+: Question data
|
||||
|
||||
Args:
|
||||
tryout_id: Tryout identifier
|
||||
website_id: Website identifier
|
||||
db: Async SQLAlchemy database session
|
||||
output_path: Optional output file path. If not provided, generates temp file.
|
||||
|
||||
Returns:
|
||||
Path to exported Excel file
|
||||
"""
|
||||
# Fetch all items for this tryout
|
||||
result = await db.execute(
|
||||
select(Item).filter(
|
||||
Item.tryout_id == tryout_id,
|
||||
Item.website_id == website_id
|
||||
).order_by(Item.slot)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
|
||||
if not items:
|
||||
raise ValueError(f"No items found for tryout_id={tryout_id}, website_id={website_id}")
|
||||
|
||||
# Create workbook
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "CONTOH"
|
||||
|
||||
# Determine max slot for column sizing
|
||||
max_slot = max(item.slot for item in items)
|
||||
|
||||
# Row 1: Header
|
||||
ws.cell(1, 1, "No")
|
||||
ws.cell(1, 2, "Level")
|
||||
ws.cell(1, 3, "Soal")
|
||||
for slot_idx in range(max_slot):
|
||||
col = slot_idx + 4
|
||||
ws.cell(1, col, f"Soal {slot_idx + 1}")
|
||||
|
||||
# Row 2: KUNCI (answer key)
|
||||
ws.cell(2, 1, "")
|
||||
ws.cell(2, 2, "")
|
||||
ws.cell(2, 3, "KUNCI")
|
||||
for item in items:
|
||||
col = item.slot + 3
|
||||
ws.cell(2, col, item.correct_answer)
|
||||
|
||||
# Row 3: Empty
|
||||
ws.cell(3, 1, "")
|
||||
ws.cell(3, 2, "")
|
||||
ws.cell(3, 3, "")
|
||||
|
||||
# Row 4: TK (p-values)
|
||||
ws.cell(4, 1, "")
|
||||
ws.cell(4, 2, "")
|
||||
ws.cell(4, 3, "TK")
|
||||
for item in items:
|
||||
col = item.slot + 3
|
||||
ws.cell(4, col, item.ctt_p or 0.5)
|
||||
|
||||
# Row 5: BOBOT (weights)
|
||||
ws.cell(5, 1, "")
|
||||
ws.cell(5, 2, "")
|
||||
ws.cell(5, 3, "BOBOT")
|
||||
for item in items:
|
||||
col = item.slot + 3
|
||||
ws.cell(5, col, item.ctt_bobot or (1.0 - (item.ctt_p or 0.5)))
|
||||
|
||||
# Rows 6+: Question data
|
||||
row_idx = 6
|
||||
for item in items:
|
||||
# Column 1: Slot number
|
||||
ws.cell(row_idx, 1, item.slot)
|
||||
|
||||
# Column 2: Level
|
||||
ws.cell(row_idx, 2, item.level)
|
||||
|
||||
# Column 3: Soal text (stem)
|
||||
ws.cell(row_idx, 3, item.stem)
|
||||
|
||||
# Columns 4+: Options
|
||||
options = item.options or {}
|
||||
ws.cell(row_idx, 4, options.get("A", ""))
|
||||
ws.cell(row_idx, 5, options.get("B", ""))
|
||||
ws.cell(row_idx, 6, options.get("C", ""))
|
||||
ws.cell(row_idx, 7, options.get("D", ""))
|
||||
|
||||
# Column 8: Correct answer
|
||||
ws.cell(row_idx, 8, item.correct_answer)
|
||||
|
||||
row_idx += 1
|
||||
|
||||
# Generate output path if not provided
|
||||
if output_path is None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
output_path = f"/tmp/tryout_{tryout_id}_export_{timestamp}.xlsx"
|
||||
|
||||
# Save workbook
|
||||
wb.save(output_path)
|
||||
|
||||
return output_path
|
||||
1124
backend/app/services/irt_calibration.py
Normal file
1124
backend/app/services/irt_calibration.py
Normal file
File diff suppressed because it is too large
Load Diff
538
backend/app/services/normalization.py
Normal file
538
backend/app/services/normalization.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""
|
||||
Dynamic Normalization Service.
|
||||
|
||||
Implements dynamic normalization with real-time calculation of rataan and SB
|
||||
for each tryout. Supports multiple normalization modes:
|
||||
- Static: Use hardcoded rataan/SB from config
|
||||
- Dynamic: Calculate rataan/SB from participant NM scores in real-time
|
||||
- Hybrid: Use static until threshold reached, then switch to dynamic
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal, Optional, Tuple
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.tryout import Tryout
|
||||
from app.models.tryout_stats import TryoutStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def calculate_dynamic_stats(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> Tuple[Optional[float], Optional[float]]:
|
||||
"""
|
||||
Calculate current dynamic stats (rataan and SB) from TryoutStats.
|
||||
|
||||
Fetches current TryoutStats for this (tryout_id, website_id) pair
|
||||
and returns the calculated rataan and SB values.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
Tuple of (rataan, sb), both None if no stats exist
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = result.scalar_one_or_none()
|
||||
|
||||
if stats is None:
|
||||
return None, None
|
||||
|
||||
return stats.rataan, stats.sb
|
||||
|
||||
|
||||
async def update_dynamic_normalization(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
nm: int,
|
||||
) -> Tuple[float, float]:
|
||||
"""
|
||||
Update dynamic normalization with new NM score.
|
||||
|
||||
Fetches current TryoutStats and incrementally updates it with the new NM:
|
||||
- Increments participant_count by 1
|
||||
- Adds NM to total_nm_sum
|
||||
- Adds NM² to total_nm_sq_sum
|
||||
- Recalculates rataan and sb
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
nm: Nilai Mentah (raw score) to add
|
||||
|
||||
Returns:
|
||||
Tuple of updated (rataan, sb)
|
||||
|
||||
Raises:
|
||||
ValueError: If nm is out of valid range [0, 1000]
|
||||
"""
|
||||
if not 0 <= nm <= 1000:
|
||||
raise ValueError(f"nm must be in range [0, 1000], got {nm}")
|
||||
|
||||
result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = result.scalar_one_or_none()
|
||||
|
||||
if stats is None:
|
||||
# Initialize new stats record
|
||||
stats = TryoutStats(
|
||||
website_id=website_id,
|
||||
tryout_id=tryout_id,
|
||||
participant_count=1,
|
||||
total_nm_sum=float(nm),
|
||||
total_nm_sq_sum=float(nm * nm),
|
||||
rataan=float(nm),
|
||||
sb=0.0, # SD is 0 for single data point
|
||||
min_nm=nm,
|
||||
max_nm=nm,
|
||||
last_calculated=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(stats)
|
||||
else:
|
||||
# Incrementally update existing stats
|
||||
stats.participant_count += 1
|
||||
stats.total_nm_sum += nm
|
||||
stats.total_nm_sq_sum += nm * nm
|
||||
|
||||
# Update min/max
|
||||
if stats.min_nm is None or nm < stats.min_nm:
|
||||
stats.min_nm = nm
|
||||
if stats.max_nm is None or nm > stats.max_nm:
|
||||
stats.max_nm = nm
|
||||
|
||||
# Recalculate mean and SD
|
||||
n = stats.participant_count
|
||||
sum_nm = stats.total_nm_sum
|
||||
sum_nm_sq = stats.total_nm_sq_sum
|
||||
|
||||
# Mean = Σ NM / n
|
||||
mean = sum_nm / n
|
||||
stats.rataan = mean
|
||||
|
||||
# Variance = (Σ NM² / n) - (mean)²
|
||||
# Using population standard deviation
|
||||
if n > 1:
|
||||
variance = (sum_nm_sq / n) - (mean ** 2)
|
||||
# Clamp variance to non-negative (handles floating point errors)
|
||||
variance = max(0.0, variance)
|
||||
stats.sb = math.sqrt(variance)
|
||||
else:
|
||||
stats.sb = 0.0
|
||||
|
||||
stats.last_calculated = datetime.now(timezone.utc)
|
||||
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Updated dynamic normalization for tryout {tryout_id}, "
|
||||
f"website {website_id}: participant_count={stats.participant_count}, "
|
||||
f"rataan={stats.rataan:.2f}, sb={stats.sb:.2f}"
|
||||
)
|
||||
|
||||
# rataan and sb are always set by this function
|
||||
assert stats.rataan is not None
|
||||
assert stats.sb is not None
|
||||
return stats.rataan, stats.sb
|
||||
|
||||
|
||||
def apply_normalization(
|
||||
nm: int,
|
||||
rataan: float,
|
||||
sb: float,
|
||||
) -> int:
|
||||
"""
|
||||
Apply normalization to NM to get NN (Nilai Nasional).
|
||||
|
||||
Formula: NN = 500 + 100 × ((NM - Rataan) / SB)
|
||||
|
||||
Normalizes scores to mean=500, SD=100 distribution.
|
||||
|
||||
Args:
|
||||
nm: Nilai Mentah (raw score) in range [0, 1000]
|
||||
rataan: Mean of NM scores
|
||||
sb: Standard deviation of NM scores
|
||||
|
||||
Returns:
|
||||
NN (normalized score) in range [0, 1000]
|
||||
|
||||
Raises:
|
||||
ValueError: If nm is out of range or sb is invalid
|
||||
"""
|
||||
if not 0 <= nm <= 1000:
|
||||
raise ValueError(f"nm must be in range [0, 1000], got {nm}")
|
||||
if sb <= 0:
|
||||
# If SD is 0 or negative, return default normalized score
|
||||
# This handles edge case where all scores are identical
|
||||
return 500
|
||||
|
||||
# Calculate normalized score
|
||||
z_score = (nm - rataan) / sb
|
||||
nn = 500 + 100 * z_score
|
||||
|
||||
# Round to integer and clamp to valid range [0, 1000]
|
||||
nn_int = round(nn)
|
||||
return max(0, min(1000, nn_int))
|
||||
|
||||
|
||||
async def get_normalization_mode(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> Literal["static", "dynamic", "hybrid"]:
|
||||
"""
|
||||
Get the current normalization mode for a tryout.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
Normalization mode: "static", "dynamic", or "hybrid"
|
||||
|
||||
Raises:
|
||||
ValueError: If tryout not found
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Tryout).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
tryout = result.scalar_one_or_none()
|
||||
|
||||
if tryout is None:
|
||||
raise ValueError(
|
||||
f"Tryout {tryout_id} not found for website {website_id}"
|
||||
)
|
||||
|
||||
return tryout.normalization_mode
|
||||
|
||||
|
||||
async def check_threshold_for_dynamic(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if participant count meets threshold for dynamic normalization.
|
||||
|
||||
Compares current participant_count with min_sample_for_dynamic from config.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
True if participant_count >= min_sample_for_dynamic, else False
|
||||
"""
|
||||
# Fetch current TryoutStats
|
||||
stats_result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = stats_result.scalar_one_or_none()
|
||||
current_participant_count = stats.participant_count if stats else 0
|
||||
|
||||
# Fetch min_sample_for_dynamic from config
|
||||
tryout_result = await db.execute(
|
||||
select(Tryout.min_sample_for_dynamic).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
min_sample = tryout_result.scalar_one_or_none()
|
||||
|
||||
if min_sample is None:
|
||||
# Default to 100 if not configured
|
||||
min_sample = 100
|
||||
|
||||
return current_participant_count >= min_sample
|
||||
|
||||
|
||||
async def get_normalization_params(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> Tuple[float, float, Literal["static", "dynamic"]]:
|
||||
"""
|
||||
Get normalization parameters (rataan, sb) based on current mode.
|
||||
|
||||
Determines which normalization parameters to use:
|
||||
- Static mode: Use config.static_rataan and config.static_sb
|
||||
- Dynamic mode: Use calculated rataan and sb from TryoutStats
|
||||
- Hybrid mode: Use static until threshold reached, then dynamic
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
Tuple of (rataan, sb, mode_used)
|
||||
|
||||
Raises:
|
||||
ValueError: If tryout not found or dynamic stats unavailable
|
||||
"""
|
||||
# Get normalization mode
|
||||
mode = await get_normalization_mode(db, website_id, tryout_id)
|
||||
|
||||
if mode == "static":
|
||||
# Use static values from config
|
||||
result = await db.execute(
|
||||
select(Tryout.static_rataan, Tryout.static_sb).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
|
||||
if row is None:
|
||||
raise ValueError(
|
||||
f"Tryout {tryout_id} not found for website {website_id}"
|
||||
)
|
||||
|
||||
rataan, sb = row
|
||||
return rataan, sb, "static"
|
||||
|
||||
elif mode == "dynamic":
|
||||
# Use dynamic values from stats
|
||||
rataan, sb = await calculate_dynamic_stats(db, website_id, tryout_id)
|
||||
|
||||
if rataan is None or sb is None:
|
||||
raise ValueError(
|
||||
f"Dynamic normalization not available for tryout {tryout_id}. "
|
||||
"No stats have been calculated yet."
|
||||
)
|
||||
|
||||
if sb == 0:
|
||||
logger.warning(
|
||||
f"Standard deviation is 0 for tryout {tryout_id}. "
|
||||
"All NM scores are identical."
|
||||
)
|
||||
|
||||
return rataan, sb, "dynamic"
|
||||
|
||||
else: # hybrid
|
||||
# Check threshold
|
||||
threshold_met = await check_threshold_for_dynamic(db, website_id, tryout_id)
|
||||
|
||||
if threshold_met:
|
||||
# Use dynamic values
|
||||
rataan, sb = await calculate_dynamic_stats(db, website_id, tryout_id)
|
||||
|
||||
if rataan is None or sb is None:
|
||||
# Fallback to static if dynamic not available
|
||||
result = await db.execute(
|
||||
select(Tryout.static_rataan, Tryout.static_sb).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if row is None:
|
||||
raise ValueError(
|
||||
f"Tryout {tryout_id} not found for website {website_id}"
|
||||
)
|
||||
rataan, sb = row
|
||||
return rataan, sb, "static"
|
||||
|
||||
return rataan, sb, "dynamic"
|
||||
else:
|
||||
# Use static values
|
||||
result = await db.execute(
|
||||
select(Tryout.static_rataan, Tryout.static_sb).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if row is None:
|
||||
raise ValueError(
|
||||
f"Tryout {tryout_id} not found for website {website_id}"
|
||||
)
|
||||
rataan, sb = row
|
||||
return rataan, sb, "static"
|
||||
|
||||
|
||||
async def calculate_skewness(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Calculate skewness of NM distribution for validation.
|
||||
|
||||
Skewness measures the asymmetry of the probability distribution.
|
||||
Values:
|
||||
- Skewness ≈ 0: Symmetric distribution
|
||||
- Skewness > 0: Right-skewed (tail to the right)
|
||||
- Skewness < 0: Left-skewed (tail to the left)
|
||||
|
||||
Formula: Skewness = (n / ((n-1)(n-2))) * Σ((x - mean) / SD)³
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
|
||||
Returns:
|
||||
Skewness value, or None if insufficient data
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = result.scalar_one_or_none()
|
||||
|
||||
if stats is None or stats.participant_count < 3:
|
||||
# Need at least 3 samples for skewness calculation
|
||||
return None
|
||||
|
||||
n = stats.participant_count
|
||||
mean = stats.rataan
|
||||
sd = stats.sb
|
||||
|
||||
if sd == 0:
|
||||
return 0.0 # All values are identical
|
||||
|
||||
# Calculate skewness
|
||||
# We need individual NM values, which we don't have in TryoutStats
|
||||
# For now, return None as we need a different approach
|
||||
# This would require storing all NM values or calculating on-the-fly
|
||||
return None
|
||||
|
||||
|
||||
async def validate_dynamic_normalization(
|
||||
db: AsyncSession,
|
||||
website_id: int,
|
||||
tryout_id: str,
|
||||
target_mean: float = 500.0,
|
||||
target_sd: float = 100.0,
|
||||
mean_tolerance: float = 5.0,
|
||||
sd_tolerance: float = 5.0,
|
||||
) -> Tuple[bool, dict]:
|
||||
"""
|
||||
Validate that dynamic normalization produces expected distribution.
|
||||
|
||||
Checks if calculated rataan and sb are close to target values.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
website_id: Website identifier
|
||||
tryout_id: Tryout identifier
|
||||
target_mean: Target mean (default: 500)
|
||||
target_sd: Target standard deviation (default: 100)
|
||||
mean_tolerance: Allowed deviation from target mean (default: 5)
|
||||
sd_tolerance: Allowed deviation from target SD (default: 5)
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, validation_details)
|
||||
|
||||
validation_details contains:
|
||||
- participant_count: Number of participants
|
||||
- current_rataan: Current mean
|
||||
- current_sb: Current standard deviation
|
||||
- mean_deviation: Absolute deviation from target mean
|
||||
- sd_deviation: Absolute deviation from target SD
|
||||
- mean_within_tolerance: True if mean deviation < mean_tolerance
|
||||
- sd_within_tolerance: True if SD deviation < sd_tolerance
|
||||
- warnings: List of warning messages
|
||||
- suggestions: List of suggestions
|
||||
"""
|
||||
# Get current stats
|
||||
result = await db.execute(
|
||||
select(TryoutStats).where(
|
||||
TryoutStats.website_id == website_id,
|
||||
TryoutStats.tryout_id == tryout_id,
|
||||
)
|
||||
)
|
||||
stats = result.scalar_one_or_none()
|
||||
|
||||
if stats is None or stats.rataan is None or stats.sb is None:
|
||||
return False, {
|
||||
"participant_count": 0,
|
||||
"current_rataan": None,
|
||||
"current_sb": None,
|
||||
"mean_deviation": None,
|
||||
"sd_deviation": None,
|
||||
"mean_within_tolerance": False,
|
||||
"sd_within_tolerance": False,
|
||||
"warnings": ["No statistics available for validation"],
|
||||
"suggestions": ["Wait for more participants to complete sessions"],
|
||||
}
|
||||
|
||||
# Calculate deviations
|
||||
mean_deviation = abs(stats.rataan - target_mean)
|
||||
sd_deviation = abs(stats.sb - target_sd)
|
||||
|
||||
# Check tolerance
|
||||
mean_within_tolerance = mean_deviation <= mean_tolerance
|
||||
sd_within_tolerance = sd_deviation <= sd_tolerance
|
||||
|
||||
is_valid = mean_within_tolerance and sd_within_tolerance
|
||||
|
||||
# Generate warnings
|
||||
warnings = []
|
||||
suggestions = []
|
||||
|
||||
if not mean_within_tolerance:
|
||||
warnings.append(f"Mean deviation ({mean_deviation:.2f}) exceeds tolerance ({mean_tolerance})")
|
||||
if stats.rataan > target_mean:
|
||||
suggestions.append("Distribution may be right-skewed - consider checking question difficulty")
|
||||
else:
|
||||
suggestions.append("Distribution may be left-skewed - consider checking question difficulty")
|
||||
|
||||
if not sd_within_tolerance:
|
||||
warnings.append(f"SD deviation ({sd_deviation:.2f}) exceeds tolerance ({sd_tolerance})")
|
||||
if stats.sb < target_sd:
|
||||
suggestions.append("SD too low - scores may be too tightly clustered")
|
||||
else:
|
||||
suggestions.append("SD too high - scores may have too much variance")
|
||||
|
||||
# Check for skewness
|
||||
skewness = await calculate_skewness(db, website_id, tryout_id)
|
||||
if skewness is not None and abs(skewness) > 0.5:
|
||||
warnings.append(f"Distribution skewness ({skewness:.2f}) > 0.5 - distribution may be asymmetric")
|
||||
suggestions.append("Consider using static normalization if dynamic normalization is unstable")
|
||||
|
||||
# Check participant count
|
||||
if stats.participant_count < 100:
|
||||
suggestions.append(f"Participant count ({stats.participant_count}) below recommended minimum (100)")
|
||||
|
||||
return is_valid, {
|
||||
"participant_count": stats.participant_count,
|
||||
"current_rataan": stats.rataan,
|
||||
"current_sb": stats.sb,
|
||||
"mean_deviation": mean_deviation,
|
||||
"sd_deviation": sd_deviation,
|
||||
"mean_within_tolerance": mean_within_tolerance,
|
||||
"sd_within_tolerance": sd_within_tolerance,
|
||||
"warnings": warnings,
|
||||
"suggestions": suggestions,
|
||||
}
|
||||
1529
backend/app/services/reporting.py
Normal file
1529
backend/app/services/reporting.py
Normal file
File diff suppressed because it is too large
Load Diff
389
backend/app/services/tryout_json_import.py
Normal file
389
backend/app/services/tryout_json_import.py
Normal file
@@ -0,0 +1,389 @@
|
||||
"""
|
||||
Importer for Sejoli tryout JSON snapshot payloads.
|
||||
|
||||
This importer stores snapshots as read-only reference data. It does not create
|
||||
or overwrite operational items, because the exported JSON does not currently
|
||||
contain the full option text needed for the live item bank.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Item, Tryout, TryoutImportSnapshot, TryoutSnapshotQuestion, Website
|
||||
|
||||
SOURCE_FORMAT = "sejoli_json"
|
||||
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
|
||||
class TryoutImportError(ValueError):
|
||||
"""Raised when the incoming payload is structurally invalid."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuestionDiffSummary:
|
||||
total_questions: int
|
||||
new_questions: int
|
||||
updated_questions: int
|
||||
unchanged_questions: int
|
||||
removed_questions: int
|
||||
missing_option_labels: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class TryoutPreview:
|
||||
source_tryout_id: str
|
||||
source_key: str
|
||||
title: str
|
||||
permalink: str | None
|
||||
question_diff: QuestionDiffSummary
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
def _parse_datetime(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
return datetime.strptime(value, DATETIME_FORMAT).replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _sha256(value: Any) -> str:
|
||||
payload = json.dumps(value, sort_keys=True, ensure_ascii=False)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _validate_root(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise TryoutImportError("Payload must be a JSON object.")
|
||||
if "tryouts" not in payload or not isinstance(payload["tryouts"], dict) or not payload["tryouts"]:
|
||||
raise TryoutImportError("Payload must contain a non-empty 'tryouts' object.")
|
||||
return payload
|
||||
|
||||
|
||||
def _extract_tryout_previews(payload: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
|
||||
return list(payload["tryouts"].items())
|
||||
|
||||
|
||||
def _normalize_question(question: dict[str, Any]) -> dict[str, Any]:
|
||||
raw_options = question.get("options") or []
|
||||
has_option_labels = any(
|
||||
bool(((opt or {}).get("text") or (opt or {}).get("label") or "").strip())
|
||||
for opt in raw_options
|
||||
if isinstance(opt, dict)
|
||||
)
|
||||
normalized = {
|
||||
"source_question_id": str(question.get("id", "")),
|
||||
"title": str(question.get("title") or "").strip(),
|
||||
"question": str(question.get("question") or "").strip(),
|
||||
"explanation": str(question.get("explanation") or "").strip() or None,
|
||||
"correct_answer": str(question.get("answer") or "").strip().upper(),
|
||||
"category_id": question.get("category_id"),
|
||||
"category_name": str(question.get("category_name") or "").strip() or None,
|
||||
"category_code": str(question.get("category_code") or "").strip() or None,
|
||||
"raw_options": raw_options,
|
||||
"option_count": len(raw_options),
|
||||
"has_option_labels": has_option_labels,
|
||||
"raw_payload": question,
|
||||
}
|
||||
normalized["content_checksum"] = _sha256(
|
||||
{
|
||||
"title": normalized["title"],
|
||||
"question": normalized["question"],
|
||||
"explanation": normalized["explanation"],
|
||||
"correct_answer": normalized["correct_answer"],
|
||||
"category_id": normalized["category_id"],
|
||||
"category_name": normalized["category_name"],
|
||||
"category_code": normalized["category_code"],
|
||||
"raw_options": normalized["raw_options"],
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
async def ensure_website_exists(db: AsyncSession, website_id: int) -> Website:
|
||||
result = await db.execute(select(Website).where(Website.id == website_id))
|
||||
website = result.scalar_one_or_none()
|
||||
if website is None:
|
||||
raise TryoutImportError(
|
||||
f"Website {website_id} not found. Register the website in the backend first; this is not configured via .env."
|
||||
)
|
||||
return website
|
||||
|
||||
|
||||
async def preview_tryout_json_import(payload: dict[str, Any], website_id: int, db: AsyncSession) -> dict[str, Any]:
|
||||
_validate_root(payload)
|
||||
await ensure_website_exists(db, website_id)
|
||||
|
||||
tryout_previews: list[TryoutPreview] = []
|
||||
total_new = total_updated = total_unchanged = total_removed = total_missing_labels = 0
|
||||
|
||||
for source_key, tryout_payload in _extract_tryout_previews(payload):
|
||||
info = tryout_payload.get("info") or {}
|
||||
source_tryout_id = str(info.get("id") or source_key)
|
||||
title = str(info.get("title") or source_key)
|
||||
questions = tryout_payload.get("questions") or []
|
||||
normalized_questions = [_normalize_question(q) for q in questions]
|
||||
|
||||
existing_result = await db.execute(
|
||||
select(TryoutSnapshotQuestion).where(
|
||||
TryoutSnapshotQuestion.website_id == website_id,
|
||||
TryoutSnapshotQuestion.source_tryout_id == source_tryout_id,
|
||||
)
|
||||
)
|
||||
existing_questions = {
|
||||
row.source_question_id: row
|
||||
for row in existing_result.scalars().all()
|
||||
}
|
||||
|
||||
new_questions = updated_questions = unchanged_questions = 0
|
||||
missing_option_labels = 0
|
||||
incoming_ids: set[str] = set()
|
||||
|
||||
for question in normalized_questions:
|
||||
incoming_ids.add(question["source_question_id"])
|
||||
existing = existing_questions.get(question["source_question_id"])
|
||||
if question["has_option_labels"] is False:
|
||||
missing_option_labels += 1
|
||||
if existing is None:
|
||||
new_questions += 1
|
||||
elif existing.content_checksum != question["content_checksum"]:
|
||||
updated_questions += 1
|
||||
else:
|
||||
unchanged_questions += 1
|
||||
|
||||
removed_questions = sum(1 for question_id, row in existing_questions.items() if row.is_active and question_id not in incoming_ids)
|
||||
|
||||
warnings: list[str] = []
|
||||
if missing_option_labels:
|
||||
warnings.append(
|
||||
f"{missing_option_labels} question(s) have no exported option text in the JSON; import will store raw reference data only."
|
||||
)
|
||||
|
||||
summary = QuestionDiffSummary(
|
||||
total_questions=len(normalized_questions),
|
||||
new_questions=new_questions,
|
||||
updated_questions=updated_questions,
|
||||
unchanged_questions=unchanged_questions,
|
||||
removed_questions=removed_questions,
|
||||
missing_option_labels=missing_option_labels,
|
||||
)
|
||||
|
||||
total_new += new_questions
|
||||
total_updated += updated_questions
|
||||
total_unchanged += unchanged_questions
|
||||
total_removed += removed_questions
|
||||
total_missing_labels += missing_option_labels
|
||||
|
||||
tryout_previews.append(
|
||||
TryoutPreview(
|
||||
source_tryout_id=source_tryout_id,
|
||||
source_key=source_key,
|
||||
title=title,
|
||||
permalink=info.get("permalink"),
|
||||
question_diff=summary,
|
||||
warnings=warnings,
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"source_format": SOURCE_FORMAT,
|
||||
"tryout_count": len(tryout_previews),
|
||||
"totals": {
|
||||
"new_questions": total_new,
|
||||
"updated_questions": total_updated,
|
||||
"unchanged_questions": total_unchanged,
|
||||
"removed_questions": total_removed,
|
||||
"missing_option_labels": total_missing_labels,
|
||||
},
|
||||
"tryouts": [
|
||||
{
|
||||
"source_tryout_id": preview.source_tryout_id,
|
||||
"source_key": preview.source_key,
|
||||
"title": preview.title,
|
||||
"permalink": preview.permalink,
|
||||
"question_diff": preview.question_diff.__dict__,
|
||||
"warnings": preview.warnings,
|
||||
}
|
||||
for preview in tryout_previews
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def import_tryout_json_snapshot(payload: dict[str, Any], website_id: int, db: AsyncSession) -> dict[str, Any]:
|
||||
preview = await preview_tryout_json_import(payload, website_id, db)
|
||||
export_info = payload.get("export_info") or {}
|
||||
|
||||
imported_tryouts: list[dict[str, Any]] = []
|
||||
|
||||
for source_key, tryout_payload in _extract_tryout_previews(payload):
|
||||
info = tryout_payload.get("info") or {}
|
||||
source_tryout_id = str(info.get("id") or source_key)
|
||||
title = str(info.get("title") or source_key)
|
||||
questions = tryout_payload.get("questions") or []
|
||||
results = tryout_payload.get("results") or []
|
||||
normalized_questions = [_normalize_question(q) for q in questions]
|
||||
|
||||
snapshot = TryoutImportSnapshot(
|
||||
website_id=website_id,
|
||||
source_tryout_id=source_tryout_id,
|
||||
source_key=source_key,
|
||||
title=title,
|
||||
source_permalink=info.get("permalink"),
|
||||
source_status=info.get("status"),
|
||||
exported_at=_parse_datetime(export_info.get("exported_at")),
|
||||
source_created_at=_parse_datetime(info.get("created_date")),
|
||||
source_modified_at=_parse_datetime(info.get("modified_date")),
|
||||
exported_by=export_info.get("exported_by"),
|
||||
question_count=len(questions),
|
||||
result_count=len(results),
|
||||
payload_checksum=_sha256(tryout_payload),
|
||||
raw_payload=tryout_payload,
|
||||
)
|
||||
db.add(snapshot)
|
||||
await db.flush()
|
||||
|
||||
# Ensure operational tryout exists
|
||||
result_tryout = await db.execute(
|
||||
select(Tryout).where(
|
||||
Tryout.website_id == website_id,
|
||||
Tryout.tryout_id == source_tryout_id,
|
||||
)
|
||||
)
|
||||
tryout = result_tryout.scalar_one_or_none()
|
||||
if not tryout:
|
||||
tryout = Tryout(
|
||||
website_id=website_id,
|
||||
tryout_id=source_tryout_id,
|
||||
name=title,
|
||||
description=f"Operational tryout basis created from imported snapshot #{snapshot.id}.",
|
||||
scoring_mode="ctt",
|
||||
selection_mode="fixed",
|
||||
normalization_mode="static",
|
||||
ai_generation_enabled=True,
|
||||
)
|
||||
db.add(tryout)
|
||||
await db.flush()
|
||||
|
||||
existing_result = await db.execute(
|
||||
select(TryoutSnapshotQuestion).where(
|
||||
TryoutSnapshotQuestion.website_id == website_id,
|
||||
TryoutSnapshotQuestion.source_tryout_id == source_tryout_id,
|
||||
)
|
||||
)
|
||||
existing_questions = {
|
||||
row.source_question_id: row
|
||||
for row in existing_result.scalars().all()
|
||||
}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
incoming_ids: set[str] = set()
|
||||
new_questions = updated_questions = unchanged_questions = 0
|
||||
|
||||
for question in normalized_questions:
|
||||
source_question_id = question["source_question_id"]
|
||||
incoming_ids.add(source_question_id)
|
||||
existing = existing_questions.get(source_question_id)
|
||||
if existing is None:
|
||||
row = TryoutSnapshotQuestion(
|
||||
website_id=website_id,
|
||||
source_tryout_id=source_tryout_id,
|
||||
source_question_id=source_question_id,
|
||||
latest_snapshot_id=snapshot.id,
|
||||
question_title=question["title"] or question["question"],
|
||||
question_html=question["question"],
|
||||
explanation_html=question["explanation"],
|
||||
raw_options=question["raw_options"],
|
||||
correct_answer=question["correct_answer"],
|
||||
category_id=question["category_id"],
|
||||
category_name=question["category_name"],
|
||||
category_code=question["category_code"],
|
||||
option_count=question["option_count"],
|
||||
has_option_labels=question["has_option_labels"],
|
||||
is_active=True,
|
||||
content_checksum=question["content_checksum"],
|
||||
raw_payload=question["raw_payload"],
|
||||
last_seen_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
new_questions += 1
|
||||
continue
|
||||
|
||||
content_changed = existing.content_checksum != question["content_checksum"]
|
||||
if content_changed:
|
||||
existing.question_title = question["title"] or question["question"]
|
||||
existing.question_html = question["question"]
|
||||
existing.explanation_html = question["explanation"]
|
||||
existing.raw_options = question["raw_options"]
|
||||
existing.correct_answer = question["correct_answer"]
|
||||
existing.category_id = question["category_id"]
|
||||
existing.category_name = question["category_name"]
|
||||
existing.category_code = question["category_code"]
|
||||
existing.option_count = question["option_count"]
|
||||
existing.has_option_labels = question["has_option_labels"]
|
||||
existing.content_checksum = question["content_checksum"]
|
||||
existing.raw_payload = question["raw_payload"]
|
||||
updated_questions += 1
|
||||
else:
|
||||
unchanged_questions += 1
|
||||
|
||||
existing.latest_snapshot_id = snapshot.id
|
||||
existing.is_active = True
|
||||
existing.last_seen_at = now
|
||||
|
||||
# If source content changed, mark AI children derived from this source as stale.
|
||||
if content_changed:
|
||||
stale_variants_result = await db.execute(
|
||||
select(Item).where(
|
||||
Item.generated_by == "ai",
|
||||
Item.source_snapshot_question_id == existing.id,
|
||||
Item.variant_status.in_(["draft", "approved", "active"]),
|
||||
)
|
||||
)
|
||||
for variant in stale_variants_result.scalars().all():
|
||||
variant.variant_status = "stale"
|
||||
|
||||
removed_questions = 0
|
||||
for source_question_id, existing in existing_questions.items():
|
||||
if existing.is_active and source_question_id not in incoming_ids:
|
||||
existing.is_active = False
|
||||
existing.latest_snapshot_id = snapshot.id
|
||||
existing.last_seen_at = now
|
||||
removed_questions += 1
|
||||
stale_removed_result = await db.execute(
|
||||
select(Item).where(
|
||||
Item.generated_by == "ai",
|
||||
Item.source_snapshot_question_id == existing.id,
|
||||
Item.variant_status.in_(["draft", "approved", "active"]),
|
||||
)
|
||||
)
|
||||
for variant in stale_removed_result.scalars().all():
|
||||
variant.variant_status = "stale"
|
||||
|
||||
imported_tryouts.append(
|
||||
{
|
||||
"snapshot_id": snapshot.id,
|
||||
"source_tryout_id": source_tryout_id,
|
||||
"title": title,
|
||||
"new_questions": new_questions,
|
||||
"updated_questions": updated_questions,
|
||||
"unchanged_questions": unchanged_questions,
|
||||
"removed_questions": removed_questions,
|
||||
"question_count": len(normalized_questions),
|
||||
}
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"source_format": SOURCE_FORMAT,
|
||||
"website_id": website_id,
|
||||
"preview": preview,
|
||||
"imported_tryouts": imported_tryouts,
|
||||
"message": "Tryout JSON snapshot imported as read-only reference data.",
|
||||
}
|
||||
456
backend/app/services/wordpress_auth.py
Normal file
456
backend/app/services/wordpress_auth.py
Normal file
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
WordPress Authentication and User Synchronization Service.
|
||||
|
||||
Handles:
|
||||
- JWT token validation via WordPress REST API
|
||||
- User synchronization from WordPress to local database
|
||||
- Multi-site support via website_id isolation
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.user import User
|
||||
from app.models.website import Website
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
# Custom exceptions for WordPress integration
|
||||
class WordPressAuthError(Exception):
|
||||
"""Base exception for WordPress authentication errors."""
|
||||
pass
|
||||
|
||||
|
||||
class WordPressTokenInvalidError(WordPressAuthError):
|
||||
"""Raised when WordPress token is invalid or expired."""
|
||||
pass
|
||||
|
||||
|
||||
class WordPressAPIError(WordPressAuthError):
|
||||
"""Raised when WordPress API is unreachable or returns error."""
|
||||
pass
|
||||
|
||||
|
||||
class WordPressRateLimitError(WordPressAuthError):
|
||||
"""Raised when WordPress API rate limit is exceeded."""
|
||||
pass
|
||||
|
||||
|
||||
class WebsiteNotFoundError(WordPressAuthError):
|
||||
"""Raised when website_id is not found in local database."""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class WordPressUserInfo:
|
||||
"""Data class for WordPress user information."""
|
||||
wp_user_id: str
|
||||
username: str
|
||||
email: str
|
||||
display_name: str
|
||||
roles: list[str]
|
||||
raw_data: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncStats:
|
||||
"""Data class for user synchronization statistics."""
|
||||
inserted: int
|
||||
updated: int
|
||||
total: int
|
||||
errors: int
|
||||
|
||||
|
||||
async def get_wordpress_api_base(website: Website) -> str:
|
||||
"""
|
||||
Get WordPress API base URL for a website.
|
||||
|
||||
Args:
|
||||
website: Website model instance
|
||||
|
||||
Returns:
|
||||
WordPress REST API base URL
|
||||
"""
|
||||
# Use website's site_url if configured, otherwise use global config
|
||||
base_url = website.site_url.rstrip('/')
|
||||
return f"{base_url}/wp-json"
|
||||
|
||||
|
||||
async def verify_wordpress_token(
|
||||
token: str,
|
||||
website_id: int,
|
||||
wp_user_id: str,
|
||||
db: AsyncSession,
|
||||
) -> Optional[WordPressUserInfo]:
|
||||
"""
|
||||
Verify WordPress JWT token and validate user identity.
|
||||
|
||||
Calls WordPress REST API GET /wp/v2/users/me with Authorization header.
|
||||
Verifies response contains matching wp_user_id.
|
||||
Verifies website_id exists in local database.
|
||||
|
||||
Args:
|
||||
token: WordPress JWT authentication token
|
||||
website_id: Website identifier for multi-site isolation
|
||||
wp_user_id: Expected WordPress user ID to verify
|
||||
db: Async database session
|
||||
|
||||
Returns:
|
||||
WordPressUserInfo if valid, None if invalid
|
||||
|
||||
Raises:
|
||||
WebsiteNotFoundError: If website_id doesn't exist
|
||||
WordPressTokenInvalidError: If token is invalid
|
||||
WordPressAPIError: If API is unreachable
|
||||
WordPressRateLimitError: If rate limited
|
||||
"""
|
||||
# Verify website exists
|
||||
website_result = await db.execute(
|
||||
select(Website).where(Website.id == website_id)
|
||||
)
|
||||
website = website_result.scalar_one_or_none()
|
||||
|
||||
if website is None:
|
||||
raise WebsiteNotFoundError(f"Website {website_id} not found")
|
||||
|
||||
api_base = await get_wordpress_api_base(website)
|
||||
url = f"{api_base}/wp/v2/users/me"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
timeout = httpx.Timeout(10.0, connect=5.0)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise WordPressTokenInvalidError("Invalid or expired WordPress token")
|
||||
|
||||
if response.status_code == 429:
|
||||
raise WordPressRateLimitError("WordPress API rate limit exceeded")
|
||||
|
||||
if response.status_code == 503:
|
||||
raise WordPressAPIError("WordPress API service unavailable")
|
||||
|
||||
if response.status_code != 200:
|
||||
raise WordPressAPIError(
|
||||
f"WordPress API error: {response.status_code} - {response.text}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Verify user ID matches
|
||||
response_user_id = str(data.get("id", ""))
|
||||
if response_user_id != str(wp_user_id):
|
||||
logger.warning(
|
||||
f"User ID mismatch: expected {wp_user_id}, got {response_user_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Extract user info
|
||||
user_info = WordPressUserInfo(
|
||||
wp_user_id=response_user_id,
|
||||
username=data.get("username", ""),
|
||||
email=data.get("email", ""),
|
||||
display_name=data.get("name", ""),
|
||||
roles=data.get("roles", []),
|
||||
raw_data=data,
|
||||
)
|
||||
|
||||
return user_info
|
||||
|
||||
except httpx.TimeoutException:
|
||||
raise WordPressAPIError("WordPress API request timed out")
|
||||
except httpx.ConnectError:
|
||||
raise WordPressAPIError("Unable to connect to WordPress API")
|
||||
except httpx.HTTPError as e:
|
||||
raise WordPressAPIError(f"HTTP error communicating with WordPress: {str(e)}")
|
||||
|
||||
|
||||
async def fetch_wordpress_users(
|
||||
website: Website,
|
||||
admin_token: str,
|
||||
page: int = 1,
|
||||
per_page: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Fetch users from WordPress API (requires admin token).
|
||||
|
||||
Calls WordPress REST API GET /wp/v2/users with admin authorization.
|
||||
|
||||
Args:
|
||||
website: Website model instance
|
||||
admin_token: WordPress admin JWT token
|
||||
page: Page number for pagination
|
||||
per_page: Number of users per page (max 100)
|
||||
|
||||
Returns:
|
||||
List of WordPress user data dictionaries
|
||||
|
||||
Raises:
|
||||
WordPressTokenInvalidError: If admin token is invalid
|
||||
WordPressAPIError: If API is unreachable
|
||||
WordPressRateLimitError: If rate limited
|
||||
"""
|
||||
api_base = await get_wordpress_api_base(website)
|
||||
url = f"{api_base}/wp/v2/users"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {admin_token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
params = {
|
||||
"page": page,
|
||||
"per_page": min(per_page, 100),
|
||||
"context": "edit", # Get full user data
|
||||
}
|
||||
|
||||
timeout = httpx.Timeout(30.0, connect=10.0)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise WordPressTokenInvalidError("Invalid admin token for user sync")
|
||||
|
||||
if response.status_code == 403:
|
||||
raise WordPressTokenInvalidError(
|
||||
"Admin token lacks permission to list users"
|
||||
)
|
||||
|
||||
if response.status_code == 429:
|
||||
raise WordPressRateLimitError("WordPress API rate limit exceeded")
|
||||
|
||||
if response.status_code == 503:
|
||||
raise WordPressAPIError("WordPress API service unavailable")
|
||||
|
||||
if response.status_code != 200:
|
||||
raise WordPressAPIError(
|
||||
f"WordPress API error: {response.status_code} - {response.text}"
|
||||
)
|
||||
|
||||
return response.json()
|
||||
|
||||
except httpx.TimeoutException:
|
||||
raise WordPressAPIError("WordPress API request timed out")
|
||||
except httpx.ConnectError:
|
||||
raise WordPressAPIError("Unable to connect to WordPress API")
|
||||
except httpx.HTTPError as e:
|
||||
raise WordPressAPIError(f"HTTP error communicating with WordPress: {str(e)}")
|
||||
|
||||
|
||||
async def sync_wordpress_users(
|
||||
website_id: int,
|
||||
admin_token: str,
|
||||
db: AsyncSession,
|
||||
) -> SyncStats:
|
||||
"""
|
||||
Synchronize users from WordPress to local database.
|
||||
|
||||
Fetches all users from WordPress API and performs upsert:
|
||||
- Updates existing users
|
||||
- Inserts new users
|
||||
|
||||
Args:
|
||||
website_id: Website identifier for multi-site isolation
|
||||
admin_token: WordPress admin JWT token
|
||||
db: Async database session
|
||||
|
||||
Returns:
|
||||
SyncStats with insertion/update counts
|
||||
|
||||
Raises:
|
||||
WebsiteNotFoundError: If website_id doesn't exist
|
||||
WordPressTokenInvalidError: If admin token is invalid
|
||||
WordPressAPIError: If API is unreachable
|
||||
"""
|
||||
# Verify website exists
|
||||
website_result = await db.execute(
|
||||
select(Website).where(Website.id == website_id)
|
||||
)
|
||||
website = website_result.scalar_one_or_none()
|
||||
|
||||
if website is None:
|
||||
raise WebsiteNotFoundError(f"Website {website_id} not found")
|
||||
|
||||
# Fetch existing users from local database
|
||||
existing_users_result = await db.execute(
|
||||
select(User).where(User.website_id == website_id)
|
||||
)
|
||||
existing_users = {
|
||||
str(user.wp_user_id): user
|
||||
for user in existing_users_result.scalars().all()
|
||||
}
|
||||
|
||||
# Fetch users from WordPress (with pagination)
|
||||
all_wp_users = []
|
||||
page = 1
|
||||
per_page = 100
|
||||
|
||||
while True:
|
||||
wp_users = await fetch_wordpress_users(
|
||||
website, admin_token, page, per_page
|
||||
)
|
||||
|
||||
if not wp_users:
|
||||
break
|
||||
|
||||
all_wp_users.extend(wp_users)
|
||||
|
||||
# Check if more pages
|
||||
if len(wp_users) < per_page:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
# Sync users
|
||||
inserted = 0
|
||||
updated = 0
|
||||
errors = 0
|
||||
|
||||
for wp_user in all_wp_users:
|
||||
try:
|
||||
wp_user_id = str(wp_user.get("id", ""))
|
||||
|
||||
if not wp_user_id:
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
if wp_user_id in existing_users:
|
||||
# Update existing user (timestamp update)
|
||||
existing_user = existing_users[wp_user_id]
|
||||
existing_user.updated_at = datetime.now(timezone.utc)
|
||||
updated += 1
|
||||
else:
|
||||
# Insert new user
|
||||
new_user = User(
|
||||
wp_user_id=wp_user_id,
|
||||
website_id=website_id,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(new_user)
|
||||
inserted += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing user {wp_user.get('id')}: {e}")
|
||||
errors += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
total = inserted + updated
|
||||
|
||||
logger.info(
|
||||
f"WordPress user sync complete for website {website_id}: "
|
||||
f"{inserted} inserted, {updated} updated, {errors} errors"
|
||||
)
|
||||
|
||||
return SyncStats(
|
||||
inserted=inserted,
|
||||
updated=updated,
|
||||
total=total,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
async def get_wordpress_user(
|
||||
wp_user_id: str,
|
||||
website_id: int,
|
||||
db: AsyncSession,
|
||||
) -> Optional[User]:
|
||||
"""
|
||||
Get user from local database by WordPress user ID and website ID.
|
||||
|
||||
Args:
|
||||
wp_user_id: WordPress user ID
|
||||
website_id: Website identifier for multi-site isolation
|
||||
db: Async database session
|
||||
|
||||
Returns:
|
||||
User object if found, None otherwise
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.wp_user_id == wp_user_id,
|
||||
User.website_id == website_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def verify_website_exists(
|
||||
website_id: int,
|
||||
db: AsyncSession,
|
||||
) -> Website:
|
||||
"""
|
||||
Verify website exists in database.
|
||||
|
||||
Args:
|
||||
website_id: Website identifier
|
||||
db: Async database session
|
||||
|
||||
Returns:
|
||||
Website model instance
|
||||
|
||||
Raises:
|
||||
WebsiteNotFoundError: If website doesn't exist
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Website).where(Website.id == website_id)
|
||||
)
|
||||
website = result.scalar_one_or_none()
|
||||
|
||||
if website is None:
|
||||
raise WebsiteNotFoundError(f"Website {website_id} not found")
|
||||
|
||||
return website
|
||||
|
||||
|
||||
async def get_or_create_user(
|
||||
wp_user_id: str,
|
||||
website_id: int,
|
||||
db: AsyncSession,
|
||||
) -> User:
|
||||
"""
|
||||
Get existing user or create new one if not exists.
|
||||
|
||||
Args:
|
||||
wp_user_id: WordPress user ID
|
||||
website_id: Website identifier
|
||||
db: Async database session
|
||||
|
||||
Returns:
|
||||
User model instance
|
||||
"""
|
||||
existing = await get_wordpress_user(wp_user_id, website_id, db)
|
||||
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
# Create new user
|
||||
new_user = User(
|
||||
wp_user_id=wp_user_id,
|
||||
website_id=website_id,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(new_user)
|
||||
await db.commit()
|
||||
await db.refresh(new_user)
|
||||
|
||||
return new_user
|
||||
Reference in New Issue
Block a user