From b8e201b45fe70f8a7d095ef594c9ae11d7677db6 Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Sat, 20 Jun 2026 01:43:39 +0700 Subject: [PATCH] Checkpoint React frontend migration --- ADMIN_TRYOUT_RESTRUCTURE_PLAN.md | 365 + FRONTEND_MIGRATION_AUDIT_REPORT.md | 615 ++ FRONTEND_MIGRATION_CUTOVER.md | 31 + UX_AUDIT_ADMIN_FLOW.md | 439 + app/routers/admin.py | 227 - app/schemas/ai.py | 102 - .env.example => backend/.env.example | 0 Dockerfile => backend/Dockerfile | 2 +- alembic.ini => backend/alembic.ini | 0 {alembic => backend/alembic}/README | 0 {alembic => backend/alembic}/env.py | 0 {alembic => backend/alembic}/script.py.mako | 0 .../20260331_000001_initial_schema.py | 0 .../20260402_000002_tryout_json_snapshots.py | 0 ...60404_000003_ai_runs_and_variant_status.py | 0 .../20260405_000004_report_schedules.py | 0 .../20260617_000005_session_expires_at.py | 26 + {app => backend/app}/__init__.py | 0 {app => backend/app}/admin.py | 0 {app => backend/app}/admin_web.py | 1895 +++- {app => backend/app}/admin_web_icons.py | 4 +- {app => backend/app}/api/__init__.py | 0 {app => backend/app}/api/v1/__init__.py | 0 {app => backend/app}/api/v1/session.py | 8 + {app => backend/app}/core/__init__.py | 0 {app => backend/app}/core/auth.py | 42 +- {app => backend/app}/core/config.py | 8 +- {app => backend/app}/core/rate_limit.py | 0 {app => backend/app}/database.py | 0 {app => backend/app}/main.py | 10 + {app => backend/app}/models/__init__.py | 0 .../app}/models/ai_generation_run.py | 0 {app => backend/app}/models/item.py | 0 .../app}/models/report_schedule.py | 0 {app => backend/app}/models/session.py | 3 + {app => backend/app}/models/tryout.py | 0 .../app}/models/tryout_import_snapshot.py | 0 .../app}/models/tryout_snapshot_question.py | 0 {app => backend/app}/models/tryout_stats.py | 0 {app => backend/app}/models/user.py | 0 {app => backend/app}/models/user_answer.py | 0 {app => backend/app}/models/website.py | 0 {app => backend/app}/routers/__init__.py | 4 + backend/app/routers/admin.py | 1077 +++ {app => backend/app}/routers/ai.py | 286 +- backend/app/routers/auth.py | 60 + {app => backend/app}/routers/import_export.py | 11 +- {app => backend/app}/routers/normalization.py | 0 {app => backend/app}/routers/reports.py | 0 {app => backend/app}/routers/sessions.py | 81 +- {app => backend/app}/routers/tryouts.py | 200 +- backend/app/routers/websites.py | 84 + {app => backend/app}/routers/wordpress.py | 0 {app => backend/app}/schemas/__init__.py | 0 backend/app/schemas/ai.py | 180 + {app => backend/app}/schemas/report.py | 0 {app => backend/app}/schemas/session.py | 2 + {app => backend/app}/schemas/tryout.py | 23 + {app => backend/app}/schemas/wordpress.py | 0 {app => backend/app}/services/__init__.py | 0 .../app}/services/ai_generation.py | 251 +- .../app}/services/cat_selection.py | 56 +- .../app}/services/config_management.py | 0 {app => backend/app}/services/ctt_scoring.py | 0 {app => backend/app}/services/excel_import.py | 0 .../app}/services/irt_calibration.py | 0 .../app}/services/normalization.py | 0 {app => backend/app}/services/reporting.py | 0 .../app}/services/tryout_json_import.py | 24 +- .../app}/services/wordpress_auth.py | 0 .../docker-compose.dev.yml | 0 .../docs}/ALUR-APLIKASI-DAN-IRT.md | 0 error.html => backend/error.html | 0 irt_1pl_mle.py => backend/irt_1pl_mle.py | 0 patch_css.py => backend/patch_css.py | 0 patch_icons.py => backend/patch_icons.py | 0 requirements.txt => backend/requirements.txt | 0 run_local.sh => backend/run_local.sh | 0 backend/test_all_post_endpoints.py | 275 + backend/test_all_routes.py | 346 + backend/test_debug_login.py | 50 + backend/test_debug_login2.py | 73 + backend/test_debug_traceback.py | 142 + test_error.py => backend/test_error.py | 0 test_fetch.py => backend/test_fetch.py | 0 backend/test_form_posts.py | 404 + backend/test_session_debug.py | 68 + backend/test_variant_approval.py | 374 + {tests => backend/tests}/test_auth_scope.py | 10 + {tests => backend/tests}/test_auth_tokens.py | 24 + .../tests}/test_model_mappings.py | 0 .../tests}/test_normalization.py | 0 .../tests}/test_operational_hardening.py | 59 + {tests => backend/tests}/test_route_wiring.py | 2 +- .../tests}/test_security_regressions.py | 0 .../tests}/test_tryout_json_import.py | 0 docker-compose.yml | 71 + form_posts_check_and_fixes.md | 7198 +++++++++++++++ frontend/.env.example | 1 + frontend/.gitignore | 24 + frontend/Dockerfile | 27 + frontend/README.md | 73 + frontend/components.json | 20 + frontend/eslint.config.js | 22 + frontend/index.html | 13 + frontend/nginx.conf | 34 + frontend/package-lock.json | 4638 ++++++++++ frontend/package.json | 56 + frontend/postcss.config.js | 6 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.css | 184 + frontend/src/App.tsx | 98 + frontend/src/assets/hero.png | Bin 0 -> 13057 bytes frontend/src/assets/react.svg | 1 + frontend/src/assets/vite.svg | 1 + frontend/src/components/AppErrorBoundary.tsx | 38 + frontend/src/components/SafeHtml.tsx | 64 + frontend/src/components/WebsiteSelector.tsx | 61 + frontend/src/components/ui/accordion.tsx | 58 + frontend/src/components/ui/alert.tsx | 59 + frontend/src/components/ui/badge.tsx | 36 + frontend/src/components/ui/button.tsx | 56 + frontend/src/components/ui/card.tsx | 79 + frontend/src/components/ui/collapsible.tsx | 9 + frontend/src/components/ui/dialog.tsx | 122 + frontend/src/components/ui/input.tsx | 22 + frontend/src/components/ui/label.tsx | 26 + frontend/src/components/ui/progress.tsx | 28 + frontend/src/components/ui/radio-group.tsx | 42 + frontend/src/components/ui/select.tsx | 158 + frontend/src/components/ui/skeleton.tsx | 15 + frontend/src/components/ui/sonner.tsx | 45 + frontend/src/components/ui/table.tsx | 117 + frontend/src/components/ui/tabs.tsx | 53 + frontend/src/components/ui/textarea.tsx | 22 + frontend/src/index.css | 91 + frontend/src/layouts/AdminLayout.tsx | 75 + frontend/src/main.tsx | 10 + frontend/src/pages/admin/Dashboard.tsx | 274 + .../src/pages/admin/ai/PendingReviews.tsx | 398 + frontend/src/pages/admin/ai/index.tsx | 16 + .../pages/admin/exams/ImportTryoutModal.tsx | 241 + frontend/src/pages/admin/exams/index.tsx | 161 + frontend/src/pages/admin/import/index.tsx | 299 + .../src/pages/admin/overview/DataOverview.tsx | 179 + .../pages/admin/questions/QuestionDetail.tsx | 238 + .../pages/admin/questions/QuestionQuality.tsx | 169 + .../pages/admin/questions/QuestionsList.tsx | 178 + .../pages/admin/questions/TemplatesList.tsx | 89 + frontend/src/pages/admin/questions/index.tsx | 20 + frontend/src/pages/admin/reports/index.tsx | 470 + frontend/src/pages/admin/settings/index.tsx | 209 + .../src/pages/admin/tryouts/AIWorkspace.tsx | 704 ++ .../src/pages/admin/tryouts/AttemptList.tsx | 91 + .../src/pages/admin/tryouts/Normalization.tsx | 185 + .../admin/tryouts/QuestionManagement.tsx | 312 + .../src/pages/admin/tryouts/TryoutLayout.tsx | 49 + .../pages/admin/tryouts/TryoutSettings.tsx | 291 + frontend/src/pages/auth/Login.tsx | 109 + frontend/src/pages/student/StudentLayout.tsx | 27 + frontend/src/pages/student/StudentResult.tsx | 116 + frontend/src/pages/student/StudentSession.tsx | 259 + frontend/src/pages/student/StudentTryouts.tsx | 147 + frontend/src/store/useAppStore.ts | 25 + frontend/src/types/index.ts | 97 + frontend/tsconfig.app.json | 30 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 24 + frontend/vite.config.ts | 13 + sejoli-tryout-132380-20260615-2229.json | 7713 +++++++++++++++++ test_api.py | 19 + test_list.py | 18 + 173 files changed, 34116 insertions(+), 782 deletions(-) create mode 100644 ADMIN_TRYOUT_RESTRUCTURE_PLAN.md create mode 100644 FRONTEND_MIGRATION_AUDIT_REPORT.md create mode 100644 FRONTEND_MIGRATION_CUTOVER.md create mode 100644 UX_AUDIT_ADMIN_FLOW.md delete mode 100644 app/routers/admin.py delete mode 100644 app/schemas/ai.py rename .env.example => backend/.env.example (100%) rename Dockerfile => backend/Dockerfile (92%) rename alembic.ini => backend/alembic.ini (100%) rename {alembic => backend/alembic}/README (100%) rename {alembic => backend/alembic}/env.py (100%) rename {alembic => backend/alembic}/script.py.mako (100%) rename {alembic => backend/alembic}/versions/20260331_000001_initial_schema.py (100%) rename {alembic => backend/alembic}/versions/20260402_000002_tryout_json_snapshots.py (100%) rename {alembic => backend/alembic}/versions/20260404_000003_ai_runs_and_variant_status.py (100%) rename {alembic => backend/alembic}/versions/20260405_000004_report_schedules.py (100%) create mode 100644 backend/alembic/versions/20260617_000005_session_expires_at.py rename {app => backend/app}/__init__.py (100%) rename {app => backend/app}/admin.py (100%) rename {app => backend/app}/admin_web.py (73%) rename {app => backend/app}/admin_web_icons.py (99%) rename {app => backend/app}/api/__init__.py (100%) rename {app => backend/app}/api/v1/__init__.py (100%) rename {app => backend/app}/api/v1/session.py (97%) rename {app => backend/app}/core/__init__.py (100%) rename {app => backend/app}/core/auth.py (75%) rename {app => backend/app}/core/config.py (94%) rename {app => backend/app}/core/rate_limit.py (100%) rename {app => backend/app}/database.py (100%) rename {app => backend/app}/main.py (97%) rename {app => backend/app}/models/__init__.py (100%) rename {app => backend/app}/models/ai_generation_run.py (100%) rename {app => backend/app}/models/item.py (100%) rename {app => backend/app}/models/report_schedule.py (100%) rename {app => backend/app}/models/session.py (97%) rename {app => backend/app}/models/tryout.py (100%) rename {app => backend/app}/models/tryout_import_snapshot.py (100%) rename {app => backend/app}/models/tryout_snapshot_question.py (100%) rename {app => backend/app}/models/tryout_stats.py (100%) rename {app => backend/app}/models/user.py (100%) rename {app => backend/app}/models/user_answer.py (100%) rename {app => backend/app}/models/website.py (100%) rename {app => backend/app}/routers/__init__.py (79%) create mode 100644 backend/app/routers/admin.py rename {app => backend/app}/routers/ai.py (52%) create mode 100644 backend/app/routers/auth.py rename {app => backend/app}/routers/import_export.py (97%) rename {app => backend/app}/routers/normalization.py (100%) rename {app => backend/app}/routers/reports.py (100%) rename {app => backend/app}/routers/sessions.py (84%) rename {app => backend/app}/routers/tryouts.py (67%) create mode 100644 backend/app/routers/websites.py rename {app => backend/app}/routers/wordpress.py (100%) rename {app => backend/app}/schemas/__init__.py (100%) create mode 100644 backend/app/schemas/ai.py rename {app => backend/app}/schemas/report.py (100%) rename {app => backend/app}/schemas/session.py (97%) rename {app => backend/app}/schemas/tryout.py (68%) rename {app => backend/app}/schemas/wordpress.py (100%) rename {app => backend/app}/services/__init__.py (100%) rename {app => backend/app}/services/ai_generation.py (70%) rename {app => backend/app}/services/cat_selection.py (92%) rename {app => backend/app}/services/config_management.py (100%) rename {app => backend/app}/services/ctt_scoring.py (100%) rename {app => backend/app}/services/excel_import.py (100%) rename {app => backend/app}/services/irt_calibration.py (100%) rename {app => backend/app}/services/normalization.py (100%) rename {app => backend/app}/services/reporting.py (100%) rename {app => backend/app}/services/tryout_json_import.py (94%) rename {app => backend/app}/services/wordpress_auth.py (100%) rename docker-compose.dev.yml => backend/docker-compose.dev.yml (100%) rename {docs => backend/docs}/ALUR-APLIKASI-DAN-IRT.md (100%) rename error.html => backend/error.html (100%) rename irt_1pl_mle.py => backend/irt_1pl_mle.py (100%) rename patch_css.py => backend/patch_css.py (100%) rename patch_icons.py => backend/patch_icons.py (100%) rename requirements.txt => backend/requirements.txt (100%) rename run_local.sh => backend/run_local.sh (100%) create mode 100644 backend/test_all_post_endpoints.py create mode 100644 backend/test_all_routes.py create mode 100644 backend/test_debug_login.py create mode 100644 backend/test_debug_login2.py create mode 100644 backend/test_debug_traceback.py rename test_error.py => backend/test_error.py (100%) rename test_fetch.py => backend/test_fetch.py (100%) create mode 100644 backend/test_form_posts.py create mode 100644 backend/test_session_debug.py create mode 100644 backend/test_variant_approval.py rename {tests => backend/tests}/test_auth_scope.py (72%) rename {tests => backend/tests}/test_auth_tokens.py (67%) rename {tests => backend/tests}/test_model_mappings.py (100%) rename {tests => backend/tests}/test_normalization.py (100%) rename {tests => backend/tests}/test_operational_hardening.py (76%) rename {tests => backend/tests}/test_route_wiring.py (82%) rename {tests => backend/tests}/test_security_regressions.py (100%) rename {tests => backend/tests}/test_tryout_json_import.py (100%) create mode 100644 docker-compose.yml create mode 100644 form_posts_check_and_fixes.md create mode 100644 frontend/.env.example create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/components.json create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/components/AppErrorBoundary.tsx create mode 100644 frontend/src/components/SafeHtml.tsx create mode 100644 frontend/src/components/WebsiteSelector.tsx create mode 100644 frontend/src/components/ui/accordion.tsx create mode 100644 frontend/src/components/ui/alert.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/collapsible.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/progress.tsx create mode 100644 frontend/src/components/ui/radio-group.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/components/ui/skeleton.tsx create mode 100644 frontend/src/components/ui/sonner.tsx create mode 100644 frontend/src/components/ui/table.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/components/ui/textarea.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/layouts/AdminLayout.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/admin/Dashboard.tsx create mode 100644 frontend/src/pages/admin/ai/PendingReviews.tsx create mode 100644 frontend/src/pages/admin/ai/index.tsx create mode 100644 frontend/src/pages/admin/exams/ImportTryoutModal.tsx create mode 100644 frontend/src/pages/admin/exams/index.tsx create mode 100644 frontend/src/pages/admin/import/index.tsx create mode 100644 frontend/src/pages/admin/overview/DataOverview.tsx create mode 100644 frontend/src/pages/admin/questions/QuestionDetail.tsx create mode 100644 frontend/src/pages/admin/questions/QuestionQuality.tsx create mode 100644 frontend/src/pages/admin/questions/QuestionsList.tsx create mode 100644 frontend/src/pages/admin/questions/TemplatesList.tsx create mode 100644 frontend/src/pages/admin/questions/index.tsx create mode 100644 frontend/src/pages/admin/reports/index.tsx create mode 100644 frontend/src/pages/admin/settings/index.tsx create mode 100644 frontend/src/pages/admin/tryouts/AIWorkspace.tsx create mode 100644 frontend/src/pages/admin/tryouts/AttemptList.tsx create mode 100644 frontend/src/pages/admin/tryouts/Normalization.tsx create mode 100644 frontend/src/pages/admin/tryouts/QuestionManagement.tsx create mode 100644 frontend/src/pages/admin/tryouts/TryoutLayout.tsx create mode 100644 frontend/src/pages/admin/tryouts/TryoutSettings.tsx create mode 100644 frontend/src/pages/auth/Login.tsx create mode 100644 frontend/src/pages/student/StudentLayout.tsx create mode 100644 frontend/src/pages/student/StudentResult.tsx create mode 100644 frontend/src/pages/student/StudentSession.tsx create mode 100644 frontend/src/pages/student/StudentTryouts.tsx create mode 100644 frontend/src/store/useAppStore.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 sejoli-tryout-132380-20260615-2229.json create mode 100644 test_api.py create mode 100644 test_list.py diff --git a/ADMIN_TRYOUT_RESTRUCTURE_PLAN.md b/ADMIN_TRYOUT_RESTRUCTURE_PLAN.md new file mode 100644 index 0000000..65a5218 --- /dev/null +++ b/ADMIN_TRYOUT_RESTRUCTURE_PLAN.md @@ -0,0 +1,365 @@ +# Admin UI Redesign - Implementation Plan + +## Overview + +This plan outlines the migration from the current scattered admin structure to a clean, hierarchy-driven navigation centered on **Tryouts**. + +### Guiding Principles +1. **One main page per domain** - Features live under their parent, not as separate menu items +2. **URL reflects depth** - Path structure shows relationship (`/admin/tryout/{id}/questions`) +3. **Tree as map** - Hierarchy tree shows structure; drill-down shows details +4. **Consistent naming** - Use "Tryout" instead of "Exam" throughout + +--- + +## 1. URL Structure + +### New URL Scheme + +| Old Route | New Route | Description | +|-----------|-----------|-------------| +| `/admin/exams` | `/admin/tryouts` | Hierarchy tree (main entry) | +| `/admin/student-attempts` | `/admin/tryout/{tryout_id}/attempts` | Attempts filtered by tryout | +| - | `/admin/tryout/{tryout_id}/questions` | Questions filtered by tryout | +| - | `/admin/tryout/{tryout_id}/questions/{question_id}/workspace` | Question workspace | +| - | `/admin/tryout/{tryout_id}/questions/{question_id}/workspace/{tab}` | Workspace tabs | +| - | `/admin/tryout/{tryout_id}/normalization` | Normalization settings for this tryout | +| `/admin/questions` | `/admin/questions` | Global question list (kept) | +| (none) | `/admin/import-tryout` | Import tryout modal/page | + +> **Note:** Import is tryout-level, not question-level. Import button lives on `/admin/tryouts` page header. + +### Hierarchy Depth Convention + +``` +/admin/tryouts → Level 0: Root +/admin/tryout/{tryout_id} → Level 1: Entity +/admin/tryout/{tryout_id}/attempts → Level 2: Related data +/admin/tryout/{tryout_id}/questions → Level 2: Related data +/admin/tryout/{tryout_id}/questions/{id} → Level 3: Specific item +/admin/tryout/{tryout_id}/questions/{id}/workspace → Level 4: Detail view +``` + +--- + +## 2. Navigation Structure + +### Proposed Navigation + +``` +Questions +├── /admin/questions # Global question list +└── /admin/tryout/*/questions/*/workspace # Direct link from tree + +Tryouts +├── /admin/tryouts # Tree: Website → Tryout → Stat → Actions + Import button +├── /admin/tryout/*/attempts # Filtered attempts +├── /admin/tryout/*/questions # Questions in this tryout +├── /admin/tryout/*/normalization # Normalization settings +└── /admin/import-tryout # Import modal/page + +Reports +├── /admin/reports # Dashboard +├── /admin/item-statistics +└── /admin/calibration-status + +Settings +├── /admin/settings +├── /admin/websites +└── /admin/password +``` + +### Navigation Item Definition + +```python +ADMIN_NAV_ITEMS = ( + ("Dashboard", "/admin/dashboard", ("/admin/dashboard",)), + ("Questions", "/admin/questions", ( + "/admin/questions", + "/admin/tryout/*/questions/*/workspace", # Pattern for direct links + )), + ("Tryouts", "/admin/tryouts", ( + "/admin/tryouts", + "/admin/tryout/*/attempts", + "/admin/tryout/*/questions", + "/admin/tryout/*/normalization", + "/admin/import-tryout", + )), + ("Reports", "/admin/reports", ( + "/admin/reports", + "/admin/item-statistics", + "/admin/calibration-status", + )), + ("Settings", "/admin/settings", ( + "/admin/settings", + "/admin/websites", + "/admin/password", + )), + ("Logout", "/admin/logout", ("/admin/logout",)), +) +``` + +--- + +## 3. Tryouts Tree Structure + +### Visual Design + +``` +┌─ Tryouts ───────────────────────────────────────────────────────────────────┐ +│ │ +│ [+ Import Tryout] │ +│ │ +│ 🌐 Website A │ +│ │ │ +│ ├─ 📋 132380 - UTBK 2024 [●] │ +│ │ └─ [Expanded on click] │ +│ │ │ +│ ├─ 📋 132381 - SIMAK UI [✓] │ +│ │ │ +│ └─ 📋 132382 - PAS Semester 1 [○] │ +│ │ +│ 🌐 Website B │ +│ └─ ... │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ + +Expanded Tryout View: +┌─ 📋 132380 - UTBK 2024 ─────────────────────────────────────────────────────┐ +│ │ +│ ┌─ Stat Card ─────────────────────────────────────────────────────────┐ │ +│ │ 👥 150 participants │ NM: 672 avg │ NN: 505 avg │ │ +│ │ ✓ 98% completion │ 📐 Calibration: ████████░░ 85% (17/20) │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ [📝 Questions (20)] [👥 Attempts (150)] [📐 Normalization] [⚙ Settings]│ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ + +Legend: +[●] Partial (50-89% calibrated) +[✓] Ready (≥90% calibrated) +[○] Needs Data (<50% calibrated) +``` + +### Import Button Location + +- **Location:** Header of `/admin/tryouts` page +- **Label:** "[+ Import Tryout]" or "[Import Tryout JSON]" +- **Behavior:** Opens import modal/page +- **Why:** Import is tryout-level operation (imports questions WITH tryout context) + +### Stat Card Components + +| Field | Source | Display | +|-------|--------|---------| +| Participants | `TryoutStats.participant_count` | 👥 {count} | +| Avg NM | `AVG(Session.NM)` where completed | 📊 {value} avg | +| Avg NN | `AVG(Session.NN)` where completed | 📈 {value} avg | +| Completion Rate | `completed / participants * 100` | ✓ {percentage}% | +| Calibration | `calibrated_items / total_items` | 📐 Progress bar + {count}/{total} | + +### Action Buttons + +| Action | Target URL | Icon | +|--------|------------|------| +| Questions | `/admin/tryout/{id}/questions` | 📝 | +| Attempts | `/admin/tryout/{id}/attempts` | 👥 | +| Normalization | `/admin/tryout/{id}/normalization` | 📐 | +| Settings | `/admin/tryout/{id}/settings` (or modal) | ⚙ | + +--- + +## 4. Page Specifications + +### 4.1 `/admin/tryouts` (Main Tree) + +**Purpose:** Primary navigation entry, shows structure at a glance + +**Default State:** +- Websites expanded +- Tryouts collapsed +- Shows calibration indicator dot next to each tryout + +**Interactions:** +- Click tryout → expand/collapse +- Expanded tryout shows stat card + action buttons +- Actions navigate to filtered views + +### 4.2 `/admin/tryout/{tryout_id}/questions` + +**Purpose:** View all questions in a specific tryout + +**Behavior:** +- Shows only original/imported questions (basis items) +- Pre-filtered by `tryout_id` +- Links to workspace for AI variant generation + +**Table Columns:** + +| Column | Description | +|--------|-------------| +| ID | Question internal ID | +| Stem Preview | First 100 chars of question text | +| Difficulty | Current difficulty level | +| Calibration | P-value or IRT-b indicator | +| Variants | Count of generated variants | +| Actions | [View] [Workspace] | + +### 4.3 `/admin/tryout/{tryout_id}/questions/{question_id}/workspace` + +**Purpose:** Generate and manage question variants + +**Tabs:** + +| Tab | Purpose | +|-----|---------| +| Generate | AI variant generation interface | +| Review | Review generated variants | +| Batch | Batch generation options | + +**Access Pattern:** +- Opens from question list or tree direct link +- Context: knows parent tryout, parent question + +### 4.4 `/admin/tryout/{tryout_id}/attempts` + +**Purpose:** View student attempts for specific tryout + +**Current Implementation:** Already exists at `/admin/student-attempts` → migrate URL + +**Enhancements:** +- Pre-filtered by `tryout_id` (no dropdown needed on this page) +- Stat card from parent tryout shown at top + +### 4.5 `/admin/tryout/{tryout_id}/normalization` + +**Purpose:** Configure normalization settings for a specific tryout + +**Settings (per-tryout):** + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| Mode | Select | Auto | Auto (calculate from data) or Manual (fixed values) | +| Rataan | Number | 500 | Target mean for normalization | +| SB | Number | 100 | Target standard deviation | +| Recalculate | Button | - | Re-run normalization on existing sessions | + +**Formula:** `NN = 500 + 100 × ((NM - Rataan) / SB)` + +**UI:** +- Simple form with current values +- "Recalculate" button triggers normalization job +- Shows last normalization timestamp + +### 4.6 `/admin/import-tryout` + +**Purpose:** Import tryout data (questions + metadata) from JSON + +**Access:** Via "[+ Import Tryout]" button on `/admin/tryouts` page + +**Behavior:** +- Opens modal or dedicated page +- Upload JSON file or paste JSON content +- Preview import before confirming +- Creates new tryout with questions + +**URL Convention:** Not under specific tryout (it's creating a new one) + +--- + +## 5. Deprecations + +### Routes to Remove + +| Route | Reason | +|-------|--------| +| `/admin/exams` | Renamed to `/admin/tryouts` | +| `/admin/student-attempts` | URL changed to `/admin/tryout/{id}/attempts` | +| `/admin/templates` | AI uses basis items directly | +| `/admin/basis-items` | Merge into question workspace | +| `/admin/hierarchy` | Tree IS the hierarchy | +| `/admin/question-quality` | Merged into tryout stat card | + +### Legacy Redirects + +```python +LEGACY_URL_MAP = { + "/admin/exams": "/admin/tryouts", + "/admin/student-attempts": "/admin/tryouts", # Or redirect to tryouts with guidance + "/admin/hierarchy": "/admin/tryouts", + "/admin/question-quality": "/admin/tryouts", + # Templates and basis-items: 404 (removed) +} +``` + +--- + +## 6. Implementation Phases + +### Phase 1: Foundation +- [ ] Rename `/admin/exams` → `/admin/tryouts` (keep old route for now) +- [ ] Implement tree structure in `/admin/tryouts` +- [ ] Move `TryoutStats` info into tree stat cards +- [ ] Add calibration indicator dots + +### Phase 2: URL Migration +- [ ] Create `/admin/tryout/{id}/attempts` (redirect from old route) +- [ ] Create `/admin/tryout/{id}/questions` +- [ ] Update navigation items + +### Phase 3: Workspace Integration +- [ ] Create question workspace route +- [ ] Implement workspace tabs +- [ ] Connect workspace to tree and question list + +### Phase 4: Cleanup +- [ ] Add legacy redirects +- [ ] Remove deprecated routes +- [ ] Update all hardcoded links in views + +### Phase 5: Polish +- [ ] Review all pages for consistency +- [ ] Update documentation +- [ ] Test all navigation paths + +--- + +## 7. Open Questions + +1. ~~Normalization settings~~ - **RESOLVED**: Move under tryout context as `/admin/tryout/{id}/normalization` + +2. ~~Import questions page~~ - **RESOLVED**: Import is tryout-level. Button on `/admin/tryouts` header, not a separate page. + +3. **Tryout settings** - What settings are actually needed? (Scoring mode, time limits, selection criteria?) + +4. **Global questions page** - Is `/admin/questions` (unfiltered) still useful, or should every question access go through tryout context? + +5. **Templates deprecation** - Confirm that `/admin/templates` is truly unused and can be safely removed? + +6. **Legacy routes for deleted pages** - Should `/admin/templates` and `/admin/basis-items` redirect somewhere or return 404? + +--- + +## 8. Files to Modify + +### Primary Changes +- `app/admin_web.py` - Major route restructuring +- Navigation definition in `admin_web.py` +- Legacy URL map + +### Likely Additions +- Static assets for tree expansion/collapse (if not using existing) + +### Documentation Updates +- `ADMIN_UI_REDESIGN_PLAN.md` - Update to reflect final structure +- `PROJECT_UNDERSTANDING.md` - Update route documentation + +--- + +## 9. Changelog + +| Version | Date | Changes | +|---------|------|---------| +| 1.0 | 2026-06-17 | Initial draft based on discussion | +| 1.1 | 2026-06-17 | - Move normalization to `/admin/tryout/{id}/normalization`
- Move import button to `/admin/tryouts` header
- Add normalization page spec (4.5)
- Rename import page spec (4.6)
- Update navigation and action buttons | diff --git a/FRONTEND_MIGRATION_AUDIT_REPORT.md b/FRONTEND_MIGRATION_AUDIT_REPORT.md new file mode 100644 index 0000000..70cb7ab --- /dev/null +++ b/FRONTEND_MIGRATION_AUDIT_REPORT.md @@ -0,0 +1,615 @@ +# Frontend Migration Audit Report + +Date: 2026-06-19 +Project: Yellow Bank Soal / IRT Bank Soal +Scope: Migration from root-level Python/FastAPI admin UI to `backend/` plus new React `frontend/` +Auditor: Codex + +## 1. Executive Summary + +The React frontend is scaffolded and builds successfully, but the migration is not yet feature-complete or integration-safe. The biggest risks are API address drift, tenant/website context bugs, missing parity with the legacy Python admin workflows, and placeholder React pages that appear functional but do not call real backend APIs. + +Current readiness assessment: **not production-ready as the primary replacement for the Python admin UI**. + +Top findings: + +| Priority | Finding | Impact | +|---|---|---| +| P0 | Local frontend API base URL omits `/api/v1` | Most API calls 404 in `npm run dev` and any environment using `frontend/.env`. | +| P0 | System admin website scope starts as `website_id=0` and React Query keys ignore website selection | First dashboard loads empty or wrong scoped data; switching websites can show stale data. | +| P0 | Several React API calls target nonexistent or renamed backend endpoints | Reports, normalization, and Excel import workflows are broken. | +| P1 | Student tryout portal from the migration plan is absent | Core learner flow is not migrated to React. | +| P1 | AI generation UI has incomplete save/review/batch behavior | Operators can generate previews, but core review and batch workflow parity is missing. | +| P1 | Unsafe `dangerouslySetInnerHTML` use without sanitization | Imported or AI-generated HTML can become an admin XSS risk. | +| P2 | Multiple legacy admin features are missing or placeholders | Hierarchy, question quality, question details, password update, exports, and settings are incomplete. | + +The build result is positive: `npm run build` completed successfully. This means the current issues are mainly behavioral and integration defects, not TypeScript compilation blockers. + +## 2. Audit Scope And Methodology + +Reviewed: + +- Repository restructure from root `app/` to `backend/app/` and new `frontend/`. +- Current React routes, pages, state store, API client, and Docker/Nginx configuration. +- Current FastAPI router definitions and generated OpenAPI paths. +- Last committed Python admin surface via `git show HEAD:app/admin_web.py`. +- Existing planning documents: `REACT_Migration_Plan.md`, `ADMIN_TRYOUT_RESTRUCTURE_PLAN.md`, and `UX_AUDIT_ADMIN_FLOW.md`. + +Verification performed: + +- `npm run build` inside `frontend/`: passed. +- FastAPI OpenAPI generation from `backend/app/main.py`: produced 55 paths. +- Static endpoint comparison between React `api.get/post/put/delete` calls and backend route definitions. + +Not performed: + +- Full browser E2E against a running backend/database. +- Live authentication, import, AI generation, or report generation. +- Full backend test suite run. + +## 3. Current Architecture Snapshot + +The current repository is in an uncommitted migration state. Git sees the old root-level Python files as deleted and `backend/` plus `frontend/` as new untracked folders. + +React frontend: + +- Admin-only route shell currently lives in `frontend/src/App.tsx`. +- API helper is `frontend/src/lib/api.ts`. +- Global website and auth token state is persisted in `frontend/src/store/useAppStore.ts`. +- The admin UI has pages for Dashboard, Questions, Tryouts, Reports, Settings, AI Generation, Import, and nested Tryout workspaces. + +Backend: + +- Main FastAPI app lives in `backend/app/main.py`. +- JSON APIs are generally under `/api/v1`. +- Legacy Python admin HTML router is still mounted at `/admin` when `ENABLE_ADMIN=true`. +- Import/export router hardcodes `/api/v1/import-export` inside the router prefix rather than relying on `settings.API_V1_STR`. + +## 4. Verification Results + +| Check | Result | Notes | +|---|---|---| +| React build | Passed | `tsc -b && vite build` completed. Vite warned that the main JS chunk is larger than 500 kB. | +| FastAPI OpenAPI paths | Passed | OpenAPI generated 55 paths. | +| API route parity | Failed | Multiple frontend calls do not map to backend paths or methods. | +| Feature parity with legacy Python admin | Partial | Several legacy workflows are absent, placeholders, or only implemented as HTML admin routes. | +| Local development readiness | Failed | `frontend/.env` and backend CORS settings do not match the default Vite dev setup. | + +## 5. Backend API Paths Observed + +The current OpenAPI schema exposes these relevant JSON paths: + +```text +/api/v1/auth/admin-login +/api/v1/websites +/api/v1/admin/dashboard/stats +/api/v1/admin/questions +/api/v1/admin/templates +/api/v1/admin/tryouts/{tryout_id}/questions +/api/v1/admin/tryouts/{tryout_id}/attempts +/api/v1/admin/ai/models +/api/v1/admin/ai/generate-preview +/api/v1/admin/ai/generate-save +/api/v1/admin/ai/pending-reviews +/api/v1/admin/ai/review/{item_id} +/api/v1/import-export/preview +/api/v1/import-export/questions +/api/v1/import-export/export/questions +/api/v1/import-export/tryout-json/preview +/api/v1/import-export/tryout-json +/api/v1/tryout/ +/api/v1/tryout/{tryout_id}/config +/api/v1/tryout/{tryout_id}/normalization +/api/v1/tryout/{tryout_id}/calibration-status +/api/v1/reports/student/performance +/api/v1/reports/items/analysis +/api/v1/reports/calibration/status +``` + +Legacy HTML-only admin paths still exist under `/admin`, including: + +```text +/admin/hierarchy +/admin/question-quality +/admin/calibration-status +/admin/item-statistics +/admin/session-overview +/admin/snapshot-questions +/admin/snapshot-questions/promote-bulk +/admin/basis-items +/admin/basis-items/{basis_item_id} +/admin/basis-items/{basis_item_id}/generate +/admin/basis-items/{basis_item_id}/review-bulk +/admin/questions/{item_id} +/admin/questions/{item_id}/generate +/admin/questions/{item_id}/generate/review-bulk +``` + +Those HTML paths are not a substitute for React JSON API parity unless the React app intentionally navigates users back into the legacy admin UI. + +## 6. Endpoint Compatibility Matrix + +This matrix assumes the frontend base URL is configured as `http://localhost:8000/api/v1`, as Docker currently does. If the base URL is `http://localhost:8000`, most rows fail one level earlier because `/api/v1` is missing. + +| React call | Backend status | Migration status | +|---|---|---| +| `/auth/admin-login` | Exists as `/api/v1/auth/admin-login` | OK when base URL includes `/api/v1`. | +| `/websites` | Exists as `/api/v1/websites` | OK when base URL includes `/api/v1`. | +| `/admin/dashboard/stats` | Exists as `/api/v1/admin/dashboard/stats` | Path OK, but website scoping can return empty/stale data. | +| `/tryout/` | Exists as `/api/v1/tryout/` | OK when base URL includes `/api/v1`. | +| `/admin/questions` | Exists as `/api/v1/admin/questions` | OK when base URL includes `/api/v1`. | +| `/admin/templates` | Exists as `/api/v1/admin/templates` | Path OK; verify runtime lazy relationship behavior. | +| `/admin/tryouts/{id}/questions` | Exists as `/api/v1/admin/tryouts/{tryout_id}/questions` | OK when base URL includes `/api/v1`. | +| `/admin/tryouts/{id}/attempts` | Exists as `/api/v1/admin/tryouts/{tryout_id}/attempts` | OK when base URL includes `/api/v1`. | +| `/admin/ai/models` | Exists as `/api/v1/admin/ai/models` | OK when base URL includes `/api/v1`. | +| `/admin/ai/generate-preview` | Exists as `/api/v1/admin/ai/generate-preview` | Path OK; payload includes unsupported `operator_notes` in one page but Pydantic ignores extras by default. | +| `/admin/ai/generate-save` | Exists as `/api/v1/admin/ai/generate-save` | Path OK; React passes placeholder slot and can cause duplicate/conflict behavior. | +| `/import-export/tryout-json/preview` | Exists as `/api/v1/import-export/tryout-json/preview` | OK when base URL includes `/api/v1`. | +| `/import-export/tryout-json` | Exists as `/api/v1/import-export/tryout-json` | OK when base URL includes `/api/v1`. | +| `/reports/calibration-status` | Backend has `/api/v1/reports/calibration/status?tryout_id=...` | Broken. Wrong path and missing required `tryout_id`. | +| `/reports/item-analysis` | Backend has `/api/v1/reports/items/analysis?tryout_id=...` | Broken. Wrong path and missing required `tryout_id`. | +| `/reports/student-performance` | Backend has `/api/v1/reports/student/performance?tryout_id=...` | Broken. Wrong path and missing required `tryout_id`. | +| `/tryouts/{id}/config` | Backend has `/api/v1/tryout/{id}/config` | Broken. Uses plural `tryouts`. | +| `POST /tryouts/{id}/normalization` | Backend has `PUT /api/v1/tryout/{id}/normalization` | Broken. Wrong path, method, and payload schema. | +| `/tryouts/{id}/normalization/recalculate` | No JSON API found | Broken. | +| `/import-export/tryout-import` | No JSON API found | Broken. Should likely use `/import-export/preview` or `/import-export/questions`. | +| `/import-export/snapshot-questions/promote-bulk` | No JSON API found | Broken. Legacy equivalent is HTML form POST `/admin/snapshot-questions/promote-bulk`. | + +## 7. Findings + +### P0-01: Local API base URL omits `/api/v1` + +Severity: P0 +Category: API routing / environment configuration +Evidence: + +- `frontend/src/lib/api.ts:5` defaults to `http://localhost:8000`. +- `frontend/.env:1` sets `VITE_API_URL=http://localhost:8000`. +- Backend JSON admin APIs are exposed under `/api/v1/...`. +- Docker build uses `VITE_API_BASE_URL: "http://localhost:8000/api/v1"` in `docker-compose.yml:62`, so Docker and local dev behave differently. + +Impact: + +- Running `npm run dev` or using the checked-in `frontend/.env` makes calls such as `/auth/admin-login`, `/websites`, and `/admin/dashboard/stats` hit the wrong backend URLs. +- Developers can see a compiling app but get login/API failures at runtime. +- Bugs may be masked in Docker while reappearing in local development or other deployments. + +Recommendation: + +- Standardize one env var name, preferably `VITE_API_URL`, and set it to the full API root: `http://localhost:8000/api/v1`. +- Add `frontend/.env.example` with the same value. +- Add a startup assertion or dev console warning if `VITE_API_URL` does not end in `/api/v1`. +- Consider making the Axios helper append `/api/v1` itself so pages never depend on a base URL convention. + +### P0-02: System-admin website scope and React Query cache can show empty or stale tenant data + +Severity: P0 +Category: Multi-tenant data isolation / state management +Evidence: + +- Login issues a system-admin token with `website_id=0` in `backend/app/routers/auth.py:50-55`. +- The comment says this placeholder should produce global access, but `require_website_auth` returns `auth.website_id` whenever it is not `None` in `backend/app/core/auth.py:147-150`. +- `WebsiteSelector` auto-selects the first website asynchronously in `frontend/src/components/WebsiteSelector.tsx:25-29`. +- Dashboard query key is `['dashboard-stats']` and does not include `websiteId` in `frontend/src/pages/admin/Dashboard.tsx:45-50`. +- Other query keys also omit `websiteId`, including `['tryouts']`, `['admin-questions']`, and `['ai-pending-reviews']`. + +Impact: + +- First-load dashboard requests can run before the selector sets `X-Website-ID`; backend may interpret the request as website `0` and return empty data. +- Switching websites can leave cached data from the prior website because React Query keys do not include the website id. +- Multi-tenant admin data can appear wrong even when the API endpoint is otherwise correct. + +Recommendation: + +- Fix backend system-admin semantics: use `website_id=None` for global system admin or make `website_id=0` explicitly mean global access. +- In React, gate website-scoped queries until `websiteId` is set, except for the websites list itself. +- Include `websiteId` in every website-scoped React Query key, for example `['dashboard-stats', websiteId]`. +- Invalidate website-scoped queries when `WebsiteSelector` changes. + +### P0-03: Reports page calls nonexistent backend paths and omits required filters + +Severity: P0 +Category: API contract / reporting +Evidence: + +- React calls `/reports/calibration-status`, `/reports/item-analysis`, and `/reports/student-performance` in `frontend/src/pages/admin/reports/index.tsx:14`, `:58`, and `:88`. +- Backend exposes `/reports/calibration/status`, `/reports/items/analysis`, and `/reports/student/performance` in `backend/app/routers/reports.py:68-80`, `:172-184`, and `:231-241`. +- Each backend report endpoint requires `tryout_id`. +- React report export buttons have no handlers in `frontend/src/pages/admin/reports/index.tsx:29-31`, `:73-75`, and `:103-105`. + +Impact: + +- All three report tabs fail at runtime. +- Even after path correction, the page needs tryout selection or route context because backend requires `tryout_id`. +- Export buttons are misleading because they do not call the export APIs. + +Recommendation: + +- Use the backend paths: + - `/reports/calibration/status?tryout_id={id}` + - `/reports/items/analysis?tryout_id={id}` + - `/reports/student/performance?tryout_id={id}` +- Add tryout selector/context to the Reports page. +- Wire export buttons to `/reports/.../export/{format}` endpoints. +- Render real report tables from response fields instead of placeholder text. + +### P0-04: Tryout normalization page uses wrong paths, method, payload, and silent fallback + +Severity: P0 +Category: API contract / scoring configuration +Evidence: + +- React fetches `/tryouts/{id}/config` in `frontend/src/pages/admin/tryouts/Normalization.tsx:22`. +- Backend route is `/tryout/{tryout_id}/config` in `backend/app/routers/tryouts.py:34-44`. +- React posts `/tryouts/{id}/normalization` with `{ rataan, sb, mode }` in `frontend/src/pages/admin/tryouts/Normalization.tsx:39-45`. +- Backend expects `PUT /tryout/{tryout_id}/normalization` with fields `normalization_mode`, `static_rataan`, and `static_sb` in `backend/app/routers/tryouts.py:109-120`. +- React calls `/tryouts/{id}/normalization/recalculate` in `frontend/src/pages/admin/tryouts/Normalization.tsx:53-56`, but no matching JSON API was found. +- The page catches config load failures and silently displays defaults in `frontend/src/pages/admin/tryouts/Normalization.tsx:21-26`. + +Impact: + +- Operators can believe they changed normalization settings when the requests actually failed or hit nonexistent endpoints. +- Silent defaults can overwrite user trust in scoring configuration by hiding missing data. +- Normalization is core to NM/NN scoring, so this is a production blocker. + +Recommendation: + +- Change GET to `/tryout/{id}/config`. +- Change save to `PUT /tryout/{id}/normalization`. +- Send backend schema names: `normalization_mode`, `static_rataan`, `static_sb`. +- Remove silent fallback for API failures; show an error state. +- Either add a backend recalculation endpoint or remove the button until the API exists. + +### P0-05: Excel import page is wired to nonexistent endpoints + +Severity: P0 +Category: Import workflow / API contract +Evidence: + +- React posts preview/upload to `/import-export/tryout-import` in `frontend/src/pages/admin/import/index.tsx:17-23`. +- React posts confirmation to `/import-export/snapshot-questions/promote-bulk` in `frontend/src/pages/admin/import/index.tsx:31-35`. +- Backend Excel import APIs are `/api/v1/import-export/preview` and `/api/v1/import-export/questions` in `backend/app/routers/import_export.py:53-62` and `:150-160`. +- Snapshot promotion currently exists only in the legacy HTML admin as `/admin/snapshot-questions/promote-bulk`. + +Impact: + +- The standalone Excel Import page cannot complete its workflow. +- Users have two import surfaces: a working JSON import modal under Tryouts and a broken Excel import page under `/admin/import`. +- The comments in the React file explicitly show uncertainty about endpoint names. + +Recommendation: + +- Decide whether Excel import remains in the React admin. +- If yes, wire preview to `/import-export/preview` and confirm to `/import-export/questions` with required `tryout_id`. +- If snapshot promotion is required in React, add a JSON API for selected snapshot question IDs and update the UI accordingly. +- Hide `/admin/import` until the contract is implemented. + +### P1-01: Student tryout portal is missing from React + +Severity: P1 +Category: Feature parity / core business flow +Evidence: + +- `REACT_Migration_Plan.md:73-85` describes Phase 3 Student Portal construction: tryout listing, exam session, async answer submission, state recovery, server timer, and result page. +- Current `frontend/src/App.tsx:38-66` only defines `/login` and `/admin/*` routes. +- No student session routes or pages were found under `frontend/src/pages`. + +Impact: + +- The React migration does not yet cover the learner-facing tryout experience. +- If the goal is full Python frontend replacement, core user-facing functionality remains unmigrated. + +Recommendation: + +- Add student routes for tryout listing, active session, answer submission, completion, and result summary. +- Use existing backend session APIs under `/api/v1/session`. +- Add E2E coverage for refresh recovery and server-synced timer behavior. + +### P1-02: AI generation workflow is incomplete and can save invalid variants + +Severity: P1 +Category: AI generation / operator workflow +Evidence: + +- Global AI page uses manual basis item id and comments that a real template selector is missing in `frontend/src/pages/admin/ai/Workspace.tsx:26-27`. +- Global AI page has "Discard" and "Save & Queue Review" buttons with no handlers in `frontend/src/pages/admin/ai/Workspace.tsx:138-140`. +- Tryout AI workspace saves generated questions with `slot: basisItem ? 1 : 1` in `frontend/src/pages/admin/tryouts/AIWorkspace.tsx:64-76`. +- Tryout AI workspace "Review Variants" and "Batch Generation" tabs are placeholder text in `frontend/src/pages/admin/tryouts/AIWorkspace.tsx:233-253`. +- Legacy Python admin supported batch count, operator notes, note inclusion, run history, filters, review-bulk, and variant detail pages. + +Impact: + +- Operators cannot reliably save variants with correct slot linkage. +- Batch generation and review parity are missing. +- Duplicate slot conflicts are likely because saved AI variants always use slot `1`. + +Recommendation: + +- Use the selected basis item's real `slot`, `tryout_id`, `website_id`, and source snapshot metadata. +- Add JSON APIs if needed for batch generation, run history, review filtering, and bulk review. +- Disable save buttons until all required fields are present. +- Remove or implement the global AI workspace to avoid two partial AI workflows. + +### P1-03: Imported and generated HTML is rendered without sanitization + +Severity: P1 +Category: Security / XSS +Evidence: + +- React renders question HTML with `dangerouslySetInnerHTML` in `frontend/src/pages/admin/tryouts/QuestionManagement.tsx`. +- React renders AI preview stem/options/explanation with `dangerouslySetInnerHTML` in `frontend/src/pages/admin/tryouts/AIWorkspace.tsx:180-200`. +- The migration plan explicitly calls out HTML sanitization as a security checklist item in `REACT_Migration_Plan.md:204-208`. + +Impact: + +- Imported Sejoli payloads or AI-generated content could inject scripts or unsafe markup into admin pages. +- Admin XSS is high impact because admins hold cross-website operational access. + +Recommendation: + +- Add a sanitizer such as DOMPurify. +- Create a single `SafeHtml` component and forbid direct `dangerouslySetInnerHTML` in pages. +- Sanitize on render and consider backend-side validation for stored HTML. + +### P1-04: CORS config does not include the default Vite dev origin + +Severity: P1 +Category: Local development / environment configuration +Evidence: + +- Backend `.env` allows `http://localhost:3000` and `http://localhost:8000` in `backend/.env:15`. +- Vite dev normally serves at `http://localhost:5173`. +- `REACT_Migration_Plan.md:53` explicitly calls out adding frontend dev origins. + +Impact: + +- Local `npm run dev` can fail with CORS errors even after the API base URL is corrected. +- Developers may mistakenly debug auth/API code when the root cause is CORS. + +Recommendation: + +- Add `http://localhost:5173` to `ALLOWED_ORIGINS`. +- Keep Docker/static origin and Vite dev origin both represented in `.env.example`. + +### P2-01: Question quality page is a static placeholder + +Severity: P2 +Category: Missing feature / reporting parity +Evidence: + +- `frontend/src/pages/admin/questions/QuestionQuality.tsx:14-47` displays `...` for all metrics. +- `frontend/src/pages/admin/questions/QuestionQuality.tsx:61-65` says diagnostic charts are coming soon. +- Legacy Python admin had a real `/admin/question-quality` view that computed calibrated totals and per-tryout readiness. + +Impact: + +- Operators lose the prior calibration diagnostics workflow. +- The page appears present but does not provide operational data. + +Recommendation: + +- Either wire this page to `/reports/calibration/status` per selected tryout or add a dashboard-level quality summary API. +- Replace placeholder cards with real metrics and loading/error states. + +### P2-02: Tryout settings and general/security settings are placeholders + +Severity: P2 +Category: Missing feature / admin configuration +Evidence: + +- Tryout settings page contains only placeholder text in `frontend/src/pages/admin/tryouts/TryoutSettings.tsx:14-16`. +- Security settings form has inputs and button but no mutation in `frontend/src/pages/admin/settings/index.tsx:151-176`. +- General settings tab is placeholder text in `frontend/src/pages/admin/settings/index.tsx:203-211`. +- Legacy Python admin had `/admin/password` and website management. + +Impact: + +- Operators cannot update tryout scoring/selection/AI settings from React. +- Password update looks available but does nothing. + +Recommendation: + +- Implement tryout settings using `/tryout/{id}/config` plus update endpoints for scoring mode, selection mode, AI generation, and calibration thresholds. +- Add or expose a JSON password-change endpoint, or hide Security until implemented. +- Replace "General" with concrete settings or remove the tab. + +### P2-03: Hierarchy/data overview was not migrated + +Severity: P2 +Category: Missing feature / operator orientation +Evidence: + +- Legacy Python admin exposed `/admin/hierarchy`. +- `UX_AUDIT_ADMIN_FLOW.md` and `ADMIN_TRYOUT_RESTRUCTURE_PLAN.md` identified hierarchy visibility as important. +- Current React sidebar has Dashboard, Questions, Tryouts, Reports, Settings only in `frontend/src/layouts/AdminLayout.tsx:10-16`. +- No React hierarchy page exists. + +Impact: + +- Operators lose the data relationship map for Website -> Tryout -> Snapshot -> Basis Item -> AI Run -> Variant. +- This was specifically identified as important for reducing confusion after import and AI generation. + +Recommendation: + +- Add a React Data Overview/Hierarchy page. +- Expose a JSON hierarchy API instead of relying on legacy HTML. +- Link it from Dashboard or Tryouts, not only Settings. + +### P2-04: Route structure deviates from the planned tryout-centric URL model + +Severity: P2 +Category: Navigation / route consistency +Evidence: + +- `ADMIN_TRYOUT_RESTRUCTURE_PLAN.md:19-28` planned singular `/admin/tryout/{tryout_id}/...` route depth. +- Current React uses plural `/admin/tryouts/:id/...` in `frontend/src/App.tsx:55-60`. +- Planned question workspace route includes question id, but current route is `/admin/tryouts/:id/questions/ai-workspace` without question id. +- TryoutLayout tabs omit Normalization from the visible tab list even though the route exists. + +Impact: + +- URL semantics differ from the planned hierarchy. +- AI workspace lacks clear parent question context. +- Users navigating to Normalization see a page that is not represented in the tab state. + +Recommendation: + +- Decide on singular or plural route convention and align docs, React routes, and links. +- Include `questionId` in AI workspace routes. +- Add a visible Normalization tab or move normalization under Settings consistently. + +### P2-05: Legacy Python admin remains mounted, creating deployment ambiguity + +Severity: P2 +Category: Deployment / migration completeness +Evidence: + +- `backend/app/main.py` still includes `admin_web_router` when admin is enabled. +- Docker serves React at port `3000` and backend at port `8000`. +- Backend still owns `/admin/*` on port `8000`; React owns `/admin/*` on port `3000`. + +Impact: + +- If production routing later places frontend and backend behind one host, `/admin` routing can easily point to the wrong application. +- Operators may accidentally use two different admin UIs with different feature coverage. + +Recommendation: + +- Define production routing explicitly: + - Frontend owns `/admin/*`. + - Backend owns `/api/v1/*`, `/docs`, `/health`, and possibly legacy admin only behind a temporary fallback path. +- Add a migration flag to disable legacy admin once React parity is reached. + +### P2-06: Query invalidation and cache keys are not website-aware + +Severity: P2 +Category: State management / data freshness +Evidence: + +- Examples: `['dashboard-stats']`, `['tryouts']`, `['admin-questions']`, `['ai-pending-reviews']`. +- The API interceptor changes `X-Website-ID` based on Zustand state, but React Query cache keys do not reflect that state. + +Impact: + +- After switching websites, React Query can return prior website data without refetching. +- The visible WebsiteSelector can imply a different scope than the data currently shown. + +Recommendation: + +- Include `websiteId` in all website-scoped query keys. +- Add a small helper for scoped keys to avoid drift. +- Consider clearing scoped query cache on logout and website switch. + +### P3-01: Several buttons look actionable but do nothing + +Severity: P3 +Category: UX polish / trust +Evidence: + +- Report export buttons have no click handlers. +- Excel "Download Template" button has no click handler. +- Global AI "Discard" and "Save & Queue Review" buttons have no click handlers. +- Settings "Update Password" button has no click handler. + +Impact: + +- The UI feels more complete than it is, which can mislead testers and operators. + +Recommendation: + +- Remove disabled/nonfunctional controls or wire them to real mutations/downloads. +- Prefer disabled buttons with explanatory tooltip only when the missing backend is intentional. + +## 8. Feature Parity Checklist + +| Area | Legacy Python admin | React status | Notes | +|---|---|---|---| +| Login/logout | Present | Partial | JWT login works by path only if base URL includes `/api/v1`; no remember-me equivalent. | +| Dashboard | Present | Partial | React has KPI cards, but first-load website scoping and query key issues affect data. | +| Website management | Present | Partial | React CRUD exists; confirm delete cascade semantics and query invalidation. | +| Tryout import JSON | Present | Mostly present | Modal maps to real JSON endpoints when base URL includes `/api/v1`. | +| Excel import | Present via API | Broken | React page calls nonexistent endpoints. | +| Snapshot question promotion | Present as legacy HTML | Missing JSON/React | React calls nonexistent API. | +| Global question list | Present with filters/detail | Partial | React list exists, but filters and detail page are missing. | +| Question detail | Present | Missing | No React route/page. | +| Question quality | Present | Placeholder | Static cards only. | +| Tryout list/tree | Present/planned | Partial | Accordion exists; average NM/NN and some plan details missing. | +| Tryout attempts | Present | Present basic | Filtered table exists. | +| Normalization | Present | Broken | Wrong API contract. | +| Tryout settings | Present via backend fields | Placeholder | No real form. | +| AI basis workspace | Present | Partial | Preview and single save partially exist; batch/review/run history missing. | +| AI pending review | Present | Partial | List and approve/reject exist; preview/detail missing. | +| Variant detail | Present | Missing | No React page. | +| Bulk variant review | Present | Missing | No React workflow. | +| Hierarchy/data overview | Present | Missing | Important operator context lost. | +| Reports dashboard | Present | Broken/placeholder | Wrong endpoints and no tryout filters. | +| Report exports | Present in backend | Missing in React | Buttons not wired. | +| Password update | Present in legacy HTML | Placeholder | No API/mutation. | +| Student tryout portal | Planned | Missing | No React student/session routes. | + +## 9. Recommended Remediation Plan + +### Phase 0: Stop the bleeding + +1. Fix `VITE_API_URL` to include `/api/v1` in `frontend/.env`, Docker build args, and `.env.example`. +2. Add `http://localhost:5173` to backend CORS for Vite dev. +3. Fix system-admin website scoping so no-header system admin is global or explicitly blocked until a website is selected. +4. Gate website-scoped React queries until `websiteId` is available. +5. Add `websiteId` to all scoped query keys. + +### Phase 1: Repair broken API contracts + +1. Fix Reports paths and require a selected tryout. +2. Fix Normalization GET/PUT paths and payload schema. +3. Remove or fix the broken Excel import page. +4. Decide whether snapshot promotion needs a JSON API and add it if React owns the workflow. +5. Add API contract tests that compare frontend endpoint constants against OpenAPI paths. + +### Phase 2: Recover feature parity + +1. Implement real Tryout Settings. +2. Implement Question Detail and Variant Detail pages. +3. Implement AI run history, review filters, batch generation, and bulk review. +4. Implement Question Quality with real metrics. +5. Implement Data Overview/Hierarchy in React. +6. Wire report export buttons. + +### Phase 3: Student portal + +1. Add learner tryout listing. +2. Add active session page using `/session/{id}/next_item` and `/submit_answer`. +3. Add server-synced timer from `expires_at`. +4. Persist session recovery state. +5. Add completion and result pages. + +### Phase 4: Migration hardening + +1. Decide the final production routing split between frontend `/admin/*` and backend `/api/v1/*`. +2. Disable or move legacy Python admin once React parity is complete. +3. Add Playwright smoke tests for login, website switch, import preview, tryout drilldown, AI preview, normalization save, and reports. +4. Add a route/API smoke test that verifies every visible navigation target and button either works or is intentionally disabled. + +## 10. Suggested Test Plan + +Minimum tests before considering the React migration complete: + +| Test | Expected result | +|---|---| +| `npm run build` | Passes with no TypeScript errors. | +| Login with local env | Hits `/api/v1/auth/admin-login`, stores token, lands on dashboard. | +| First dashboard load | Waits for or uses a real selected website, never website `0`. | +| Website switch | Dashboard, tryouts, questions, reports, and AI pending reviews refetch for the selected website. | +| Tryout JSON preview/import | Calls `/api/v1/import-export/tryout-json/preview` and `/tryout-json`; new tryouts appear. | +| Excel import | Calls real preview and import endpoints or the page is hidden. | +| Normalization save | GET `/api/v1/tryout/{id}/config`, PUT `/api/v1/tryout/{id}/normalization`, visible success/error state. | +| Reports | Requires tryout context and loads real calibration/item/student data. | +| AI preview/save | Saves with correct basis slot and displays generated variant in review queue. | +| AI review/bulk | Approve/reject/archive works and status updates are visible. | +| XSS smoke | Imported HTML and AI HTML are sanitized before rendering. | +| Student session | Start/resume/answer/complete/result works with server timer. | + +## 11. Final Assessment + +The migration has a good foundation: React, routing, TanStack Query, Zustand, shadcn-style components, Docker/Nginx serving, and a number of admin pages are already present. The main risk is that the UI currently looks further along than its backend integration really is. + +The highest leverage next move is to stabilize the API boundary: fix the `/api/v1` base URL, align endpoint paths and methods, make website scoping deterministic, and add website-aware query keys. Once that is done, the team can safely fill the larger parity gaps without chasing confusing 404s, empty dashboards, or stale tenant data. diff --git a/FRONTEND_MIGRATION_CUTOVER.md b/FRONTEND_MIGRATION_CUTOVER.md new file mode 100644 index 0000000..1d1d29e --- /dev/null +++ b/FRONTEND_MIGRATION_CUTOVER.md @@ -0,0 +1,31 @@ +# Frontend Migration Cutover Notes + +## Route Ownership + +- React owns browser-facing admin routes under `/admin/*` and student routes under `/student/*`. +- FastAPI owns JSON APIs under `/api/v1/*`. +- The legacy Python admin remains available as fallback until React parity smoke tests are accepted. + +## Local Development + +- React Vite dev server: `http://127.0.0.1:5173` +- Backend API root: `http://localhost:8000/api/v1` +- Frontend API config should keep `VITE_API_URL` pointed at the FastAPI v1 root. +- System-admin tokens may be global with `website_id: null`; React sends `X-Website-ID` only when the website selector has an explicit website. + +## Cutover Guardrails + +- Do not disable the legacy admin until React covers import, snapshot promotion, question detail, AI review, reports, normalization, settings, and student session smoke tests. +- Avoid adding new frontend calls to legacy or nonexistent API paths. New React API calls should map to OpenAPI paths. +- Website-scoped React Query keys must include the selected website ID and should be gated until a website is selected. +- Any page rendering question HTML must use the shared `SafeHtml` component. + +## Smoke Coverage Used During Migration Fix + +- Admin dashboard +- Global questions list and question detail +- Data overview hierarchy +- AI review, variants, and run history +- Excel import +- Tryout questions, snapshot promotion, settings, normalization, and AI workspace +- Student tryout list, session start, next item, answer submission, completion, and result summary diff --git a/UX_AUDIT_ADMIN_FLOW.md b/UX_AUDIT_ADMIN_FLOW.md new file mode 100644 index 0000000..57007ff --- /dev/null +++ b/UX_AUDIT_ADMIN_FLOW.md @@ -0,0 +1,439 @@ +# UX Audit: Admin Flow - IRT Bank Soal + +> **Audit Date:** 2026-06-17 +> **Auditor:** Dev Agent +> **Focus:** Login → First-time experience → Navigation discoverability → Hierarchy visibility + +--- + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Login Flow Analysis](#login-flow-analysis) +3. [Post-Login Experience](#post-login-experience) +4. [Navigation & Discoverability](#navigation--discoverability) +5. [Hierarchy Visibility](#hierarchy-visibility) +6. [Issue Summary & Priority Matrix](#issue-summary--priority-matrix) +7. [Recommended Improvements](#recommended-improvements) +8. [Appendix: Current vs Proposed Flow](#appendix-current-vs-proposed-flow) + +--- + +## Executive Summary + +The current admin flow has significant UX gaps that make it difficult for new administrators to orient themselves and complete tasks efficiently. The main issues are: + +| Category | Severity | Count | +|----------|----------|-------| +| Critical (blocks usage) | 🔴 High | 4 | +| Medium (confuses users) | 🟡 Medium | 6 | +| Low (minor friction) | 🟢 Low | 5 | + +### Key Findings + +1. **No onboarding guidance** after login - users land on Dashboard with no context +2. **Hierarchy is hidden** in Settings submenu - should be prominently visible +3. **Navigation labels are inconsistent** - mixed technical and human terms +4. **Login page lacks branding** - no visual connection to the product +5. **No breadcrumb navigation** - users get lost in deep pages + +--- + +## Login Flow Analysis + +### Current State + +The login page (`/admin/login`) presents: +- Simple username/password form +- "Remember me" checkbox +- Minimal error messaging +- Help button (bottom-right corner) + +```python +# Current login form elements +- Username field +- Password field +- Remember me checkbox +- Sign in button +``` + +### Issues Found + +| # | Issue | Impact | Severity | +|---|-------|--------|----------| +| 1.1 | **No product branding/logo** | Users don't know what system they're logging into | 🟡 Medium | +| 1.2 | **No error state distinction** | Failed login looks same as rate limiting | 🟡 Medium | +| 1.3 | **"Remember me" is unclear** | Doesn't explain session duration or implications | 🟢 Low | +| 1.4 | **No "forgot password" path** | No recovery mechanism exists | 🟡 Medium | +| 1.5 | **Help button is discoverable** | Good: floating help exists but underutilized | 🟢 Positive | + +### Login → Dashboard Redirect + +**Current behavior:** After successful login → `/admin/dashboard` + +**What users see:** +``` +┌─────────────────────────────────────────┐ +│ Good Morning, admin! 👋 │ +│ Here's what's happening today. │ +│ │ +│ ⚠️ 25 questions need calibration │ +│ 📝 3 AI-generated questions pending │ +│ 💡 Tip: Start by importing questions... │ +│ │ +│ 📊 System Overview │ +│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ +│ │ 5 │ │ 150 │ │ 890 │ │ 2 │ │ +│ │Exams │ │Quest │ │Tests │ │Sites │ │ +│ └──────┘ └──────┘ └──────┘ └──────┘ │ +└─────────────────────────────────────────┘ +``` + +### Problems After Login + +| # | Issue | Why It's a Problem | +|---|-------|-------------------| +| 2.1 | **No welcome message explaining the system** | First-time users don't know what IRT Bank Soal does | +| 2.2 | **"5 Exams" is meaningless without context** | Users don't know what an Exam/Tryout means | +| 2.3 | **Alerts are action-oriented but not instructive** | "Import questions" - but where? How? | +| 2.4 | **Quick Actions use technical language** | "Generate AI Questions" doesn't explain what happens | +| 2.5 | **No first-time setup wizard** | Empty state users have no guidance | + +--- + +## Navigation & Discoverability + +### Current Navigation Structure + +``` +Sidebar Navigation (collapsed view): +┌─────────────────────────┐ +│ IRT Bank Soal Admin │ +├─────────────────────────┤ +│ 📊 Dashboard │ ← Always first +│ 📝 Questions │ ← What is this? +│ 📥 Import Questions │ ← Separate from Questions? +│ 🤖 AI Generator │ ← Is this part of Questions? +│ 📋 Exams │ ← Tryout = Exam? +│ 📈 Reports │ +│ ⚙️ Settings │ ← Hierarchy buried here +│ ─────────────────────── │ +│ 🚪 Logout │ +└─────────────────────────┘ +``` + +### Label Analysis + +| Current Label | User Interpretation | Issue | +|---------------|---------------------|-------| +| Questions | "Where I view questions?" | ✅ Clear | +| Import Questions | "Is this separate from Questions?" | ⚠️ Unclear relationship | +| AI Generator | "What does AI Generate?" | ⚠️ Vague | +| Exams | "Same as Tryout?" | ⚠️ Mismatch with backend term | +| Reports | "Student scores?" | ✅ Clear | +| Settings → Hierarchy | "What is hierarchy?" | 🔴 Wrong place + wrong term | + +### Missing Navigation Features + +| # | Missing Feature | Impact | +|---|-----------------|--------| +| 3.1 | **No breadcrumbs** | Users can't trace their path back | +| 3.2 | **No "back to parent" links** | Deep pages have no escape route | +| 3.3 | **No search/global nav** | Can't jump to specific pages | +| 3.4 | **No recent pages** | Can't quickly return to work in progress | +| 3.5 | **Settings is a catch-all** | Mixes Website management, Hierarchy, Password | + +--- + +## Hierarchy Visibility + +### Current Hierarchy Location + +Hierarchy is located at: **Settings → Data Structure** (`/admin/hierarchy`) + +### Problems with Current Hierarchy Placement + +| # | Issue | Why It Matters | +|---|-------|----------------| +| 4.1 | **Buried 2 levels deep** | First-time users never find it | +| 4.2 | **Label is technical** | "Data Structure" vs "How data connects" | +| 4.3 | **No explanation of the hierarchy concept** | Users don't know Website → Tryout → Questions → Variants | +| 4.4 | **No visual flowchart on Dashboard** | Users should see the big picture immediately | + +### Expected Mental Model + +``` +┌─────────────────────────────────────────────────────────────┐ +│ USER'S EXPECTED FLOW │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 1. Website (where exams are hosted) │ +│ │ │ +│ ▼ │ +│ 2. Tryout/Exam (the test itself) │ +│ │ │ +│ ▼ │ +│ 3. Questions (individual items in the test) │ +│ │ │ +│ ├── Original/Basis Question ──────────────────────┐ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ AI Variant (different version) │ │ +│ │ │ │ +│ └── (repeated for each question slot) │ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Where Users Expect Hierarchy Info + +| Location | User Expectation | +|----------|------------------| +| **Dashboard** | "Show me the big picture" - visual overview | +| **First-time tooltip** | "Here's how things connect" | +| **Help/Docs** | "Explain the data model" | +| **Settings sidebar** | ❌ Too late - user already lost | + +--- + +## Issue Summary & Priority Matrix + +### Priority Matrix + +``` + │ High Value │ Low Value │ +────────────────────┼──────────────┼──────────────┤ +High Effort │ [A] Refactor │ [B] Nice to │ + │ Navigation │ have │ +────────────────────┼──────────────┼──────────────┤ +Low Effort │ [C] Quick │ [D] Ignore │ + │ Wins │ │ +────────────────────┼──────────────┼──────────────┤ +``` + +### Cell [A] - High Value, High Effort (Do First) + +| Issue ID | Description | Notes | +|----------|-------------|-------| +| P1 | **Add Dashboard onboarding section** | Explain the system + show hierarchy flow | +| P2 | **Move Hierarchy to prominent location** | Dashboard or separate nav item | +| P3 | **Redesign navigation labels** | Human-friendly, consistent terminology | +| P4 | **Add breadcrumbs** | Across all pages | + +### Cell [C] - High Value, Low Effort (Quick Wins) + +| Issue ID | Description | Effort | +|----------|-------------|--------| +| Q1 | Add product logo to login page | 15 min | +| Q2 | Improve dashboard welcome message | 10 min | +| Q3 | Add "How it works" section to Dashboard | 30 min | +| Q4 | Rename "Data Structure" → "Data Overview" in Settings | 5 min | +| Q5 | Add contextual tooltips to Quick Actions | 20 min | + +### Cell [B] - Low Value, High Effort (Consider Later) + +| Issue ID | Description | +|----------|-------------| +| L1 | Global search across all pages | +| L2 | Recent pages sidebar widget | +| L3 | Full first-time setup wizard | + +### Cell [D] - Low Value, Low Effort (Ignore) + +| Issue ID | Description | +|----------|-------------| +| N1 | Custom "Remember me" tooltip | +| N2 | Login page background gradient (cosmetic only) | + +--- + +## Recommended Improvements + +### Phase 1: Critical Fixes (Same Session) + +#### 1. Login Page Enhancement + +```html + +
+ +

IRT Bank Soal

+

Adaptive Question Bank System

+
+``` + +#### 2. Dashboard - Add "How It Works" Section + +Add this block to dashboard after greeting: + +```html +
+

How Your Exam System Works

+
+
+ 1 + Add Website + Connect your WordPress site +
+
+
+ 2 + Import Questions + Upload your exam questions +
+
+
+ 3 + Generate Variants + AI creates different versions +
+
+
+ 4 + Students Take Tests + Adaptive difficulty adjusts +
+
+ View full data structure → +
+``` + +#### 3. Dashboard - Add "Get Started" for Empty State + +When `tryouts_count == 0`: + +```html +
+

🚀 Welcome to IRT Bank Soal!

+

Get started in 3 simple steps:

+ +
+
+ 1 +

Connect a Website

+

Add your WordPress site to the system

+ Add Website → +
+
+ 2 +

Import Questions

+

Upload questions from Excel or JSON

+ Import Questions → +
+
+ 3 +

Generate Variants

+

Use AI to create question variations

+ Generate Variants → +
+
+
+``` + +### Phase 2: Navigation Improvement (Next Sprint) + +#### 4. Rename Navigation Items + +| Current | Proposed | Reason | +|---------|----------|--------| +| Import Questions | Import from Excel | More specific | +| AI Generator | Generate AI Questions | Action-oriented | +| Settings → Hierarchy | (move to Dashboard) | Too hidden | +| Questions | Question Bank | Clarify scope | + +#### 5. Add Breadcrumbs Component + +```html + +``` + +### Phase 3: Advanced Features (Future) + +#### 6. First-Time Setup Wizard + +Modal that walks new admins through: +1. Website configuration +2. First import +3. Basic settings review + +#### 7. Interactive Hierarchy Diagram + +Replace static hierarchy view with interactive visualization: + +```mermaid +graph LR + A[Website] --> B[Tryout] + B --> C[Questions] + C --> D[Variants] + C --> E[Student Answers] + D --> F[AI Generation] +``` + +--- + +## Appendix: Current vs Proposed Flow + +### Current Flow (Confusing) + +``` +Login + ↓ +Dashboard (counts, no context) + ↓ (guess where to go) +Settings? Questions? Import? (trial & error) + ↓ +Get lost → Leave → Ask for help +``` + +### Proposed Flow (Guided) + +``` +Login + ↓ +Dashboard + ├─ "Here's how it works" (visual flow) + ├─ Quick Stats (with explanations) + ├─ Alerts (with direct action buttons) + └─ Recent Activity + ↓ +Follow guided steps OR jump to specific task + ↓ +Complete task → Return to Dashboard + ↓ +See updated progress +``` + +--- + +## Files to Modify + +| File | Changes Needed | +|------|---------------| +| `app/admin_web.py` | Dashboard content, navigation labels, breadcrumbs | +| `app/admin_web_icons.py` | (No changes needed) | +| `app/templates/` | (Add if using templates) | + +--- + +## Test Checklist + +After implementing changes, verify: + +- [ ] Login page shows product branding +- [ ] Dashboard explains the system for first-time users +- [ ] Empty state shows guided setup +- [ ] Navigation labels are consistent and clear +- [ ] Hierarchy is accessible from Dashboard +- [ ] Breadcrumbs appear on all sub-pages +- [ ] Quick Actions have explanatory tooltips +- [ ] User can complete first import without help + +--- + +*End of Audit Report* diff --git a/app/routers/admin.py b/app/routers/admin.py deleted file mode 100644 index 90ea841..0000000 --- a/app/routers/admin.py +++ /dev/null @@ -1,227 +0,0 @@ -""" -Admin API router for custom admin actions. - -Provides admin-specific endpoints for triggering calibration, -toggling AI generation, and resetting normalization. -""" - -from typing import Any, Dict - -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.auth import AuthContext, get_auth_context, require_website_auth -from app.core.config import get_settings -from app.database import get_db -from app.models import Tryout, TryoutStats -from app.services.irt_calibration import ( - calibrate_all, - CALIBRATION_SAMPLE_THRESHOLD, -) - -router = APIRouter(prefix="/admin", tags=["admin"]) -settings = get_settings() - - -@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 admin_trigger_calibration( - tryout_id: str, - db: AsyncSession = Depends(get_db), - auth: AuthContext = Depends(get_auth_context), -) -> Dict[str, Any]: - """ - 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"}) - - # Verify tryout exists - 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 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}/toggle-ai-generation", - summary="Toggle AI generation", - description="Toggle AI question generation for a tryout.", -) -async def admin_toggle_ai_generation( - tryout_id: str, - db: AsyncSession = Depends(get_db), - auth: AuthContext = Depends(get_auth_context), -) -> Dict[str, Any]: - """ - Toggle AI generation for a tryout. - - Updates Tryout.AI_generation_enabled field. - - Args: - tryout_id: Tryout identifier - db: Database session - website_id: Website ID from header - - Returns: - Updated AI generation status - - Raises: - HTTPException: If tryout not found - """ - website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) - - # Get 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 HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Tryout {tryout_id} not found for website {website_id}", - ) - - # Toggle AI generation - tryout.ai_generation_enabled = not tryout.ai_generation_enabled - await db.commit() - await db.refresh(tryout) - - status = "enabled" if tryout.ai_generation_enabled else "disabled" - return { - "tryout_id": tryout_id, - "ai_generation_enabled": tryout.ai_generation_enabled, - "message": f"AI generation {status} for tryout {tryout_id}", - } - - -@router.post( - "/{tryout_id}/reset-normalization", - summary="Reset normalization", - description="Reset normalization to static values and clear incremental stats.", -) -async def admin_reset_normalization( - tryout_id: str, - db: AsyncSession = Depends(get_db), - auth: AuthContext = Depends(get_auth_context), -) -> Dict[str, Any]: - """ - Reset normalization for a tryout. - - Resets rataan, sb to static values and clears incremental stats. - - Args: - tryout_id: Tryout identifier - db: Database session - website_id: Website ID from header - - Returns: - Reset statistics - - Raises: - HTTPException: If tryout or stats not found - """ - website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) - - # Get tryout 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: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"TryoutStats for {tryout_id} not found for website {website_id}", - ) - - # Get tryout for static values - 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: - # Reset to static values - stats.rataan = tryout.static_rataan - stats.sb = tryout.static_sb - else: - # Reset to default values - stats.rataan = 500.0 - stats.sb = 100.0 - - # Clear incremental stats - old_participant_count = stats.participant_count - stats.participant_count = 0 - stats.total_nm_sum = 0.0 - stats.total_nm_sq_sum = 0.0 - stats.min_nm = None - stats.max_nm = None - stats.last_calculated = None - - await db.commit() - await db.refresh(stats) - - return { - "tryout_id": tryout_id, - "rataan": stats.rataan, - "sb": stats.sb, - "cleared_stats": { - "previous_participant_count": old_participant_count, - }, - "message": f"Normalization reset to static values (rataan={stats.rataan}, sb={stats.sb}). Incremental stats cleared.", - } diff --git a/app/schemas/ai.py b/app/schemas/ai.py deleted file mode 100644 index 5168f21..0000000 --- a/app/schemas/ai.py +++ /dev/null @@ -1,102 +0,0 @@ -""" -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 - - -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 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 - error: Optional[str] = None - cached: bool = False - - -class AISaveRequest(BaseModel): - stem: str = Field(..., description="Question stem") - options: Dict[str, str] = Field( - ..., description="Answer options (A, B, C, D)" - ) - correct: str = Field(..., description="Correct answer (A/B/C/D)") - 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" - ) - 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: - if v.upper() not in ["A", "B", "C", "D"]: - raise ValueError("Correct answer must be A, B, C, or D") - return v.upper() - - @field_validator("options") - @classmethod - def validate_options(cls, v: Dict[str, str]) -> Dict[str, str]: - required_keys = {"A", "B", "C", "D"} - if not required_keys.issubset(set(v.keys())): - raise ValueError("Options must contain keys A, B, C, D") - return v - - -class AISaveResponse(BaseModel): - success: bool = Field(..., description="Whether save was successful") - item_id: Optional[int] = 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 - - @field_validator("correct") - @classmethod - def validate_correct(cls, v: str) -> str: - if v.upper() not in ["A", "B", "C", "D"]: - raise ValueError("Correct answer must be A, B, C, or D") - return v.upper() diff --git a/.env.example b/backend/.env.example similarity index 100% rename from .env.example rename to backend/.env.example diff --git a/Dockerfile b/backend/Dockerfile similarity index 92% rename from Dockerfile rename to backend/Dockerfile index 651bc4d..1f34c76 100644 --- a/Dockerfile +++ b/backend/Dockerfile @@ -15,4 +15,4 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . # Run migrations and start the app -CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"] +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"] diff --git a/alembic.ini b/backend/alembic.ini similarity index 100% rename from alembic.ini rename to backend/alembic.ini diff --git a/alembic/README b/backend/alembic/README similarity index 100% rename from alembic/README rename to backend/alembic/README diff --git a/alembic/env.py b/backend/alembic/env.py similarity index 100% rename from alembic/env.py rename to backend/alembic/env.py diff --git a/alembic/script.py.mako b/backend/alembic/script.py.mako similarity index 100% rename from alembic/script.py.mako rename to backend/alembic/script.py.mako diff --git a/alembic/versions/20260331_000001_initial_schema.py b/backend/alembic/versions/20260331_000001_initial_schema.py similarity index 100% rename from alembic/versions/20260331_000001_initial_schema.py rename to backend/alembic/versions/20260331_000001_initial_schema.py diff --git a/alembic/versions/20260402_000002_tryout_json_snapshots.py b/backend/alembic/versions/20260402_000002_tryout_json_snapshots.py similarity index 100% rename from alembic/versions/20260402_000002_tryout_json_snapshots.py rename to backend/alembic/versions/20260402_000002_tryout_json_snapshots.py diff --git a/alembic/versions/20260404_000003_ai_runs_and_variant_status.py b/backend/alembic/versions/20260404_000003_ai_runs_and_variant_status.py similarity index 100% rename from alembic/versions/20260404_000003_ai_runs_and_variant_status.py rename to backend/alembic/versions/20260404_000003_ai_runs_and_variant_status.py diff --git a/alembic/versions/20260405_000004_report_schedules.py b/backend/alembic/versions/20260405_000004_report_schedules.py similarity index 100% rename from alembic/versions/20260405_000004_report_schedules.py rename to backend/alembic/versions/20260405_000004_report_schedules.py diff --git a/backend/alembic/versions/20260617_000005_session_expires_at.py b/backend/alembic/versions/20260617_000005_session_expires_at.py new file mode 100644 index 0000000..8cfecf9 --- /dev/null +++ b/backend/alembic/versions/20260617_000005_session_expires_at.py @@ -0,0 +1,26 @@ +"""add session expires at + +Revision ID: 20260617_000005 +Revises: 20260405_000004 +Create Date: 2026-06-17 15:00:00 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260617_000005" +down_revision: Union[str, None] = "20260405_000004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("sessions", sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + op.drop_column("sessions", "expires_at") diff --git a/app/__init__.py b/backend/app/__init__.py similarity index 100% rename from app/__init__.py rename to backend/app/__init__.py diff --git a/app/admin.py b/backend/app/admin.py similarity index 100% rename from app/admin.py rename to backend/app/admin.py diff --git a/app/admin_web.py b/backend/app/admin_web.py similarity index 73% rename from app/admin_web.py rename to backend/app/admin_web.py index ebd97db..a27ee97 100644 --- a/app/admin_web.py +++ b/backend/app/admin_web.py @@ -14,7 +14,7 @@ from datetime import datetime, timezone from html import escape, unescape from typing import Any -import aioredis +import redis.asyncio as aioredis from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile from sqlalchemy import Integer, func, or_, select from sqlalchemy.exc import IntegrityError @@ -37,6 +37,7 @@ from app.models import ( Tryout, TryoutImportSnapshot, TryoutSnapshotQuestion, + TryoutStats, UserAnswer, Website, ) @@ -133,37 +134,40 @@ def _dashboard_redirect() -> RedirectResponse: # Organized by workflow: Dashboard > Questions > Exams > Reports > Settings ADMIN_NAV_ITEMS = ( - # Main navigation groups + # Dashboard ("Dashboard", "/admin/dashboard", ("/admin/dashboard",)), - # Questions section + # Questions - global question bank ( "Questions", "/admin/questions", ( "/admin/questions", - "/admin/templates", - "/admin/question-quality", + "/admin/tryout/*/questions/*/workspace", ), ), + # Tryouts - hierarchy tree with drill-down ( - "Import Questions", - "/admin/tryout-import", + "Tryouts", + "/admin/tryouts", ( + "/admin/tryouts", + "/admin/tryout/*/attempts", + "/admin/tryout/*/questions", + "/admin/tryout/*/normalization", + "/admin/import-tryout", + ), + ), + # Import - tryout-level import + ( + "Import", + "/admin/import-tryout", + ( + "/admin/import-tryout", "/admin/tryout-import", "/admin/snapshot-questions", ), ), - # Exams section - ( - "Exams", - "/admin/exams", - ( - "/admin/exams", - "/admin/student-attempts", - "/admin/normalization", - ), - ), - # Reports section + # Reports ( "Reports", "/admin/reports", @@ -174,14 +178,13 @@ ADMIN_NAV_ITEMS = ( "/admin/session-overview", ), ), - # Settings section + # Settings ( "Settings", "/admin/settings", ( "/admin/settings", "/admin/websites", - "/admin/hierarchy", "/admin/password", ), ), @@ -191,16 +194,54 @@ ADMIN_NAV_ITEMS = ( # URL mapping for backwards compatibility (old URLs -> new URLs) LEGACY_URL_MAP = { - "/admin/basis-items": "/admin/ai-generation", - "/admin/calibration-status": "/admin/question-quality", + # Exams renamed to Tryouts + "/admin/exams": "/admin/tryouts", + "/admin/student-attempts": "/admin/tryouts", + # Legacy AI/question routes + "/admin/questions": "/admin/questions", # Keep as-is (global questions) + "/admin/basis-items": "/admin/tryouts", + "/admin/templates": "/admin/tryouts", + "/admin/question-quality": "/admin/tryouts", + "/admin/hierarchy": "/admin/tryouts", + "/admin/ai-generation": "/admin/tryouts", + # Reports + "/admin/calibration-status": "/admin/reports", "/admin/item-statistics": "/admin/reports", - "/admin/session-overview": "/admin/exams", + "/admin/session-overview": "/admin/reports", } # Navigation section icons (using SVG for consistent professional look) NAV_ICONS = NAV_ICONS_SVG +def _breadcrumbs( + request: Request, items: list[tuple[str, str | None]] | None = None +) -> str: + """Generate breadcrumb navigation HTML. + + Args: + request: The FastAPI request object + items: List of (label, url) tuples. URL can be None for current page. + If None, returns empty string. + + Returns: + HTML string for breadcrumbs, or empty string if no items. + """ + if not items: + return "" + + crumbs = ['") + return "".join(crumbs) + + def _replace_emojis_with_icons(html: str) -> str: """Replace emoji characters with SVG icons in HTML content.""" for emoji, icon_svg in EMOJI_TO_ICON.items(): @@ -215,14 +256,40 @@ def _is_admin_nav_active( nav_path: str, child_prefixes: tuple[str, ...], ) -> bool: + """Check if the current path matches the nav item or its children. + + Supports wildcard patterns like "/admin/tryout/*/questions" where * matches any single path segment. + """ if current_path == nav_path: return True for prefix in child_prefixes: - if current_path == prefix or current_path.startswith(f"{prefix}/"): + if _path_matches_pattern(current_path, prefix): return True return False +def _path_matches_pattern(path: str, pattern: str) -> bool: + """Match a path against a pattern that may contain wildcards (*). + + Wildcards match a single path segment. For example: + - "/admin/tryout/*/questions" matches "/admin/tryout/123/questions" + - "/admin/tryout/*/questions/*/workspace" matches "/admin/tryout/123/questions/456/workspace" + """ + path_parts = path.strip("/").split("/") + pattern_parts = pattern.strip("/").split("/") + + if len(path_parts) < len(pattern_parts): + return False + + for i, part in enumerate(pattern_parts): + if part == "*": + continue # Wildcard matches any segment + if i >= len(path_parts) or path_parts[i] != part: + return False + + return True + + def _admin_nav_links(request: Request) -> str: """Render human-friendly navigation links with icons.""" current_path = request.url.path @@ -267,32 +334,55 @@ def _render_auth_page( {escape(title)} +
+
+ +

Adaptive Question Bank System

+
+
+

{escape(title)}

+

{escape(subtitle)}

+ {body.replace("__REMEMBER_ME_CHECKED__", remember_me_checked)} + +
+
-
-

{escape(title)}

-

{escape(subtitle)}

- {body.replace("__REMEMBER_ME_CHECKED__", remember_me_checked)} -
""" csrf_token = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24) @@ -316,7 +406,7 @@ def _render_auth_page( def _render_admin_page( - request: Request, title: str, page_title: str, body: str + request: Request, title: str, page_title: str, body: str, breadcrumbs: str = "" ) -> HTMLResponse: sidebar_links = _admin_nav_links(request) html = f""" @@ -367,6 +457,38 @@ def _render_admin_page( .dashboard-hero h1 {{ font-size: 28px; margin: 0 0 8px; color: #0f172a; }} .dashboard-subtitle {{ color: #64748b; font-size: 16px; margin: 0; }} + /* How It Works Section */ + .how-it-works {{ background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); border-radius: 16px; padding: 24px; margin-bottom: 24px; border: 1px solid #bfdbfe; }} + .how-it-works-title {{ font-size: 18px; margin: 0 0 20px; color: #1e40af; font-weight: 600; }} + .flow-steps {{ display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; justify-content: center; }} + .flow-step {{ display: flex; flex-direction: column; align-items: center; text-align: center; min-width: 120px; }} + .step-num {{ display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #3b82f6; color: #fff; font-size: 14px; font-weight: 700; margin-bottom: 8px; }} + .step-title {{ font-size: 14px; font-weight: 600; color: #1e40af; margin-bottom: 4px; }} + .step-desc {{ font-size: 12px; color: #3b82f6; }} + .step-arrow {{ font-size: 24px; color: #93c5fd; align-self: center; margin-top: 4px; }} + .flow-link {{ display: inline-block; margin-top: 16px; font-size: 14px; font-weight: 500; color: #2563eb; text-align: center; width: 100%; }} + .flow-link:hover {{ text-decoration: underline; }} + + /* Getting Started / Empty State */ + .getting-started {{ background: #fff; border-radius: 16px; padding: 32px; margin-bottom: 24px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); border: 1px solid #e2e8f0; }} + .getting-started h2 {{ font-size: 24px; margin: 0 0 8px; color: #0f172a; }} + .getting-started-intro {{ color: #64748b; margin: 0 0 24px; }} + .steps-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; }} + .step-card {{ background: #f8fafc; border-radius: 12px; padding: 20px; border: 1px solid #e2e8f0; position: relative; }} + .step-card .num {{ position: absolute; top: 12px; right: 12px; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; background: #e2e8f0; color: #475569; font-size: 12px; font-weight: 700; }} + .step-card h3 {{ font-size: 16px; margin: 0 0 8px; color: #0f172a; }} + .step-card p {{ font-size: 14px; color: #64748b; margin: 0 0 16px; }} + .btn {{ display: inline-block; padding: 10px 16px; border-radius: 8px; font-size: 14px; font-weight: 600; text-decoration: none; transition: all 0.2s; }} + .btn-primary {{ background: #3b82f6; color: #fff; }} + .btn-primary:hover {{ background: #2563eb; text-decoration: none; }} + + /* Breadcrumbs */ + .breadcrumbs {{ display: flex; align-items: center; gap: 8px; margin-bottom: 24px; font-size: 14px; color: #64748b; }} + .breadcrumbs a {{ color: #3b82f6; text-decoration: none; }} + .breadcrumbs a:hover {{ text-decoration: underline; }} + .breadcrumbs .sep {{ color: #cbd5e1; }} + .breadcrumbs .current {{ color: #475569; font-weight: 500; }} + .section-title {{ font-size: 16px; color: #475569; margin: 32px 0 16px!important; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }} .section-title:first-of-type {{ margin-top: 0; }} .section-title svg {{ width: 32px; height: 32px; margin-bottom: -3px; }} @@ -447,7 +569,7 @@ def _render_admin_page( .field-grid .wide {{ grid-column: 1 / -1; }} .tab-panel {{ margin-top: 8px; }} .toolbar {{ display: flex; align-items: end; gap: 12px; flex-wrap: wrap; margin: 12px 0 16px; }} - .toolbar label {{ min-width: 150px; margin-top: 0; }} + .toolbar label {{ min-width: 150px; margin-top: 0; margin-bottom: 0;}} .toolbar input, .toolbar select {{ min-width: 150px; }} .table-wrap {{ width: 100%; overflow-x: auto; }} .table-wrap table {{ min-width: 860px; }} @@ -765,6 +887,7 @@ def _render_admin_page( {EMOJI_TO_ICON["ℹ️"]}
+ {breadcrumbs}

{escape(page_title)}

{_replace_emojis_with_icons(body)} @@ -1133,8 +1256,6 @@ def _snapshot_questions_body( """ - - async def _recent_generation_runs( db: AsyncSession, limit: int = 20 ) -> list[AIGenerationRun]: @@ -2312,12 +2433,77 @@ async def dashboard_view(request: Request, db: AsyncSession = Depends(get_db)): else: greeting = "Good Evening" + # Build "How It Works" section + how_it_works_html = f""" +
+

How Your Exam System Works

+
+
+ 1 + Add Website + Connect your WordPress site +
+
+
+ 2 + Import Questions + Upload your exam questions +
+
+
+ 3 + Generate Variants + AI creates different versions +
+
+
+ 4 + Students Take Tests + Adaptive difficulty adjusts +
+
+ View full data structure → +
+ """ + + # Build empty state "Get Started" section + empty_state_html = "" + if tryouts_count == 0 and items_count == 0: + empty_state_html = f""" +
+

🚀 Welcome to IRT Bank Soal!

+

Get started in 3 simple steps:

+
+
+ 1 +

Connect a Website

+

Add your WordPress site to host exams

+ Add Website → +
+
+ 2 +

Import Questions

+

Upload questions from Excel or JSON

+ Import Questions → +
+
+ 3 +

Generate Variants

+

Use AI to create question variations

+ Generate Variants → +
+
+
+ """ + body = f"""

{greeting}, {escape(admin.username)}! 👋

Here's what's happening with your exam system today.

+ {how_it_works_html} + {empty_state_html} {alerts_html}

📊 System Overview

@@ -2664,7 +2850,15 @@ async def questions_view( """ - return _render_admin_page(request, "Questions", "📝 Question Bank", body) + return _render_admin_page( + request, + "Question Bank", + "📝 Question Bank", + body, + breadcrumbs=_breadcrumbs( + request, [("Exams", "/admin/exams"), ("Question Bank", None)] + ), + ) @router.get("/questions/{item_id}", include_in_schema=False) @@ -3019,226 +3213,290 @@ async def question_quality_view(request: Request, db: AsyncSession = Depends(get @router.get("/exams", include_in_schema=False) -async def exams_view(request: Request, db: AsyncSession = Depends(get_db)): - """Exams overview - list all exams with human-friendly display and visual cards.""" +async def exams_view(request: Request): + """Redirect to /admin/tryouts for backwards compatibility.""" + return RedirectResponse(url="/admin/tryouts", status_code=HTTP_303_SEE_OTHER) + + +@router.get("/student-attempts", include_in_schema=False) +async def student_attempts_view( + request: Request, + db: AsyncSession = Depends(get_db), + tryout_id: str = "", + status: str = "", + page: int = 1, +): + """Student Attempts - shows all student attempts with scores grouped by exam.""" admin = await _current_admin(request) if not admin: return _login_redirect() - # Get all tryouts with stats - result = await db.execute( - select(Tryout) - .options(selectinload(Tryout.stats)) - .order_by(Tryout.created_at.desc()) - ) - tryouts = list(result.scalars().all()) + # Get all tryouts for filter dropdown + tryouts_result = await db.execute(select(Tryout).order_by(Tryout.created_at.desc())) + tryouts = list(tryouts_result.scalars().all()) - # Get summary stats - total_tryouts = len(tryouts) - total_participants = sum( - s.stats.participant_count if s.stats else 0 for s in tryouts - ) - total_items_result = await db.execute(select(func.count(Item.id))) - total_items = total_items_result.scalar() or 0 + # Build sessions query + sessions_query = select(Session).options(selectinload(Session.user)) - # Build exam cards - exam_cards = [] - for tryout in tryouts: - stats = tryout.stats - participant_count = stats.participant_count if stats else 0 - avg_nm = stats.rataan if stats else None - std_nm = stats.std if stats else None - min_nm = stats.minimum if stats else None - max_nm = stats.maximum if stats else None + if tryout_id: + sessions_query = sessions_query.where(Session.tryout_id == tryout_id) - # Get item count - items_result = await db.execute( - select(func.count(Item.id)).where( - Item.tryout_id == tryout.tryout_id, Item.website_id == tryout.website_id - ) + if status == "completed": + sessions_query = sessions_query.where(Session.is_completed == True) + elif status == "in_progress": + sessions_query = sessions_query.where(Session.is_completed == False) + + sessions_query = sessions_query.order_by(Session.created_at.desc()) + + # Pagination + page_size = 50 + offset = (page - 1) * page_size + sessions_query = sessions_query.offset(offset).limit(page_size) + + result = await db.execute(sessions_query) + sessions = list(result.scalars().all()) + + # Get total count for pagination + count_query = select(func.count(Session.id)) + if tryout_id: + count_query = count_query.where(Session.tryout_id == tryout_id) + if status == "completed": + count_query = count_query.where(Session.is_completed == True) + elif status == "in_progress": + count_query = count_query.where(Session.is_completed == False) + total_count = await db.scalar(count_query) or 0 + total_pages = max(1, (total_count + page_size - 1) // page_size) + + # Get tryout stats for selected tryout + selected_tryout_stats = None + if tryout_id: + stats_result = await db.execute( + select(TryoutStats).where(TryoutStats.tryout_id == tryout_id) ) - item_count = items_result.scalar() or 0 + selected_tryout_stats = stats_result.scalar_one_or_none() - # Get calibrated items count - calibrated_result = await db.execute( - select(func.count(Item.id)).where( - Item.tryout_id == tryout.tryout_id, - Item.website_id == tryout.website_id, - Item.calibrated == True, - ) - ) - calibrated_count = calibrated_result.scalar() or 0 + # Build exam selector HTML + exam_options = '' + for t in tryouts: + selected = "selected" if t.tryout_id == tryout_id else "" + exam_options += f'' - # Scoring mode badge with colors - mode_colors = { - "ctt": ("CTT", "background: #dbeafe; color: #1e40af;", "📐"), - "irt": ("IRT", "background: #fce7f3; color: #9d174d;", "📈"), - "hybrid": ("Hybrid", "background: #fef3c7; color: #92400e;", "🔄"), - } - mode_info = mode_colors.get( - tryout.scoring_mode, (tryout.scoring_mode.upper(), "", "📋") - ) - mode_badge = f'{mode_info[2]} {mode_info[0]}' - - # Calibration progress - calibration_pct = (calibrated_count / item_count * 100) if item_count > 0 else 0 - calibration_color = ( - "#10b981" - if calibration_pct >= 90 - else "#f59e0b" - if calibration_pct >= 50 - else "#ef4444" - ) - - exam_cards.append(f""" -
-
-

{escape(tryout.name or tryout.tryout_id)}

- {mode_badge} -
-
ID: {escape(tryout.tryout_id)}
- -
-
- 📝 -
- {item_count} - Questions -
-
-
- 👥 -
- {participant_count} - Students -
-
-
- 📊 -
- {"N/A" if avg_nm is None else f"{avg_nm:.0f}"} - Avg Score -
-
-
- -
-
- Calibration - {calibrated_count}/{item_count} ({calibration_pct:.0f}%) -
-
-
-
-
- -
-
- Score Range: - {"N/A" if min_nm is None else f"{min_nm:.0f}"} - {"N/A" if max_nm is None else f"{max_nm:.0f}"} -
-
- Std Dev: - {"N/A" if std_nm is None else f"{std_nm:.1f}"} -
-
- - -
- """) - - # Summary cards - summary_html = f""" -
-
- 📋 -
- {total_tryouts} - Total Exams -
-
-
- 👥 -
- {total_participants} - Total Students -
-
-
- 📝 -
- {total_items} - Total Questions -
-
-
+ exam_selector = f""" + """ + status_options = f""" + + """ + + # Summary stats for selected tryout + summary_html = "" + if selected_tryout_stats: + completed_count = ( + await db.scalar( + select(func.count(Session.id)).where( + Session.tryout_id == tryout_id, Session.is_completed == True + ) + ) + or 0 + ) + avg_nm_result = await db.execute( + select(func.avg(Session.NM)).where( + Session.tryout_id == tryout_id, + Session.is_completed == True, + Session.NM.isnot(None), + ) + ) + avg_nm = avg_nm_result.scalar() or 0 + avg_nn_result = await db.execute( + select(func.avg(Session.NN)).where( + Session.tryout_id == tryout_id, + Session.is_completed == True, + Session.NN.isnot(None), + ) + ) + avg_nn = avg_nn_result.scalar() or 0 + completion_rate = ( + (completed_count / selected_tryout_stats.participant_count * 100) + if selected_tryout_stats.participant_count > 0 + else 0 + ) + + summary_html = f""" +
+
+ 👥 +
+ {completed_count} + Completed +
+
+
+ 📊 +
+ {avg_nm:.0f} + Avg NM Score +
+
+
+ 📈 +
+ {avg_nn:.0f} + Avg NN Score +
+
+
+ +
+ {completion_rate:.0f}% + Completion Rate +
+
+
+ """ + elif tryout_id: + summary_html = ( + '
No stats available for this exam.
' + ) + + # Build sessions table + if sessions: + session_rows = [] + for session in sessions: + user_name = session.user.wp_user_id if session.user else session.wp_user_id + status_badge = ( + '✓ Completed' + if session.is_completed + else '⟳ In Progress' + ) + nm_display = f"{session.NM:.0f}" if session.NM is not None else "N/A" + nn_display = f"{session.NN:.0f}" if session.NN is not None else "N/A" + theta_display = ( + f"{session.theta:.2f}" if session.theta is not None else "N/A" + ) + time_display = ( + f"{(session.end_time - session.start_time).seconds // 60} min" + if session.end_time and session.start_time + else "N/A" + ) + + session_rows.append(f""" + + {escape(user_name)} + {escape(session.tryout_id)} + {status_badge} + {session.total_benar} + {nm_display} + {nn_display} + {theta_display} + {time_display} + {escape(str(session.start_time)[:19] if session.start_time else "")} + + """) + + sessions_table = f""" + + + + + + + + + + + + + + + + {"".join(session_rows)} + +
StudentExamStatusCorrectNM ScoreNN ScoreThetaDurationStarted
+ """ + else: + sessions_table = ( + '
No student attempts found.
' + ) + + # Pagination + pagination_html = "" + if total_pages > 1: + page_links = [] + for p in range(1, total_pages + 1): + active_class = 'class="active"' if p == page else "" + page_links.append( + f'{p}' + ) + pagination_html = f""" + + """ + body = f""" -

View and manage your exams. Each exam shows student statistics and calibration progress.

+

View and analyze student attempts across all exams.

+ +
+ + {exam_selector} + + {status_options} + +
{summary_html} -

All Exams

- -
- {"".join(exam_cards) if exam_cards else '
No exams yet. Import questions to create your first exam.
'} -
+

Student Attempts

+ {sessions_table} + {pagination_html} """ - return _render_admin_page(request, "Exams", "📋 Exams", body) + return _render_admin_page( + request, + "Student Attempts", + "👥 Student Attempts", + body, + ) @router.get("/reports", include_in_schema=False) @@ -3388,7 +3646,12 @@ async def reports_view(request: Request, db: AsyncSession = Depends(get_db)): """ - return _render_admin_page(request, "Reports", "📈 Reports", body) + return _render_admin_page( + request, + "Reports", + "📈 Reports", + body, + ) @router.get("/settings", include_in_schema=False) @@ -3515,9 +3778,1093 @@ async def settings_view(request: Request, db: AsyncSession = Depends(get_db)): """ - return _render_admin_page(request, "Settings", "⚙️ Settings", body) + return _render_admin_page( + request, + "Settings", + "⚙️ Settings", + body, + ) +# ============================================================ +# TRYOUT-SCOPED ROUTES (new hierarchy-based URLs) +# ============================================================ + + +@router.get("/tryouts", include_in_schema=False) +async def tryouts_view(request: Request, db: AsyncSession = Depends(get_db)): + """Tryouts overview - tree structure showing websites > tryouts with stats.""" + admin = await _current_admin(request) + if not admin: + return _login_redirect() + + # Get all websites with their tryouts + websites_result = await db.execute(select(Website).order_by(Website.site_name)) + websites = list(websites_result.scalars().all()) + + # Get all tryouts with stats, grouped by website + tryouts_result = await db.execute( + select(Tryout) + .options(selectinload(Tryout.stats)) + .order_by(Tryout.created_at.desc()) + ) + all_tryouts = list(tryouts_result.scalars().all()) + + # Build tree HTML + tree_html = [] + for website in websites: + website_tryouts = [t for t in all_tryouts if t.website_id == website.id] + + # Build tryout cards for this website + tryout_cards = [] + for tryout in website_tryouts: + stats = tryout.stats + participant_count = stats.participant_count if stats else 0 + avg_nm = stats.rataan if stats else None + avg_nn = stats.std if stats else None + + # Get item count and calibration + items_result = await db.execute( + select(func.count(Item.id)).where( + Item.tryout_id == tryout.tryout_id, + Item.website_id == tryout.website_id, + ) + ) + item_count = items_result.scalar() or 0 + + calibrated_result = await db.execute( + select(func.count(Item.id)).where( + Item.tryout_id == tryout.tryout_id, + Item.website_id == tryout.website_id, + Item.calibrated == True, + ) + ) + calibrated_count = calibrated_result.scalar() or 0 + calibration_pct = ( + (calibrated_count / item_count * 100) if item_count > 0 else 0 + ) + + # Calibration status indicator + if calibration_pct >= 90: + status_dot = "✓" + status_class = "status-ready" + elif calibration_pct >= 50: + status_dot = "●" + status_class = "status-partial" + else: + status_dot = "○" + status_class = "status-needs-data" + + # Scoring mode badge + mode_colors = { + "ctt": ("CTT", "#dbeafe", "#1e40af"), + "irt": ("IRT", "#fce7f3", "#9d174d"), + "hybrid": ("Hybrid", "#fef3c7", "#92400e"), + } + mode_info = mode_colors.get( + tryout.scoring_mode, (tryout.scoring_mode.upper(), "#e2e8f0", "#475569") + ) + + tryout_cards.append(f""" +
+
+ + {escape(tryout.tryout_id)} + - {escape(tryout.name or "Untitled")} + {status_dot} +
+ +
+ """) + + if website_tryouts: + tree_html.append(f""" +
+
+ 🌐 + {escape(website.site_name)} + ({len(website_tryouts)} tryouts) +
+
+ {"".join(tryout_cards)} +
+
+ """) + + body = f""" +
+
+

Browse tryouts organized by website. Click to expand and see stats.

+
+ + + Import Tryout + +
+ +
+ {"".join(tree_html) if tree_html else '
No tryouts yet. Import a tryout to get started.
'} +
+ + + + + """ + + return _render_admin_page( + request, + "Tryouts", + "📋 Tryouts", + body, + ) + + +@router.get("/tryout/{tryout_id}/questions", include_in_schema=False) +async def tryout_questions_view( + request: Request, + tryout_id: int, + db: AsyncSession = Depends(get_db), + page: int = 1, +): + """View original questions with collapsible variant rows in a specific tryout.""" + admin = await _current_admin(request) + if not admin: + return _login_redirect() + + # Get the tryout + tryout_result = await db.execute(select(Tryout).where(Tryout.id == tryout_id)) + tryout = tryout_result.scalar_one_or_none() + if not tryout: + raise HTTPException(status_code=404, detail="Tryout not found") + + # Get only ORIGINAL questions (basis_item_id = NULL) + # Include variants relationship to show AI-generated variants + original_items_query = ( + select(Item) + .options(selectinload(Item.variants)) + .where( + Item.tryout_id == tryout.tryout_id, + Item.website_id == tryout.website_id, + Item.basis_item_id.is_(None), # Only original questions + ) + .order_by(Item.slot.asc()) + ) + + # Get total count of original questions + count_result = await db.execute( + select(func.count(Item.id)).where( + Item.tryout_id == tryout.tryout_id, + Item.website_id == tryout.website_id, + Item.basis_item_id.is_(None), + ) + ) + total_original = count_result.scalar() or 0 + + # Get total variant count + variant_count_result = await db.execute( + select(func.count(Item.id)).where( + Item.tryout_id == tryout.tryout_id, + Item.website_id == tryout.website_id, + Item.basis_item_id.isnot(None), + ) + ) + total_variants = variant_count_result.scalar() or 0 + + # Pagination (for original questions only) + per_page = 25 + total_pages = max(1, (total_original + per_page - 1) // per_page) + page = max(1, min(page, total_pages)) + offset = (page - 1) * per_page + + original_items_query = original_items_query.offset(offset).limit(per_page) + result = await db.execute(original_items_query) + original_items = list(result.scalars().all()) + + # Build question rows with collapsible variants + question_rows = [] + for item in original_items: + # Difficulty + p_value = item.ctt_p + if p_value is None: + difficulty_label = "Unknown" + difficulty_class = "difficulty-unknown" + elif p_value > 0.70: + difficulty_label = "Easy" + difficulty_class = "difficulty-easy" + elif p_value >= 0.30: + difficulty_label = "Medium" + difficulty_class = "difficulty-medium" + else: + difficulty_label = "Hard" + difficulty_class = "difficulty-hard" + + stem_preview = escape(_truncate(_html_to_text(item.stem or ""), 100)) + variants = item.variants or [] + variant_count = len(variants) + + # Toggle icon for variants + toggle_icon = ( + f"""""" + if variant_count > 0 + else "" + ) + + question_rows.append(f""" + + + {toggle_icon} + {item.slot} + + + {stem_preview} +
+ {difficulty_label} + | + ID #{item.id} + | + Used {item.calibration_sample_size or 0}x + {f'|{variant_count} variant{"s" if variant_count != 1 else ""}' if variant_count > 0 else ""} +
+ + {escape(item.level or "-")} + + + {"✅ Calibrated" if item.calibrated else "⏳ Needs Data"} + + + + Workspace + + + """) + + # Add variant rows (collapsed by default) + if variant_count > 0: + variant_rows = [] + for variant in variants: + v_p_value = variant.ctt_p + if v_p_value is None: + v_difficulty_label = "Unknown" + v_difficulty_class = "difficulty-unknown" + elif v_p_value > 0.70: + v_difficulty_label = "Easy" + v_difficulty_class = "difficulty-easy" + elif v_p_value >= 0.30: + v_difficulty_label = "Medium" + v_difficulty_class = "difficulty-medium" + else: + v_difficulty_label = "Hard" + v_difficulty_class = "difficulty-hard" + + v_stem_preview = escape( + _truncate(_html_to_text(variant.stem or ""), 80) + ) + v_calibrated_class = ( + "status-approved" if variant.calibrated else "status-draft" + ) + v_calibrated_label = ( + "✅ Calibrated" if variant.calibrated else "⏳ Needs Data" + ) + v_source_icon = "🤖" if variant.generated_by == "ai" else "📝" + v_source_label = "AI" if variant.generated_by == "ai" else "Manual" + + variant_rows.append(f""" + + + + {v_source_icon} + #{variant.id} + + + {v_stem_preview} +
+ {v_difficulty_label} + | + {v_source_label} + {f'|Model: {variant.ai_model.split("/")[-1] if variant.ai_model else "N/A"}' if variant.ai_model else ""} +
+ + {escape(variant.level or "-")} + + {v_calibrated_label} + + + View + + + """) + question_rows.extend(variant_rows) + + # Pagination HTML + pagination_html = "" + if total_pages > 1: + page_links = [] + for p in range(max(1, page - 2), min(total_pages + 1, page + 3)): + active_class = "active" if p == page else "" + page_links.append( + f'{p}' + ) + pagination_html = f""" + + """ + + # Summary stats + summary_html = f""" +
+
+ 📝 +
+ {total_original} + Original Questions +
+
+
+ 🤖 +
+ {total_variants} + AI Variants +
+
+
+ """ + + table_html = f""" +
+ + + + + + + + + + + + { + "".join(question_rows) + if question_rows + else f'' + } + +
#QuestionLevelStatusActions
No original questions in this tryout.
+
+ """ + + body = f""" + + + {summary_html} + {table_html} + {pagination_html} + + + + + """ + + return _render_admin_page( + request, + f"Questions - {tryout.name or tryout.tryout_id}", + "📝 Questions", + body, + breadcrumbs=_breadcrumbs( + request, + [ + ("Tryouts", "/admin/tryouts"), + (tryout.name or tryout.tryout_id, None), + ("Questions", None), + ], + ), + ) + + +@router.get("/tryout/{tryout_id}/attempts", include_in_schema=False) +async def tryout_attempts_view( + request: Request, + tryout_id: int, + db: AsyncSession = Depends(get_db), + status: str = "", + page: int = 1, +): + """View student attempts for a specific tryout.""" + admin = await _current_admin(request) + if not admin: + return _login_redirect() + + # Get the tryout + tryout_result = await db.execute(select(Tryout).where(Tryout.id == tryout_id)) + tryout = tryout_result.scalar_one_or_none() + if not tryout: + raise HTTPException(status_code=404, detail="Tryout not found") + + # Get sessions for this tryout + sessions_query = ( + select(Session) + .options(selectinload(Session.user)) + .where(Session.tryout_id == tryout.tryout_id) + ) + + if status == "completed": + sessions_query = sessions_query.where(Session.is_completed == True) + elif status == "in_progress": + sessions_query = sessions_query.where(Session.is_completed == False) + + sessions_query = sessions_query.order_by(Session.created_at.desc()) + + # Pagination + page_size = 50 + offset = (page - 1) * page_size + + # Get count + count_query = select(func.count(Session.id)).where( + Session.tryout_id == tryout.tryout_id + ) + if status == "completed": + count_query = count_query.where(Session.is_completed == True) + elif status == "in_progress": + count_query = count_query.where(Session.is_completed == False) + total_count = await db.scalar(count_query) or 0 + total_pages = max(1, (total_count + page_size - 1) // page_size) + + sessions_query = sessions_query.offset(offset).limit(page_size) + result = await db.execute(sessions_query) + sessions = list(result.scalars().all()) + + # Summary stats + completed_count = ( + await db.scalar( + select(func.count(Session.id)).where( + Session.tryout_id == tryout.tryout_id, Session.is_completed == True + ) + ) + or 0 + ) + avg_nm_result = await db.execute( + select(func.avg(Session.NM)).where( + Session.tryout_id == tryout.tryout_id, + Session.is_completed == True, + Session.NM.isnot(None), + ) + ) + avg_nm = avg_nm_result.scalar() or 0 + avg_nn_result = await db.execute( + select(func.avg(Session.NN)).where( + Session.tryout_id == tryout.tryout_id, + Session.is_completed == True, + Session.NN.isnot(None), + ) + ) + avg_nn = avg_nn_result.scalar() or 0 + + summary_html = f""" +
+
+ 👥 +
+ {total_count} + Total Attempts +
+
+
+ +
+ {completed_count} + Completed +
+
+
+ 📊 +
+ {avg_nm:.0f} + Avg NM +
+
+
+ 📈 +
+ {avg_nn:.0f} + Avg NN +
+
+
+ """ + + # Build sessions table + if sessions: + session_rows = [] + for session in sessions: + user_name = session.user.wp_user_id if session.user else session.wp_user_id + status_badge = ( + '✓ Completed' + if session.is_completed + else '⟳ In Progress' + ) + nm_display = f"{session.NM:.0f}" if session.NM is not None else "N/A" + nn_display = f"{session.NN:.0f}" if session.NN is not None else "N/A" + theta_display = ( + f"{session.theta:.2f}" if session.theta is not None else "N/A" + ) + time_display = ( + f"{(session.end_time - session.start_time).seconds // 60} min" + if session.end_time and session.start_time + else "N/A" + ) + + session_rows.append(f""" + + {escape(user_name)} + {status_badge} + {session.total_benar} + {nm_display} + {nn_display} + {theta_display} + {time_display} + {escape(str(session.start_time)[:19] if session.start_time else "")} + + """) + + sessions_table = f""" + + + + + + + + + + + + + + + {"".join(session_rows)} + +
StudentStatusCorrectNM ScoreNN ScoreThetaDurationStarted
+ """ + else: + sessions_table = ( + '
No attempts found for this tryout.
' + ) + + # Pagination + pagination_html = "" + if total_pages > 1: + page_links = [] + for p in range(1, total_pages + 1): + active_class = 'class="active"' if p == page else "" + page_links.append( + f'{p}' + ) + pagination_html = f'' + + status_options = f""" + + """ + + body = f""" + + +
+ + {status_options} +
+ + {summary_html} + +

Student Attempts

+ {sessions_table} + {pagination_html} + + + """ + + return _render_admin_page( + request, + f"Attempts - {tryout.name or tryout.tryout_id}", + "👥 Attempts", + body, + breadcrumbs=_breadcrumbs( + request, + [ + ("Tryouts", "/admin/tryouts"), + (tryout.name or tryout.tryout_id, None), + ("Attempts", None), + ], + ), + ) + + +@router.get("/tryout/{tryout_id}/normalization", include_in_schema=False) +async def tryout_normalization_view( + request: Request, + tryout_id: int, + db: AsyncSession = Depends(get_db), +): + """Normalization settings for a specific tryout.""" + admin = await _current_admin(request) + if not admin: + return _login_redirect() + + # Get the tryout + tryout_result = await db.execute(select(Tryout).where(Tryout.id == tryout_id)) + tryout = tryout_result.scalar_one_or_none() + if not tryout: + raise HTTPException(status_code=404, detail="Tryout not found") + + # Get tryout stats + stats_result = await db.execute( + select(TryoutStats).where(TryoutStats.tryout_id == tryout.tryout_id) + ) + stats = stats_result.scalar_one_or_none() + + # Current values + current_rataan = stats.rataan if stats else 500 + current_sb = stats.std if stats else 100 + current_minimum = stats.minimum if stats else 0 + current_maximum = stats.maximum if stats else 1000 + + body = f""" + + +
+
+

Current Statistics

+
+
+ Participants: + {stats.participant_count if stats else 0} +
+
+ Current Mean (NM): + {current_rataan:.2f} +
+
+ Current Std Dev: + {current_sb:.2f} +
+
+ Score Range: + {current_minimum:.0f} - {current_maximum:.0f} +
+
+
+ +
+

Normalization Formula

+
+ NN = 500 + 100 × ((NM - Rataan) / SB) +
+

+ Where NM is the raw score, Rataan is the target mean, + and SB is the target standard deviation. +

+
+ +
+

Target Parameters

+
+
+
+ + +
+
+ + +
+
+
+ + Recalculate Scores +
+
+
+
+ + + """ + + return _render_admin_page( + request, + f"Normalization - {tryout.name or tryout.tryout_id}", + "📐 Normalization", + body, + breadcrumbs=_breadcrumbs( + request, + [ + ("Tryouts", "/admin/tryouts"), + (tryout.name or tryout.tryout_id, None), + ("Normalization", None), + ], + ), + ) + + +@router.get("/import-tryout", include_in_schema=False) +async def import_tryout_view(request: Request, db: AsyncSession = Depends(get_db)): + """Import tryout page - import tryout JSON files.""" + admin = await _current_admin(request) + if not admin: + return _login_redirect() + + # Get websites for selection + websites_result = await db.execute(select(Website).order_by(Website.site_name)) + websites = list(websites_result.scalars().all()) + + website_options = "".join( + f'' + for site in websites + ) + + body = f""" + + +
+
+
+ + +
+ +
+ + +

Upload a tryout JSON file

+
+ +
+ + +
+ +
+ +
+
+ +
+

Import Format

+

The JSON file should contain:

+
    +
  • tryout_id - Unique tryout identifier
  • +
  • name - Tryout name/title
  • +
  • questions - Array of question objects
  • +
+
+
+ + + """ + + return _render_admin_page( + request, + "Import Tryout", + "📥 Import Tryout", + body, + breadcrumbs=_breadcrumbs( + request, + [("Tryouts", "/admin/tryouts"), ("Import", None)], + ), + ) # ============================================================ @@ -3533,7 +4880,15 @@ async def hierarchy_view(request: Request, db: AsyncSession = Depends(get_db)): context = await _load_hierarchy_context(db) body = _hierarchy_view_body(context) - return _render_admin_page(request, "Data Hierarchy", "Data Hierarchy", body) + return _render_admin_page( + request, + "Data Overview", + "📊 Data Overview", + body, + breadcrumbs=_breadcrumbs( + request, [("Exams", "/admin/exams"), ("Data Overview", None)] + ), + ) @router.get("/websites", include_in_schema=False) @@ -4574,8 +5929,6 @@ def _ai_generate_tab( """ - - def _ai_runs_tab( item: Item, generation_runs: list[AIGenerationRun], @@ -4800,7 +6153,11 @@ def _ai_variant_detail_body(variant: Item, basis_item: Item | None) -> str: f"#{basis_item.id} | Tryout {escape(str(basis_item.tryout_id))} | " f"Slot {basis_item.slot} | {escape(_truncate(_html_to_text(basis_item.stem), 160))}" ) - review_url = f"/admin/questions/{variant.basis_item_id}/generate?tab=review" if variant.basis_item_id else "/admin/basis-items" + review_url = ( + f"/admin/questions/{variant.basis_item_id}/generate?tab=review" + if variant.basis_item_id + else "/admin/basis-items" + ) if variant.generation_run_id: review_url = f"{review_url}&run_id={variant.generation_run_id}" @@ -4846,10 +6203,6 @@ def _ai_variant_detail_body(variant: Item, basis_item: Item | None) -> str: """ - - - - @router.get("/questions/{item_id}/generate") async def question_generate_view( request: Request, @@ -4870,7 +6223,7 @@ async def question_generate_view( return RedirectResponse(url="/admin/questions", status_code=HTTP_303_SEE_OTHER) stats = await get_ai_stats(db) - + # Fetch runs and variants specific to this item runs_result = await db.execute( select(AIGenerationRun) @@ -4889,7 +6242,7 @@ async def question_generate_view( stmt = stmt.where(Item.level == level) if run_id and run_id.isdigit(): stmt = stmt.where(Item.generation_run_id == int(run_id)) - + stmt = stmt.order_by(Item.created_at.desc()) variants_result = await db.execute(stmt) generated_variants = list(variants_result.scalars().all()) @@ -4938,29 +6291,48 @@ async def question_generate_submit( return RedirectResponse(url="/admin/questions", status_code=HTTP_303_SEE_OTHER) if not settings.OPENROUTER_API_KEY: - return RedirectResponse(url=f"/admin/questions/{item.id}/generate?error=API key missing", status_code=HTTP_303_SEE_OTHER) + return RedirectResponse( + url=f"/admin/questions/{item.id}/generate?error=API key missing", + status_code=HTTP_303_SEE_OTHER, + ) count = int(generation_count) if generation_count.isdigit() else 1 - - from app.services.ai_playground_generator import generate_variants_for_item - + + from app.services.ai_generation import ( + create_generation_run, + generate_questions_batch, + ) + try: - run_id, generated = await generate_variants_for_item( + # Create a generation run to track this batch + run_id = await create_generation_run( + basis_item_id=item.id, + target_level=target_level, + requested_count=count, + model=ai_model, + created_by=admin.username if admin else "unknown", db=db, - item=item, + source_snapshot_question_id=item.source_snapshot_question_id, + operator_notes=operator_notes, + ) + + # Generate the variants + generated = await generate_questions_batch( + basis_item=item, target_level=target_level, ai_model=ai_model, - num_variants=count, + count=count, operator_notes=operator_notes, - include_note_for_admin=include_note_for_admin, - include_note_in_prompt=include_note_in_prompt, ) except Exception as e: - return RedirectResponse(url=f"/admin/questions/{item.id}/generate?error={str(e)}", status_code=HTTP_303_SEE_OTHER) + return RedirectResponse( + url=f"/admin/questions/{item.id}/generate?error={str(e)}", + status_code=HTTP_303_SEE_OTHER, + ) saved_item_ids: list[int] = [] from app.schemas.ai import GeneratedQuestion - from app.services.ai_playground_generator import save_ai_question + from app.services.ai_generation import save_ai_question for generated_question in generated: item_id_saved = await save_ai_question( @@ -4991,6 +6363,7 @@ async def question_generate_submit( status_code=HTTP_303_SEE_OTHER, ) + @router.get("/questions/{item_id}/generate/variants/{variant_id}") async def ai_playground_variant_detail( item_id: int, @@ -5027,12 +6400,6 @@ async def ai_playground_variant_detail( ) - - - - - - @router.post("/questions/{item_id}/generate/review-bulk") async def question_generate_review_bulk( request: Request, @@ -5048,10 +6415,16 @@ async def question_generate_review_bulk( valid_actions = {"approved", "rejected", "archived", "stale", "active"} if action not in valid_actions: - return RedirectResponse(url=f"/admin/questions/{item_id}/generate?tab={tab}&error=Invalid action", status_code=HTTP_303_SEE_OTHER) + return RedirectResponse( + url=f"/admin/questions/{item_id}/generate?tab={tab}&error=Invalid action", + status_code=HTTP_303_SEE_OTHER, + ) if not item_ids: - return RedirectResponse(url=f"/admin/questions/{item_id}/generate?tab={tab}&error=No items selected", status_code=HTTP_303_SEE_OTHER) + return RedirectResponse( + url=f"/admin/questions/{item_id}/generate?tab={tab}&error=No items selected", + status_code=HTTP_303_SEE_OTHER, + ) result = await db.execute(select(Item).where(Item.id.in_(item_ids))) variants = list(result.scalars().all()) diff --git a/app/admin_web_icons.py b/backend/app/admin_web_icons.py similarity index 99% rename from app/admin_web_icons.py rename to backend/app/admin_web_icons.py index 31c66ab..24ba568 100644 --- a/app/admin_web_icons.py +++ b/backend/app/admin_web_icons.py @@ -102,9 +102,7 @@ EMOJI_TO_ICON = { # Navigation icon mapping NAV_ICONS_SVG = { "Dashboard": ICON_DASHBOARD, - "Questions": ICON_QUESTIONS, - "Import Questions": ICON_IMPORT, - "AI Generator": ICON_AI, + "Import": ICON_IMPORT, "Exams": ICON_EXAMS, "Reports": ICON_REPORTS, "Settings": ICON_SETTINGS, diff --git a/app/api/__init__.py b/backend/app/api/__init__.py similarity index 100% rename from app/api/__init__.py rename to backend/app/api/__init__.py diff --git a/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py similarity index 100% rename from app/api/v1/__init__.py rename to backend/app/api/v1/__init__.py diff --git a/app/api/v1/session.py b/backend/app/api/v1/session.py similarity index 97% rename from app/api/v1/session.py rename to backend/app/api/v1/session.py index f8d1e13..aaa0c49 100644 --- a/app/api/v1/session.py +++ b/backend/app/api/v1/session.py @@ -50,6 +50,9 @@ class NextItemResponse(BaseModel): 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 @@ -212,6 +215,11 @@ async def get_next_item_endpoint( 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, diff --git a/app/core/__init__.py b/backend/app/core/__init__.py similarity index 100% rename from app/core/__init__.py rename to backend/app/core/__init__.py diff --git a/app/core/auth.py b/backend/app/core/auth.py similarity index 75% rename from app/core/auth.py rename to backend/app/core/auth.py index a39245f..651f7e4 100644 --- a/app/core/auth.py +++ b/backend/app/core/auth.py @@ -21,7 +21,7 @@ settings = get_settings() @dataclass class AuthContext: - website_id: int + website_id: Optional[int] role: str wp_user_id: Optional[str] = None @@ -36,13 +36,13 @@ def _b64url_decode(raw: str) -> bytes: def issue_access_token( - website_id: int, + 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), + "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), @@ -91,14 +91,19 @@ def decode_access_token(token: str) -> AuthContext: website_id = payload.get("website_id") role = payload.get("role") - if website_id is None or not 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), + website_id=int(website_id) if website_id is not None else None, role=str(role), wp_user_id=payload.get("wp_user_id"), ) @@ -106,6 +111,7 @@ def decode_access_token(token: str) -> AuthContext: 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( @@ -118,25 +124,45 @@ def get_auth_context( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Authorization header format. Use: Bearer {token}", ) - return decode_access_token(parts[1]) + + 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, -) -> int: +) -> 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, + 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, diff --git a/app/core/config.py b/backend/app/core/config.py similarity index 94% rename from app/core/config.py rename to backend/app/core/config.py index 7321ee2..b3e36af 100644 --- a/app/core/config.py +++ b/backend/app/core/config.py @@ -4,10 +4,10 @@ Application configuration using Pydantic Settings. Loads configuration from environment variables with validation. """ -from typing import Literal, List, Union +from typing import Annotated, Literal, List, Union from pydantic import Field, field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict class Settings(BaseSettings): @@ -98,8 +98,8 @@ class Settings(BaseSettings): ) # CORS - stored as list, accepts comma-separated string from env - ALLOWED_ORIGINS: List[str] = Field( - default=["http://localhost:3000"], + 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", ) diff --git a/app/core/rate_limit.py b/backend/app/core/rate_limit.py similarity index 100% rename from app/core/rate_limit.py rename to backend/app/core/rate_limit.py diff --git a/app/database.py b/backend/app/database.py similarity index 100% rename from app/database.py rename to backend/app/database.py diff --git a/app/main.py b/backend/app/main.py similarity index 97% rename from app/main.py rename to backend/app/main.py index 40febad..c6580e2 100644 --- a/app/main.py +++ b/backend/app/main.py @@ -31,11 +31,13 @@ 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() @@ -190,6 +192,10 @@ async def health_check(): # Include API routers with version prefix +app.include_router( + auth_router, + prefix=f"{settings.API_V1_STR}", +) app.include_router( import_export_router, ) @@ -213,6 +219,10 @@ 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( diff --git a/app/models/__init__.py b/backend/app/models/__init__.py similarity index 100% rename from app/models/__init__.py rename to backend/app/models/__init__.py diff --git a/app/models/ai_generation_run.py b/backend/app/models/ai_generation_run.py similarity index 100% rename from app/models/ai_generation_run.py rename to backend/app/models/ai_generation_run.py diff --git a/app/models/item.py b/backend/app/models/item.py similarity index 100% rename from app/models/item.py rename to backend/app/models/item.py diff --git a/app/models/report_schedule.py b/backend/app/models/report_schedule.py similarity index 100% rename from app/models/report_schedule.py rename to backend/app/models/report_schedule.py diff --git a/app/models/session.py b/backend/app/models/session.py similarity index 97% rename from app/models/session.py rename to backend/app/models/session.py index af1b9e2..d1df3c7 100644 --- a/app/models/session.py +++ b/backend/app/models/session.py @@ -89,6 +89,9 @@ class Session(Base): 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" ) diff --git a/app/models/tryout.py b/backend/app/models/tryout.py similarity index 100% rename from app/models/tryout.py rename to backend/app/models/tryout.py diff --git a/app/models/tryout_import_snapshot.py b/backend/app/models/tryout_import_snapshot.py similarity index 100% rename from app/models/tryout_import_snapshot.py rename to backend/app/models/tryout_import_snapshot.py diff --git a/app/models/tryout_snapshot_question.py b/backend/app/models/tryout_snapshot_question.py similarity index 100% rename from app/models/tryout_snapshot_question.py rename to backend/app/models/tryout_snapshot_question.py diff --git a/app/models/tryout_stats.py b/backend/app/models/tryout_stats.py similarity index 100% rename from app/models/tryout_stats.py rename to backend/app/models/tryout_stats.py diff --git a/app/models/user.py b/backend/app/models/user.py similarity index 100% rename from app/models/user.py rename to backend/app/models/user.py diff --git a/app/models/user_answer.py b/backend/app/models/user_answer.py similarity index 100% rename from app/models/user_answer.py rename to backend/app/models/user_answer.py diff --git a/app/models/website.py b/backend/app/models/website.py similarity index 100% rename from app/models/website.py rename to backend/app/models/website.py diff --git a/app/routers/__init__.py b/backend/app/routers/__init__.py similarity index 79% rename from app/routers/__init__.py rename to backend/app/routers/__init__.py index 5234693..9b2552c 100644 --- a/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -4,18 +4,22 @@ 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", ] diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..9c61a15 --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,1077 @@ +""" +Admin API router for custom admin actions. + +Provides admin-specific endpoints for triggering calibration, +toggling AI generation, and resetting normalization. +""" + +from datetime import datetime, timezone +from typing import Any, Dict, Literal + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.auth import AuthContext, ensure_website_scope_matches, get_auth_context, require_website_auth +from app.core.config import get_settings +from app.database import get_db +from app.models import AIGenerationRun, Item, Tryout, TryoutImportSnapshot, TryoutSnapshotQuestion, TryoutStats, UserAnswer, Website +from app.services.irt_calibration import ( + calibrate_all, + CALIBRATION_SAMPLE_THRESHOLD, +) + +router = APIRouter(prefix="/admin", tags=["admin"]) +settings = get_settings() + + +class BulkReviewRequest(BaseModel): + item_ids: list[int] = Field(..., min_length=1) + status: Literal["active", "approved", "rejected", "archived", "stale"] + review_notes: str | None = None + + +class SnapshotPromoteRequest(BaseModel): + snapshot_question_ids: list[int] = Field(..., min_length=1) + + +def _snapshot_slot_map(snapshot: TryoutImportSnapshot) -> dict[str, int]: + slot_map: dict[str, int] = {} + questions = (snapshot.raw_payload or {}).get("questions") or [] + for index, question in enumerate(questions, start=1): + source_question_id = str((question or {}).get("id") or "").strip() + if source_question_id: + slot_map[source_question_id] = index + return slot_map + + +def _snapshot_options_to_item_options(raw_options: list[dict[str, Any]] | list[Any]) -> dict[str, str]: + item_options: dict[str, str] = {} + for option in raw_options or []: + if not isinstance(option, dict): + continue + increment = str(option.get("increment") or "").strip().upper() + text = str(option.get("text") or option.get("label") or "").strip() + if increment and text: + item_options[increment] = text + return item_options + + +def _serialize_item(item: Item, include_content: bool = False) -> dict[str, Any]: + payload: dict[str, Any] = { + "id": item.id, + "item_id": str(item.id), + "tryout_id": item.tryout_id, + "website_id": item.website_id, + "slot": item.slot, + "level": item.level, + "stem_text": item.stem, + "p_value": item.ctt_p, + "ctt_bobot": item.ctt_bobot, + "ctt_category": item.ctt_category, + "irt_b": item.irt_b, + "irt_se": item.irt_se, + "calibrated": item.calibrated, + "calibration_sample_size": item.calibration_sample_size, + "generated_by": item.generated_by, + "ai_model": item.ai_model, + "basis_item_id": item.basis_item_id, + "generation_run_id": item.generation_run_id, + "source_snapshot_question_id": item.source_snapshot_question_id, + "variant_status": item.variant_status, + "created_at": item.created_at, + "updated_at": item.updated_at, + } + if include_content: + payload.update( + { + "stem": item.stem, + "options": item.options, + "correct_answer": item.correct_answer, + "explanation": item.explanation, + "reviewed_by": item.reviewed_by, + "reviewed_at": item.reviewed_at, + "review_notes": item.review_notes, + } + ) + return payload + + +def _serialize_ai_run(run: AIGenerationRun, basis: Item | None = None) -> dict[str, Any]: + generated_items = list(run.generated_items or []) + pending_count = sum(1 for item in generated_items if item.variant_status == "draft") + reviewed_count = sum(1 for item in generated_items if item.variant_status != "draft") + if pending_count: + run_status = "pending_review" + elif generated_items: + run_status = "completed" + else: + run_status = "created" + + return { + "id": run.id, + "basis_item_id": run.basis_item_id, + "target_level": run.target_level, + "requested_count": run.requested_count, + "model": run.model, + "created_by": run.created_by, + "created_at": run.created_at, + "status": run_status, + "generated_count": len(generated_items), + "pending_review_count": pending_count, + "reviewed_count": reviewed_count, + "basis_tryout_id": basis.tryout_id if basis else None, + "basis_slot": basis.slot if basis else None, + } + + +async def _ensure_operational_tryout(snapshot: TryoutImportSnapshot, db: AsyncSession) -> Tryout: + result = await db.execute( + select(Tryout).where( + Tryout.website_id == snapshot.website_id, + Tryout.tryout_id == snapshot.source_tryout_id, + ) + ) + tryout = result.scalar_one_or_none() + if tryout: + return tryout + + tryout = Tryout( + website_id=snapshot.website_id, + tryout_id=snapshot.source_tryout_id, + name=snapshot.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() + return tryout + + +async def _promote_snapshot_question_to_item( + snapshot: TryoutImportSnapshot, + question: TryoutSnapshotQuestion, + db: AsyncSession, +) -> tuple[Item | None, str]: + if question.website_id != snapshot.website_id or question.source_tryout_id != snapshot.source_tryout_id: + return None, "mismatch" + + slot_map = _snapshot_slot_map(snapshot) + slot = slot_map.get(question.source_question_id) + if not slot: + max_slot = ( + await db.scalar( + select(func.max(Item.slot)).where( + Item.website_id == snapshot.website_id, + Item.tryout_id == snapshot.source_tryout_id, + Item.level == "sedang", + ) + ) + or 0 + ) + slot = max_slot + 1 + + options = _snapshot_options_to_item_options(question.raw_options) + if not options: + return None, "missing_options" + + await _ensure_operational_tryout(snapshot, db) + existing_item_result = await db.execute( + select(Item).where( + Item.website_id == snapshot.website_id, + Item.tryout_id == snapshot.source_tryout_id, + Item.slot == slot, + Item.level == "sedang", + ) + ) + existing_item = existing_item_result.scalar_one_or_none() + if existing_item is not None: + return existing_item, "existing" + + item = Item( + tryout_id=snapshot.source_tryout_id, + website_id=snapshot.website_id, + slot=slot, + level="sedang", + stem=question.question_html, + options=options, + correct_answer=question.correct_answer, + explanation=question.explanation_html, + generated_by="manual", + source_snapshot_question_id=question.id, + variant_status="active", + calibrated=False, + calibration_sample_size=0, + ) + db.add(item) + await db.flush() + return item, "created" + + +@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 admin_trigger_calibration( + tryout_id: str, + db: AsyncSession = Depends(get_db), + auth: AuthContext = Depends(get_auth_context), +) -> Dict[str, Any]: + """ + 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"}) + + # 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}/toggle-ai-generation", + summary="Toggle AI generation", + description="Toggle AI question generation for a tryout.", +) +async def admin_toggle_ai_generation( + tryout_id: str, + db: AsyncSession = Depends(get_db), + auth: AuthContext = Depends(get_auth_context), +) -> Dict[str, Any]: + """ + Toggle AI generation for a tryout. + + Updates Tryout.AI_generation_enabled field. + + Args: + tryout_id: Tryout identifier + db: Database session + website_id: Website ID from header + + Returns: + Updated AI generation status + + Raises: + HTTPException: If tryout not found + """ + 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}", + ) + + # Toggle AI generation + tryout.ai_generation_enabled = not tryout.ai_generation_enabled + await db.commit() + await db.refresh(tryout) + + status = "enabled" if tryout.ai_generation_enabled else "disabled" + return { + "tryout_id": tryout_id, + "ai_generation_enabled": tryout.ai_generation_enabled, + "message": f"AI generation {status} for tryout {tryout_id}", + } + + +@router.post( + "/{tryout_id}/reset-normalization", + summary="Reset normalization", + description="Reset normalization to static values and clear incremental stats.", +) +async def admin_reset_normalization( + tryout_id: str, + db: AsyncSession = Depends(get_db), + auth: AuthContext = Depends(get_auth_context), +) -> Dict[str, Any]: + """ + Reset normalization for a tryout. + + Resets rataan, sb to static values and clears incremental stats. + + Args: + tryout_id: Tryout identifier + db: Database session + website_id: Website ID from header + + Returns: + Reset statistics + + Raises: + HTTPException: If tryout or stats not found + """ + website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) + + # Get tryout stats + 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() + + if stats is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"TryoutStats for {tryout_id} not found for website {website_id}", + ) + + # Get tryout for static values + tryout_query = select(Tryout).where(Tryout.tryout_id == tryout_id) + if website_id is not None: + tryout_query = tryout_query.where(Tryout.website_id == website_id) + + tryout_result = await db.execute(tryout_query) + tryout = tryout_result.scalar_one_or_none() + + if tryout: + # Reset to static values + stats.rataan = tryout.static_rataan + stats.sb = tryout.static_sb + else: + # Reset to default values + stats.rataan = 500.0 + stats.sb = 100.0 + + # Clear incremental stats + old_participant_count = stats.participant_count + stats.participant_count = 0 + stats.total_nm_sum = 0.0 + stats.total_nm_sq_sum = 0.0 + stats.min_nm = None + stats.max_nm = None + stats.last_calculated = None + + await db.commit() + await db.refresh(stats) + + return { + "tryout_id": tryout_id, + "rataan": stats.rataan, + "sb": stats.sb, + "cleared_stats": { + "previous_participant_count": old_participant_count, + }, + "message": f"Normalization reset to static values (rataan={stats.rataan}, sb={stats.sb}). Incremental stats cleared.", + } + + +@router.get( + "/tryouts/{tryout_id}/questions", + summary="Get tryout questions", + description="Retrieve all questions/items for a specific tryout for admin management.", +) +async def admin_get_tryout_questions( + tryout_id: str, + db: AsyncSession = Depends(get_db), + auth: AuthContext = Depends(get_auth_context), +) -> Dict[str, Any]: + """Retrieve questions/items for a tryout.""" + website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) + from app.models.item import Item + from app.models.tryout_snapshot_question import TryoutSnapshotQuestion + + query = select(Item).where(Item.tryout_id == tryout_id).order_by(Item.id) + if website_id is not None: + query = query.where(Item.website_id == website_id) + + result = await db.execute(query) + items = result.scalars().all() + + snapshot_query = select(TryoutSnapshotQuestion).where( + TryoutSnapshotQuestion.source_tryout_id == tryout_id + ).order_by(TryoutSnapshotQuestion.id) + if website_id is not None: + snapshot_query = snapshot_query.where(TryoutSnapshotQuestion.website_id == website_id) + + snapshot_result = await db.execute(snapshot_query) + snapshots = snapshot_result.scalars().all() + promoted_by_snapshot_question_id = { + item.source_snapshot_question_id: item + for item in items + if item.source_snapshot_question_id is not None + } + + return { + "tryout_id": tryout_id, + "items": [ + _serialize_item(i) + for i in items + ], + "snapshot_questions": [ + { + "id": s.id, + "latest_snapshot_id": s.latest_snapshot_id, + "source_question_id": s.source_question_id, + "question_title": s.question_title, + "question_html": s.question_html, + "explanation_html": s.explanation_html, + "option_count": s.option_count, + "has_option_labels": s.has_option_labels, + "correct_answer": s.correct_answer, + "is_active": s.is_active, + "promoted_item": _serialize_item(promoted_by_snapshot_question_id[s.id]) + if s.id in promoted_by_snapshot_question_id + else None, + "created_at": s.created_at, + } + for s in snapshots + ] + } + + +@router.get( + "/tryouts/{tryout_id}/attempts", + summary="Get tryout attempts", + description="Retrieve all student attempts (sessions) for a specific tryout.", +) +async def admin_get_tryout_attempts( + tryout_id: str, + db: AsyncSession = Depends(get_db), + auth: AuthContext = Depends(get_auth_context), +) -> Dict[str, Any]: + """Retrieve student attempts/sessions for a tryout.""" + website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) + from app.models.session import Session + + query = select(Session).where(Session.tryout_id == tryout_id).order_by(Session.created_at.desc()) + if website_id is not None: + query = query.where(Session.website_id == website_id) + + result = await db.execute(query) + sessions = result.scalars().all() + + return { + "tryout_id": tryout_id, + "attempts": [ + { + "id": s.id, + "session_id": s.session_id, + "wp_user_id": s.wp_user_id, + "start_time": s.start_time, + "end_time": s.end_time, + "expires_at": s.expires_at, + "is_completed": s.is_completed, + "scoring_mode_used": s.scoring_mode_used, + "NM": s.NM, + "NN": s.NN, + "total_benar": s.total_benar, + } + for s in sessions + ] + } + + +@router.get( + "/dashboard/stats", + summary="Get dashboard statistics", + description="Retrieve aggregated system metrics for the admin dashboard.", +) +async def admin_get_dashboard_stats( + db: AsyncSession = Depends(get_db), + auth: AuthContext = Depends(get_auth_context), +) -> Dict[str, Any]: + """Retrieve overview metrics for the dashboard.""" + website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) + + from sqlalchemy import func + from app.models import Tryout, Item, Session, AIGenerationRun + + # Base queries with website filtering + tryouts_q = select(func.count()).select_from(Tryout) + items_q = select(func.count()).select_from(Item) + sessions_q = select(func.count()).select_from(Session) + completed_q = select(func.count()).select_from(Session).where(Session.is_completed.is_(True)) + uncalibrated_q = select(func.count()).select_from(Item).where(Item.calibrated.is_(False)) + + if website_id is not None: + tryouts_q = tryouts_q.where(Tryout.website_id == website_id) + items_q = items_q.where(Item.website_id == website_id) + sessions_q = sessions_q.where(Session.website_id == website_id) + completed_q = completed_q.where(Session.website_id == website_id) + uncalibrated_q = uncalibrated_q.where(Item.website_id == website_id) + + # Execute counts + tryouts_count = await db.scalar(tryouts_q) or 0 + items_count = await db.scalar(items_q) or 0 + sessions_count = await db.scalar(sessions_q) or 0 + completed_count = await db.scalar(completed_q) or 0 + uncalibrated_count = await db.scalar(uncalibrated_q) or 0 + + # Recent sessions + recent_sessions_q = select(Session).where(Session.is_completed.is_(True)).order_by(Session.end_time.desc()).limit(5) + if website_id is not None: + recent_sessions_q = recent_sessions_q.where(Session.website_id == website_id) + recent_sessions_result = await db.execute(recent_sessions_q) + recent_sessions = recent_sessions_result.scalars().all() + + # AI stats (from AIGenerationRun) - note: AI runs don't have website_id currently, but we can filter by basis_item.website_id if joined. For now we will return all. + recent_runs_q = ( + select(AIGenerationRun) + .options(selectinload(AIGenerationRun.generated_items)) + .order_by(AIGenerationRun.id.desc()) + .limit(3) + ) + recent_runs_result = await db.execute(recent_runs_q) + recent_runs = recent_runs_result.scalars().all() + basis_ids = [run.basis_item_id for run in recent_runs if run.basis_item_id is not None] + basis_by_id: dict[int, Item] = {} + if basis_ids: + basis_result = await db.execute(select(Item).where(Item.id.in_(basis_ids))) + basis_by_id = {item.id: item for item in basis_result.scalars().all()} + + # Calculate calibration status + calibrated_count = items_count - uncalibrated_count + calibration_percentage = round((calibrated_count / items_count * 100) if items_count > 0 else 0, 2) + + # Calculate completion rate + completion_rate = round((completed_count / sessions_count * 100) if sessions_count > 0 else 0, 2) + + return { + "metrics": { + "tryouts": tryouts_count, + "items": items_count, + "sessions": sessions_count, + "completed_sessions": completed_count, + "completion_rate": completion_rate, + "calibration_percentage": calibration_percentage, + }, + "recent_sessions": [ + { + "id": s.id, + "wp_user_id": s.wp_user_id, + "tryout_id": s.tryout_id, + "end_time": s.end_time, + "NM": s.NM, + "NN": s.NN + } for s in recent_sessions + ], + "recent_ai_runs": [ + _serialize_ai_run(run, basis_by_id.get(run.basis_item_id)) + for run in recent_runs + if website_id is None + or ( + basis_by_id.get(run.basis_item_id) is not None + and basis_by_id[run.basis_item_id].website_id == website_id + ) + ] + } + + +@router.get( + "/questions", + summary="Get all questions", + description="Retrieve all questions across all tryouts.", +) +async def admin_get_all_questions( + db: AsyncSession = Depends(get_db), + auth: AuthContext = Depends(get_auth_context), +) -> Dict[str, Any]: + """Retrieve all questions/items.""" + website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) + from app.models.item import Item + + query = select(Item).order_by(Item.id.desc()).limit(500) + 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": [ + _serialize_item(i) + for i in items + ] + } + + +@router.get( + "/questions/{item_id}", + summary="Get question detail", + description="Retrieve one question with options, explanation, basis item, and generated variants.", +) +async def admin_get_question_detail( + item_id: int, + db: AsyncSession = Depends(get_db), + auth: AuthContext = Depends(get_auth_context), +) -> Dict[str, Any]: + website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) + + result = await db.execute( + select(Item) + .options(selectinload(Item.variants), selectinload(Item.basis_item)) + .where(Item.id == item_id) + ) + item = result.scalar_one_or_none() + if item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Question not found") + ensure_website_scope_matches(website_id, item.website_id) + + usage_result = await db.execute( + select( + func.count(UserAnswer.id), + func.count(func.distinct(UserAnswer.wp_user_id)), + ).where(UserAnswer.item_id == item.id) + ) + impressions, unique_users = usage_result.one() + + return { + "item": _serialize_item(item, include_content=True), + "basis_item": _serialize_item(item.basis_item, include_content=True) if item.basis_item else None, + "variants": [_serialize_item(variant, include_content=True) for variant in item.variants], + "usage": { + "impressions": int(impressions or 0), + "unique_users": int(unique_users or 0), + }, + } + + +@router.get( + "/questions/{item_id}/variants", + summary="Get question variants", + description="Retrieve generated variants for a basis question.", +) +async def admin_get_question_variants( + item_id: int, + db: AsyncSession = Depends(get_db), + auth: AuthContext = Depends(get_auth_context), +) -> Dict[str, Any]: + website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) + + basis = await db.get(Item, item_id) + if basis is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Question not found") + ensure_website_scope_matches(website_id, basis.website_id) + + result = await db.execute( + select(Item) + .where(Item.basis_item_id == item_id) + .order_by(Item.created_at.desc(), Item.id.desc()) + ) + return {"items": [_serialize_item(item, include_content=True) for item in result.scalars().all()]} + + +@router.get( + "/snapshots", + summary="List imported tryout snapshots", + description="List imported JSON snapshots for the selected website.", +) +async def admin_list_snapshots( + db: AsyncSession = Depends(get_db), + auth: AuthContext = Depends(get_auth_context), +) -> Dict[str, Any]: + website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) + query = select(TryoutImportSnapshot).order_by(TryoutImportSnapshot.id.desc()).limit(100) + if website_id is not None: + query = query.where(TryoutImportSnapshot.website_id == website_id) + + result = await db.execute(query) + snapshots = result.scalars().all() + return { + "snapshots": [ + { + "id": snapshot.id, + "website_id": snapshot.website_id, + "source_tryout_id": snapshot.source_tryout_id, + "source_key": snapshot.source_key, + "title": snapshot.title, + "question_count": snapshot.question_count, + "result_count": snapshot.result_count, + "created_at": snapshot.created_at, + } + for snapshot in snapshots + ] + } + + +@router.get( + "/snapshots/{snapshot_id}/questions", + summary="List snapshot questions", + description="List read-only source questions and promotion status for a snapshot.", +) +async def admin_list_snapshot_questions( + snapshot_id: int, + db: AsyncSession = Depends(get_db), + auth: AuthContext = Depends(get_auth_context), +) -> Dict[str, Any]: + website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) + snapshot = await db.get(TryoutImportSnapshot, snapshot_id) + if snapshot is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Snapshot not found") + ensure_website_scope_matches(website_id, snapshot.website_id) + + slot_map = _snapshot_slot_map(snapshot) + question_result = await db.execute( + select(TryoutSnapshotQuestion) + .where( + TryoutSnapshotQuestion.website_id == snapshot.website_id, + TryoutSnapshotQuestion.source_tryout_id == snapshot.source_tryout_id, + ) + .order_by(TryoutSnapshotQuestion.source_question_id.asc()) + ) + questions = list(question_result.scalars().all()) + questions.sort(key=lambda row: (slot_map.get(row.source_question_id, 10**9), row.source_question_id)) + + item_result = await db.execute( + select(Item).where( + Item.website_id == snapshot.website_id, + Item.tryout_id == snapshot.source_tryout_id, + Item.level == "sedang", + ) + ) + promoted_items_by_slot = {item.slot: item for item in item_result.scalars().all()} + + return { + "snapshot": { + "id": snapshot.id, + "website_id": snapshot.website_id, + "source_tryout_id": snapshot.source_tryout_id, + "title": snapshot.title, + "question_count": snapshot.question_count, + "created_at": snapshot.created_at, + }, + "questions": [ + { + "id": question.id, + "slot": slot_map.get(question.source_question_id), + "source_question_id": question.source_question_id, + "question_title": question.question_title, + "question_html": question.question_html, + "correct_answer": question.correct_answer, + "option_count": question.option_count, + "has_option_labels": question.has_option_labels, + "is_active": question.is_active, + "promoted_item": _serialize_item(promoted_items_by_slot[slot_map[question.source_question_id]]) + if slot_map.get(question.source_question_id) in promoted_items_by_slot + else None, + } + for question in questions + ], + } + + +@router.post( + "/snapshots/{snapshot_id}/promote", + summary="Promote snapshot questions", + description="Promote selected snapshot questions into live medium-level basis items.", +) +async def admin_promote_snapshot_questions( + snapshot_id: int, + request: SnapshotPromoteRequest, + db: AsyncSession = Depends(get_db), + auth: AuthContext = Depends(get_auth_context), +) -> Dict[str, Any]: + website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) + snapshot = await db.get(TryoutImportSnapshot, snapshot_id) + if snapshot is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Snapshot not found") + ensure_website_scope_matches(website_id, snapshot.website_id) + + result = await db.execute( + select(TryoutSnapshotQuestion).where(TryoutSnapshotQuestion.id.in_(request.snapshot_question_ids)) + ) + questions = list(result.scalars().all()) + requested_ids = set(request.snapshot_question_ids) + found_ids = {question.id for question in questions} + missing_ids = sorted(requested_ids - found_ids) + if missing_ids: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"message": "Some snapshot questions were not found", "ids": missing_ids}, + ) + + rows = [] + for question in questions: + item, row_status = await _promote_snapshot_question_to_item(snapshot, question, db) + rows.append( + { + "snapshot_question_id": question.id, + "status": row_status, + "item": _serialize_item(item) if item else None, + } + ) + await db.commit() + return {"snapshot_id": snapshot_id, "results": rows} + + +@router.get( + "/overview/hierarchy", + summary="Get data hierarchy overview", + description="Return website, snapshot, source question, basis, run, and variant hierarchy data.", +) +async def admin_get_hierarchy_overview( + db: AsyncSession = Depends(get_db), + auth: AuthContext = Depends(get_auth_context), +) -> Dict[str, Any]: + website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) + + websites_query = select(Website).order_by(Website.id.asc()) + snapshots_query = select(TryoutImportSnapshot).order_by(TryoutImportSnapshot.id.desc()) + questions_query = select(TryoutSnapshotQuestion).order_by(TryoutSnapshotQuestion.id.asc()) + basis_query = select(Item).where(Item.generated_by != "ai", Item.level == "sedang") + variants_query = select(Item).where(Item.generated_by == "ai") + runs_query = select(AIGenerationRun).order_by(AIGenerationRun.id.desc()) + + if website_id is not None: + websites_query = websites_query.where(Website.id == website_id) + snapshots_query = snapshots_query.where(TryoutImportSnapshot.website_id == website_id) + questions_query = questions_query.where(TryoutSnapshotQuestion.website_id == website_id) + basis_query = basis_query.where(Item.website_id == website_id) + variants_query = variants_query.where(Item.website_id == website_id) + + websites = list((await db.execute(websites_query)).scalars().all()) + snapshots = list((await db.execute(snapshots_query)).scalars().all()) + source_questions = list((await db.execute(questions_query)).scalars().all()) + basis_items = list((await db.execute(basis_query)).scalars().all()) + variants = list((await db.execute(variants_query)).scalars().all()) + runs = list((await db.execute(runs_query)).scalars().all()) + + basis_by_source: dict[int, list[Item]] = {} + variants_by_basis: dict[int, list[Item]] = {} + for item in basis_items: + if item.source_snapshot_question_id is not None: + basis_by_source.setdefault(item.source_snapshot_question_id, []).append(item) + for item in variants: + if item.basis_item_id is not None: + variants_by_basis.setdefault(item.basis_item_id, []).append(item) + + snapshots_without_basis = 0 + for snapshot in snapshots: + snapshot_question_ids = [ + question.id + for question in source_questions + if question.latest_snapshot_id == snapshot.id + ] + if snapshot_question_ids and not any(basis_by_source.get(question_id) for question_id in snapshot_question_ids): + snapshots_without_basis += 1 + + return { + "summary": { + "websites": len(websites), + "snapshots": len(snapshots), + "source_questions": len(source_questions), + "basis_items": len(basis_items), + "ai_runs": len(runs), + "variants": len(variants), + "snapshots_without_basis": snapshots_without_basis, + "basis_without_variants": sum(1 for item in basis_items if not variants_by_basis.get(item.id)), + "orphan_variants": sum(1 for item in variants if item.basis_item_id is None or item.basis_item_id not in {basis.id for basis in basis_items}), + }, + "websites": [ + { + "id": website.id, + "name": website.site_name, + "domain": website.site_url, + "snapshots": [ + { + "id": snapshot.id, + "tryout_id": snapshot.source_tryout_id, + "title": snapshot.title, + "question_count": snapshot.question_count, + "created_at": snapshot.created_at, + "basis_items": [ + _serialize_item(item) + for question in source_questions + if question.latest_snapshot_id == snapshot.id + for item in basis_by_source.get(question.id, []) + ], + } + for snapshot in snapshots + if snapshot.website_id == website.id + ], + } + for website in websites + ], + } + + +@router.get( + "/ai/runs", + summary="Get AI generation run history", + description="List AI generation runs for admin review.", +) +async def admin_get_ai_runs( + db: AsyncSession = Depends(get_db), + auth: AuthContext = Depends(get_auth_context), +) -> Dict[str, Any]: + website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) + query = ( + select(AIGenerationRun) + .options(selectinload(AIGenerationRun.generated_items)) + .order_by(AIGenerationRun.id.desc()) + .limit(100) + ) + result = await db.execute(query) + runs = result.scalars().all() + + basis_ids = [run.basis_item_id for run in runs if run.basis_item_id is not None] + basis_by_id: dict[int, Item] = {} + if basis_ids: + basis_result = await db.execute(select(Item).where(Item.id.in_(basis_ids))) + basis_by_id = {item.id: item for item in basis_result.scalars().all()} + + rows = [] + for run in runs: + basis = basis_by_id.get(run.basis_item_id) + if website_id is not None and (basis is None or basis.website_id != website_id): + continue + rows.append(_serialize_ai_run(run, basis)) + return {"runs": rows} + + +@router.get( + "/ai/variants", + summary="List AI generated variants", + description="List generated variants with optional basis/tryout/status filters.", +) +async def admin_get_ai_variants( + tryout_id: str | None = None, + basis_item_id: int | None = None, + status_filter: str | None = None, + db: AsyncSession = Depends(get_db), + auth: AuthContext = Depends(get_auth_context), +) -> Dict[str, Any]: + website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) + query = select(Item).where(Item.generated_by == "ai").order_by(Item.created_at.desc()).limit(300) + if website_id is not None: + query = query.where(Item.website_id == website_id) + if tryout_id: + query = query.where(Item.tryout_id == tryout_id) + if basis_item_id is not None: + query = query.where(Item.basis_item_id == basis_item_id) + if status_filter: + query = query.where(Item.variant_status == status_filter) + + result = await db.execute(query) + return {"items": [_serialize_item(item, include_content=True) for item in result.scalars().all()]} + + +@router.post( + "/ai/review-bulk", + summary="Bulk review AI generated variants", + description="Apply a review status to multiple AI generated variants.", +) +async def admin_bulk_review_ai_questions( + request: BulkReviewRequest, + db: AsyncSession = Depends(get_db), + auth: AuthContext = Depends(get_auth_context), +) -> Dict[str, Any]: + website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) + result = await db.execute(select(Item).where(Item.id.in_(request.item_ids), Item.generated_by == "ai")) + items = list(result.scalars().all()) + + found_ids = {item.id for item in items} + missing_ids = sorted(set(request.item_ids) - found_ids) + if missing_ids: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"message": "Some AI variants were not found", "ids": missing_ids}, + ) + + for item in items: + ensure_website_scope_matches(website_id, item.website_id) + item.variant_status = request.status + item.review_notes = request.review_notes + item.reviewed_at = datetime.now(timezone.utc) + item.reviewed_by = auth.wp_user_id or auth.role + + await db.commit() + return {"updated": len(items), "status": request.status, "item_ids": [item.id for item in items]} + + +@router.get( + "/templates", + summary="Get question templates", + description="Retrieve basis items (templates) for AI generation.", +) +async def admin_get_templates( + db: AsyncSession = Depends(get_db), + auth: AuthContext = Depends(get_auth_context), +) -> Dict[str, Any]: + """Retrieve basis items (level=sedang, not AI generated).""" + website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) + from app.models.item import Item + + query = ( + select(Item) + .options(selectinload(Item.variants)) + .where(Item.level == "sedang", Item.generated_by != "ai") + .order_by(Item.updated_at.desc(), Item.id.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, + "stem_text": i.stem_text if hasattr(i, 'stem_text') else i.stem[:100], + "p_value": i.ctt_p, + "created_at": i.created_at, + "variants_count": len(i.variants) if hasattr(i, 'variants') else 0, # Note: this won't work without a join or lazy loading, let's omit variants_count or do a subquery + } + for i in items + ] + } diff --git a/app/routers/ai.py b/backend/app/routers/ai.py similarity index 52% rename from app/routers/ai.py rename to backend/app/routers/ai.py index 0d14b5f..86cb1fd 100644 --- a/app/routers/ai.py +++ b/backend/app/routers/ai.py @@ -8,7 +8,7 @@ import logging from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Request, status -from sqlalchemy import and_, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import get_settings @@ -22,6 +22,9 @@ 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, @@ -30,8 +33,13 @@ from app.schemas.ai import ( ) 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, ) @@ -42,6 +50,19 @@ 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, @@ -107,12 +128,7 @@ async def generate_preview( ) ensure_website_scope_matches(website_id, basis_item.website_id) - # Validate basis item is sedang level - 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}", - ) + _validate_original_basis_item(basis_item) # Generate question try: @@ -137,6 +153,7 @@ async def generate_preview( 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, @@ -171,7 +188,6 @@ async def generate_preview( 200: {"description": "Question saved successfully"}, 400: {"description": "Invalid request data"}, 404: {"description": "Basis item or tryout not found"}, - 409: {"description": "Item already exists at this slot/level"}, 500: {"description": "Database save failed"}, }, ) @@ -185,8 +201,8 @@ async def generate_save( Save AI-generated question to database. - **stem**: Question text - - **options**: Dict with A, B, C, D options - - **correct**: Correct answer (A/B/C/D) + - **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 @@ -216,26 +232,7 @@ async def generate_save( detail=f"Basis item not found: {request.basis_item_id}", ) ensure_website_scope_matches(website_id, basis_item.website_id) - - # Check for duplicate (same tryout, website, slot, level) - existing_result = await db.execute( - select(Item).where( - and_( - Item.tryout_id == request.tryout_id, - Item.website_id == request.website_id, - Item.slot == request.slot, - Item.level == request.level, - ) - ) - ) - existing = existing_result.scalar_one_or_none() - - if existing: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"Item already exists at slot={request.slot}, level={request.level} " - f"for tryout={request.tryout_id}", - ) + _validate_original_basis_item(basis_item) # Create GeneratedQuestion from request from app.schemas.ai import GeneratedQuestion @@ -246,6 +243,21 @@ async def generate_save( 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( @@ -256,6 +268,9 @@ async def generate_save( 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, ) @@ -268,6 +283,111 @@ async def generate_save( 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]), ) @@ -313,22 +433,98 @@ 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 { - "models": [ + "items": [ { - "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", - }, + "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} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..2085f7e --- /dev/null +++ b/backend/app/routers/auth.py @@ -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", + } diff --git a/app/routers/import_export.py b/backend/app/routers/import_export.py similarity index 97% rename from app/routers/import_export.py rename to backend/app/routers/import_export.py index 41d8ed1..9b62b6a 100644 --- a/app/routers/import_export.py +++ b/backend/app/routers/import_export.py @@ -292,12 +292,6 @@ async def export_questions( """ Export questions to Excel file. - Creates Excel file with standardized format: - - 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 ID from header @@ -394,6 +388,11 @@ async def import_tryout_json( 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", diff --git a/app/routers/normalization.py b/backend/app/routers/normalization.py similarity index 100% rename from app/routers/normalization.py rename to backend/app/routers/normalization.py diff --git a/app/routers/reports.py b/backend/app/routers/reports.py similarity index 100% rename from app/routers/reports.py rename to backend/app/routers/reports.py diff --git a/app/routers/sessions.py b/backend/app/routers/sessions.py similarity index 84% rename from app/routers/sessions.py rename to backend/app/routers/sessions.py index 27815ae..865a2e3 100644 --- a/app/routers/sessions.py +++ b/backend/app/routers/sessions.py @@ -7,7 +7,7 @@ Endpoints: - POST /session: Create new session """ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.exc import IntegrityError @@ -25,6 +25,7 @@ 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, @@ -83,14 +84,15 @@ async def complete_session( website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"}) # Get session with tryout relationship - result = await db.execute( + session_query = ( select(Session) .options(selectinload(Session.tryout)) - .where( - Session.session_id == session_id, - Session.website_id == website_id, - ) + .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: @@ -110,18 +112,25 @@ async def complete_session( 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 == website_id, + 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)): @@ -130,10 +139,7 @@ async def complete_session( detail="Duplicate item answers are not allowed in a session completion", ) - existing_answers_result = await db.execute( - select(UserAnswer.item_id).where(UserAnswer.session_id == session.session_id) - ) - existing_answered_item_ids = {row[0] for row in existing_answers_result.all()} + 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( @@ -148,7 +154,15 @@ async def complete_session( total_bobot_earned = 0.0 user_answer_records = [] - for answer_input in request.user_answers: + 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: @@ -172,7 +186,7 @@ async def complete_session( user_answer = UserAnswer( session_id=session.session_id, wp_user_id=session.wp_user_id, - website_id=website_id, + website_id=effective_website_id, tryout_id=session.tryout_id, item_id=item.id, response=answer_input.response.upper(), @@ -187,7 +201,7 @@ async def complete_session( # Calculate total_bobot_max for NM calculation try: total_bobot_max = await get_total_bobot_max( - db, website_id, session.tryout_id, level="sedang" + db, effective_website_id, session.tryout_id, level="sedang" ) except ValueError: # Fallback: calculate from items we have @@ -209,7 +223,7 @@ async def complete_session( # Get current stats for dynamic normalization stats_result = await db.execute( select(TryoutStats).where( - TryoutStats.website_id == website_id, + TryoutStats.website_id == effective_website_id, TryoutStats.tryout_id == session.tryout_id, ) ) @@ -226,7 +240,7 @@ async def complete_session( # Hybrid: use dynamic if enough data, otherwise static stats_result = await db.execute( select(TryoutStats).where( - TryoutStats.website_id == website_id, + TryoutStats.website_id == effective_website_id, TryoutStats.tryout_id == session.tryout_id, ) ) @@ -253,7 +267,7 @@ async def complete_session( session.sb_used = sb # Update tryout stats incrementally - await update_tryout_stats(db, website_id, session.tryout_id, nm) + await update_tryout_stats(db, effective_website_id, session.tryout_id, nm) # Commit all changes try: @@ -276,6 +290,7 @@ async def complete_session( 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, @@ -325,12 +340,11 @@ async def get_session( """ website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"}) - result = await db.execute( - select(Session).where( - Session.session_id == session_id, - Session.website_id == website_id, - ) - ) + 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: @@ -375,6 +389,7 @@ async def create_session( 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, @@ -384,7 +399,7 @@ async def create_session( # Verify tryout exists tryout_result = await db.execute( select(Tryout).where( - Tryout.website_id == website_id, + Tryout.website_id == effective_website_id, Tryout.tryout_id == request.tryout_id, ) ) @@ -393,7 +408,7 @@ async def create_session( if tryout is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Tryout {request.tryout_id} not found for website {website_id}", + detail=f"Tryout {request.tryout_id} not found for website {effective_website_id}", ) # Check if session already exists @@ -408,14 +423,26 @@ async def create_session( 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=website_id, + website_id=effective_website_id, tryout_id=request.tryout_id, scoring_mode_used=request.scoring_mode, - start_time=datetime.now(timezone.utc), + start_time=started_at, + expires_at=started_at + timedelta(hours=2), is_completed=False, total_benar=0, total_bobot_earned=0.0, diff --git a/app/routers/tryouts.py b/backend/app/routers/tryouts.py similarity index 67% rename from app/routers/tryouts.py rename to backend/app/routers/tryouts.py index 353bda5..762d3e1 100644 --- a/app/routers/tryouts.py +++ b/backend/app/routers/tryouts.py @@ -19,11 +19,13 @@ 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, ) @@ -53,14 +55,15 @@ async def get_tryout_config( website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"}) # Get tryout with stats - result = await db.execute( + query = ( select(Tryout) .options(selectinload(Tryout.stats)) - .where( - Tryout.website_id == website_id, - Tryout.tryout_id == tryout_id, - ) + .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: @@ -104,6 +107,73 @@ async def get_tryout_config( ) +@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, @@ -134,12 +204,11 @@ async def update_normalization( website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) # Get tryout - result = await db.execute( - select(Tryout).where( - Tryout.website_id == website_id, - Tryout.tryout_id == tryout_id, - ) - ) + 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: @@ -160,12 +229,11 @@ async def update_normalization( tryout.static_sb = request.static_sb # Get current stats for participant count - stats_result = await db.execute( - select(TryoutStats).where( - TryoutStats.website_id == website_id, - TryoutStats.tryout_id == tryout_id, - ) - ) + 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 @@ -204,22 +272,42 @@ async def list_tryouts( """ website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"}) - # Get tryouts with stats - result = await db.execute( - select(Tryout) - .options(selectinload(Tryout.stats)) - .where(Tryout.website_id == website_id) - ) + # 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 ] @@ -254,12 +342,11 @@ async def get_calibration_status( website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) # Verify tryout exists - tryout_result = await db.execute( - select(Tryout).where( - Tryout.website_id == website_id, - Tryout.tryout_id == tryout_id, - ) - ) + 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: @@ -269,16 +356,16 @@ async def get_calibration_status( ) # Get calibration statistics - stats_result = await db.execute( - 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.website_id == website_id, - Item.tryout_id == tryout_id, - ) - ) + 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 @@ -331,12 +418,11 @@ async def trigger_calibration( ) # Verify tryout exists - tryout_result = await db.execute( - select(Tryout).where( - Tryout.website_id == website_id, - Tryout.tryout_id == tryout_id, - ) - ) + 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: @@ -395,12 +481,11 @@ async def trigger_item_calibration( from app.services.irt_calibration import calibrate_item, CALIBRATION_SAMPLE_THRESHOLD # Verify tryout exists - tryout_result = await db.execute( - select(Tryout).where( - Tryout.website_id == website_id, - Tryout.tryout_id == tryout_id, - ) - ) + 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: @@ -410,13 +495,14 @@ async def trigger_item_calibration( ) # Verify item belongs to this tryout - item_result = await db.execute( - select(Item).where( - Item.id == item_id, - Item.website_id == website_id, - Item.tryout_id == tryout_id, - ) + 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: diff --git a/backend/app/routers/websites.py b/backend/app/routers/websites.py new file mode 100644 index 0000000..058216f --- /dev/null +++ b/backend/app/routers/websites.py @@ -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"} diff --git a/app/routers/wordpress.py b/backend/app/routers/wordpress.py similarity index 100% rename from app/routers/wordpress.py rename to backend/app/routers/wordpress.py diff --git a/app/schemas/__init__.py b/backend/app/schemas/__init__.py similarity index 100% rename from app/schemas/__init__.py rename to backend/app/schemas/__init__.py diff --git a/backend/app/schemas/ai.py b/backend/app/schemas/ai.py new file mode 100644 index 0000000..7a8e126 --- /dev/null +++ b/backend/app/schemas/ai.py @@ -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 diff --git a/app/schemas/report.py b/backend/app/schemas/report.py similarity index 100% rename from app/schemas/report.py rename to backend/app/schemas/report.py diff --git a/app/schemas/session.py b/backend/app/schemas/session.py similarity index 97% rename from app/schemas/session.py rename to backend/app/schemas/session.py index c27ac00..bba6038 100644 --- a/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -52,6 +52,7 @@ class SessionCompleteResponse(BaseModel): tryout_id: str start_time: datetime end_time: Optional[datetime] + expires_at: Optional[datetime] = None is_completed: bool scoring_mode_used: str @@ -99,6 +100,7 @@ class SessionResponse(BaseModel): tryout_id: str start_time: datetime end_time: Optional[datetime] + expires_at: Optional[datetime] = None is_completed: bool scoring_mode_used: str diff --git a/app/schemas/tryout.py b/backend/app/schemas/tryout.py similarity index 68% rename from app/schemas/tryout.py rename to backend/app/schemas/tryout.py index 3dbc147..b4c998b 100644 --- a/app/schemas/tryout.py +++ b/backend/app/schemas/tryout.py @@ -64,16 +64,39 @@ class TryoutStatsResponse(BaseModel): 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.""" diff --git a/app/schemas/wordpress.py b/backend/app/schemas/wordpress.py similarity index 100% rename from app/schemas/wordpress.py rename to backend/app/schemas/wordpress.py diff --git a/app/services/__init__.py b/backend/app/services/__init__.py similarity index 100% rename from app/services/__init__.py rename to backend/app/services/__init__.py diff --git a/app/services/ai_generation.py b/backend/app/services/ai_generation.py similarity index 70% rename from app/services/ai_generation.py rename to backend/app/services/ai_generation.py index b2e59f1..075dcdd 100644 --- a/app/services/ai_generation.py +++ b/backend/app/services/ai_generation.py @@ -9,6 +9,8 @@ import json import logging import re import ast +import time +from dataclasses import dataclass from typing import Any, Dict, Literal, Optional, Union import httpx @@ -20,13 +22,14 @@ 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 GeneratedQuestion +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 = { @@ -42,6 +45,159 @@ LEVEL_DESCRIPTIONS = { "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, @@ -65,6 +221,10 @@ def get_prompt_template( 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()] @@ -103,17 +263,19 @@ Generate 1 new question that is {level_desc} than the basis question above. REQUIREMENTS: 1. Keep the SAME topic/subject matter as the basis question 2. Use similar context and terminology -3. Create exactly 4 answer options (A, B, C, D) -4. Only ONE correct answer -5. Include a clear explanation of why the correct answer is correct -6. Make the question noticeably {level_desc} - not just a minor variation -7. Follow and preserve any HTML formatting (e.g.,

,
, ) present in the basis question +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

,
, , , , , , , , 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": {{"A": "Option A text", "B": "Option B text", "C": "Option C text", "D": "Option D text"}}, "correct": "A", "explanation": "Explanation text here"}} +{{"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 "A", "B", "C", or "D".""" +Remember: The correct field must be exactly one of: {option_label_text}.""" return prompt @@ -164,18 +326,13 @@ def validate_and_create_question(data: Dict[str, Any]) -> Optional[GeneratedQues options = _normalize_options(data.get("options")) if not options: - logger.warning("Options cannot be normalized to A/B/C/D map") - return None - - required_options = {"A", "B", "C", "D"} - if not required_options.issubset(set(options.keys())): - logger.warning(f"Missing required options: {required_options - set(options.keys())}") + 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 required_options: + if correct not in set(options.keys()): logger.warning(f"Invalid correct answer: {correct}") return None @@ -258,7 +415,7 @@ def _try_parse_json_like(candidate: str) -> Any: 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.get(k, "") for k in ["A", "B", "C", "D"] if normalized.get(k, "")} + return {k: normalized[k] for k in OPTION_LABELS if normalized.get(k, "")} if isinstance(raw_options, list): mapped: dict[str, str] = {} @@ -269,9 +426,9 @@ def _normalize_options(raw_options: Any) -> dict[str, str]: else: key = "" text = str(opt).strip() - if not key and idx < 4: - key = ["A", "B", "C", "D"][idx] - if key in {"A", "B", "C", "D"} and text: + if not key and idx < len(OPTION_LABELS): + key = OPTION_LABELS[idx] + if key in OPTION_LABELS and text: mapped[key] = text return mapped @@ -282,24 +439,44 @@ 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 {"A", "B", "C", "D"}: + if raw_text in OPTION_LABELS: return raw_text if raw_text.isdigit(): idx = int(raw_text) - if 1 <= idx <= 4: - return ["A", "B", "C", "D"][idx - 1] - if 0 <= idx <= 3: - return ["A", "B", "C", "D"][idx] - if raw_text in {"OPTION A", "OPTION B", "OPTION C", "OPTION D"}: + 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[str]: +) -> Optional[OpenRouterCallResult]: """ Call OpenRouter API to generate question. @@ -309,7 +486,7 @@ async def call_openrouter_api( max_retries: Maximum retry attempts Returns: - API response text or None if failed + OpenRouterCallResult with response text and usage, or None if failed """ if not settings.OPENROUTER_API_KEY: logger.error("OPENROUTER_API_KEY not configured") @@ -362,7 +539,12 @@ async def call_openrouter_api( choices = data.get("choices", []) if choices: message = choices[0].get("message", {}) - return message.get("content") + 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 @@ -423,19 +605,20 @@ async def generate_question( operator_notes=operator_notes, ) - max_generation_attempts = 2 + max_generation_attempts = 3 for attempt in range(1, max_generation_attempts + 1): - response_text = await call_openrouter_api(prompt, ai_model) - if not response_text: + 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(response_text) - if generated: + 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 AI response (attempt %s/%s), retrying", + "Failed to parse or validate AI response (attempt %s/%s), retrying", attempt, max_generation_attempts, ) diff --git a/app/services/cat_selection.py b/backend/app/services/cat_selection.py similarity index 92% rename from app/services/cat_selection.py rename to backend/app/services/cat_selection.py index eb52c35..6064693 100644 --- a/app/services/cat_selection.py +++ b/backend/app/services/cat_selection.py @@ -53,6 +53,30 @@ class TerminationCheck: 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( @@ -99,7 +123,8 @@ async def get_next_item_fixed( select(Item) .where( Item.tryout_id == tryout_id, - Item.website_id == website_id + Item.website_id == website_id, + _servable_item_filter(), ) .order_by(Item.slot, Item.level) ) @@ -113,7 +138,16 @@ async def get_next_item_fixed( query = query.where(not_(Item.id.in_(answered_item_ids))) result = await db.execute(query) - items = result.scalars().all() + 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( @@ -187,6 +221,7 @@ async def get_next_item_adaptive( .where( Item.tryout_id == tryout_id, Item.website_id == website_id, + _servable_item_filter(), Item.calibrated == True # Only calibrated items for IRT ) ) @@ -204,7 +239,16 @@ async def get_next_item_adaptive( query = query.where(Item.generated_by == 'manual') result = await db.execute(query) - items = result.scalars().all() + 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( @@ -553,7 +597,8 @@ async def get_available_levels_for_slot( .where( Item.tryout_id == tryout_id, Item.website_id == website_id, - Item.slot == slot + Item.slot == slot, + _servable_item_filter(), ) .distinct() ) @@ -599,7 +644,8 @@ async def simulate_cat_selection( select(Item) .where( Item.tryout_id == tryout_id, - Item.website_id == website_id + Item.website_id == website_id, + _servable_item_filter(), ) .order_by(Item.slot) ) diff --git a/app/services/config_management.py b/backend/app/services/config_management.py similarity index 100% rename from app/services/config_management.py rename to backend/app/services/config_management.py diff --git a/app/services/ctt_scoring.py b/backend/app/services/ctt_scoring.py similarity index 100% rename from app/services/ctt_scoring.py rename to backend/app/services/ctt_scoring.py diff --git a/app/services/excel_import.py b/backend/app/services/excel_import.py similarity index 100% rename from app/services/excel_import.py rename to backend/app/services/excel_import.py diff --git a/app/services/irt_calibration.py b/backend/app/services/irt_calibration.py similarity index 100% rename from app/services/irt_calibration.py rename to backend/app/services/irt_calibration.py diff --git a/app/services/normalization.py b/backend/app/services/normalization.py similarity index 100% rename from app/services/normalization.py rename to backend/app/services/normalization.py diff --git a/app/services/reporting.py b/backend/app/services/reporting.py similarity index 100% rename from app/services/reporting.py rename to backend/app/services/reporting.py diff --git a/app/services/tryout_json_import.py b/backend/app/services/tryout_json_import.py similarity index 94% rename from app/services/tryout_json_import.py rename to backend/app/services/tryout_json_import.py index 6c2fb38..b801349 100644 --- a/app/services/tryout_json_import.py +++ b/backend/app/services/tryout_json_import.py @@ -17,7 +17,7 @@ from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.models import Item, TryoutImportSnapshot, TryoutSnapshotQuestion, Website +from app.models import Item, Tryout, TryoutImportSnapshot, TryoutSnapshotQuestion, Website SOURCE_FORMAT = "sejoli_json" DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S" @@ -248,6 +248,28 @@ async def import_tryout_json_snapshot(payload: dict[str, Any], website_id: int, 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, diff --git a/app/services/wordpress_auth.py b/backend/app/services/wordpress_auth.py similarity index 100% rename from app/services/wordpress_auth.py rename to backend/app/services/wordpress_auth.py diff --git a/docker-compose.dev.yml b/backend/docker-compose.dev.yml similarity index 100% rename from docker-compose.dev.yml rename to backend/docker-compose.dev.yml diff --git a/docs/ALUR-APLIKASI-DAN-IRT.md b/backend/docs/ALUR-APLIKASI-DAN-IRT.md similarity index 100% rename from docs/ALUR-APLIKASI-DAN-IRT.md rename to backend/docs/ALUR-APLIKASI-DAN-IRT.md diff --git a/error.html b/backend/error.html similarity index 100% rename from error.html rename to backend/error.html diff --git a/irt_1pl_mle.py b/backend/irt_1pl_mle.py similarity index 100% rename from irt_1pl_mle.py rename to backend/irt_1pl_mle.py diff --git a/patch_css.py b/backend/patch_css.py similarity index 100% rename from patch_css.py rename to backend/patch_css.py diff --git a/patch_icons.py b/backend/patch_icons.py similarity index 100% rename from patch_icons.py rename to backend/patch_icons.py diff --git a/requirements.txt b/backend/requirements.txt similarity index 100% rename from requirements.txt rename to backend/requirements.txt diff --git a/run_local.sh b/backend/run_local.sh similarity index 100% rename from run_local.sh rename to backend/run_local.sh diff --git a/backend/test_all_post_endpoints.py b/backend/test_all_post_endpoints.py new file mode 100644 index 0000000..2f678d8 --- /dev/null +++ b/backend/test_all_post_endpoints.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +""" +Comprehensive test of all form POST endpoints with proper authentication. +""" + +import re +import sys + +import httpx + +BASE_URL = "http://localhost:8000" + + +def login(client: httpx.Client) -> bool: + """Login and maintain session.""" + response = client.get("/admin/login") + if response.status_code != 200: + return False + + match = re.search(r'name="csrf_token" value="([^"]+)"', response.text) + csrf_token = match.group(1) if match else "" + + if not csrf_token: + return False + + response = client.post( + "/admin/login", + data={ + "username": "admin", + "password": "admin123", + "csrf_token": csrf_token, + }, + follow_redirects=True, + ) + + return response.status_code == 200 and "/admin/dashboard" in str(response.url) + + +def get_csrf_token(client: httpx.Client, page_url: str) -> str: + """Extract CSRF token from a page.""" + response = client.get(page_url) + if response.status_code == 200: + match = re.search(r'name="csrf_token" value="([^"]+)"', response.text) + if match: + return match.group(1) + return "" + + +def test_endpoint(client: httpx.Client, name: str, url: str, data: dict) -> dict: + """Test a single POST endpoint.""" + csrf_token = get_csrf_token(client, url) + + # Get the base URL (strip query params) for CSRF token extraction + base_url = url.split("?")[0] if "?" in url else url + + # If we're on a different page, get CSRF token from there + if not csrf_token: + # Try to get CSRF from dashboard if it's a subpage + csrf_token = get_csrf_token(client, "/admin/dashboard") + + if not csrf_token: + return { + "name": name, + "status_code": None, + "has_ise": False, + "has_traceback": False, + "error": "Could not get CSRF token", + "response_preview": "", + } + + # Add CSRF token to data + test_data = data.copy() + test_data["csrf_token"] = csrf_token + + response = client.post( + url, + data=test_data, + follow_redirects=True, + ) + + has_ise = response.status_code == 500 or "Internal Server Error" in response.text + has_traceback = "Traceback" in response.text + + if has_traceback: + idx = response.text.find("Traceback") + traceback_text = response.text[idx : idx + 2000] + print(f"\n ⚠️ TRACEBACK on {name}:") + print(f" {traceback_text[:500]}...") + + return { + "name": name, + "status_code": response.status_code, + "has_ise": has_ise, + "has_traceback": has_traceback, + "error": None, + "response_preview": response.text[:500], + } + + +def main(): + print("=" * 80) + print("Testing All Form POST Endpoints for Internal Server Errors") + print("=" * 80) + + results = [] + + with httpx.Client(base_url=BASE_URL, timeout=60.0) as client: + print("\nStep 1: Logging in...") + if not login(client): + print("❌ Login failed") + return 1 + print("✅ Login successful") + + # Test 1: Variant approval (with item ID 4) + print("\nStep 2: Testing variant approval...") + result = test_endpoint( + client, + "Variant approval (/admin/questions/4/generate/review-bulk)", + "/admin/questions/4/generate?tab=review", + {"item_ids": "4", "action": "approved", "tab": "review"}, + ) + results.append(result) + print( + f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}" + ) + + # Test 2: Basis item review + print("\nStep 3: Testing basis item review...") + result = test_endpoint( + client, + "Basis item review (/admin/basis-items/4/review-bulk)", + "/admin/basis-items/4", + {"item_ids": "4", "action": "approved"}, + ) + results.append(result) + print( + f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}" + ) + + # Test 3: Generate variants for question + print("\nStep 4: Testing generate variants...") + result = test_endpoint( + client, + "Generate variants (/admin/questions/4/generate)", + "/admin/questions/4/generate?tab=generate", + { + "target_level": "mudah", + "ai_model": "meta-llama/llama-4-maverick:free", + "generation_count": "1", + "operator_notes": "", + "include_note_for_admin": "on", + "include_note_in_prompt": "", + }, + ) + results.append(result) + print( + f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}" + ) + + # Test 5: Website creation + print("\nStep 5: Testing website creation...") + result = test_endpoint( + client, + "Website creation (/admin/websites)", + "/admin/websites", + {"site_name": "Test Site API", "site_url": "https://test-api.example.com"}, + ) + results.append(result) + print( + f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}" + ) + + # Test 6: Website deletion (with test ID) + print("\nStep 6: Testing website deletion...") + # First create a website + result_create = test_endpoint( + client, + "Create test website", + "/admin/websites", + { + "site_name": "Delete Test Site", + "site_url": "https://delete-test.example.com", + }, + ) + + # Now delete it (using website ID 2 if exists) + result = test_endpoint( + client, + "Website deletion (/admin/websites/2/delete)", + "/admin/websites/2/delete", + {}, + ) + results.append(result) + print( + f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}" + ) + + # Test 7: Tryout import preview (without file - should get validation error not server error) + print("\nStep 7: Testing tryout import preview...") + result = test_endpoint( + client, + "Tryout import preview (/admin/tryout-import/preview)", + "/admin/tryout-import", + {"website_id": "1"}, + ) + results.append(result) + print(f" Status: {result['status_code']} (validation error expected: 422)") + + # Test 8: Snapshot promote bulk + print("\nStep 8: Testing snapshot promote bulk...") + result = test_endpoint( + client, + "Snapshot promote (/admin/snapshot-questions/promote-bulk)", + "/admin/snapshot-questions", + {"snapshot_id": "1", "snapshot_question_ids": ""}, + ) + results.append(result) + print( + f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}" + ) + + # Test 9: AI generation basis item + print("\nStep 9: Testing AI generation for basis item...") + result = test_endpoint( + client, + "Basis item generate (/admin/basis-items/4/generate)", + "/admin/basis-items/4", + { + "target_level": "mudah", + "ai_model": "", + "generation_count": "1", + "operator_notes": "", + }, + ) + results.append(result) + print( + f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}" + ) + + # Summary + print("\n" + "=" * 80) + print("RESULTS SUMMARY") + print("=" * 80) + + errors = [] + for result in results: + if result.get("has_traceback"): + errors.append(f"❌ {result['name']}: TRACEBACK") + print(f"❌ {result['name']}: TRACEBACK") + elif result.get("has_ise"): + errors.append(f"❌ {result['name']}: INTERNAL SERVER ERROR") + print(f"❌ {result['name']}: INTERNAL SERVER ERROR") + elif result.get("error"): + print(f"⚠️ {result['name']}: {result['error']}") + elif result["status_code"] in [200, 303]: + print(f"✅ {result['name']}: OK ({result['status_code']})") + elif result["status_code"] == 422: + print(f"✅ {result['name']}: Validation Error (expected)") + else: + print(f"⚠️ {result['name']}: Status {result['status_code']}") + + print() + if errors: + print("❌ Some endpoints have INTERNAL SERVER ERRORS:") + for error in errors: + print(f" {error}") + return 1 + else: + print("✅ All form POST endpoints tested successfully!") + print(" No Internal Server Errors detected.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/test_all_routes.py b/backend/test_all_routes.py new file mode 100644 index 0000000..d0ede41 --- /dev/null +++ b/backend/test_all_routes.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +""" +Test all routes in the IRT Bank Soal application. +Tests each endpoint and checks for Internal Server Errors. +""" + +import json +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from urllib.parse import urlparse + +import httpx + +BASE_URL = "http://localhost:8000" + +# All routes from OpenAPI spec +API_ROUTES = [ + # Root endpoints + ("GET", "/"), + ("GET", "/health"), + # Session endpoints + ("POST", "/api/v1/session/"), + ("GET", "/api/v1/session/{session_id}"), + ("POST", "/api/v1/session/{session_id}/complete"), + ("GET", "/api/v1/session/{session_id}/next_item"), + ("POST", "/api/v1/session/{session_id}/submit_answer"), + # Tryout endpoints + ("GET", "/api/v1/tryout/"), + ("GET", "/api/v1/tryout/{tryout_id}/config"), + ("PUT", "/api/v1/tryout/{tryout_id}/normalization"), + ("GET", "/api/v1/tryout/{tryout_id}/calibration-status"), + ("POST", "/api/v1/tryout/{tryout_id}/calibrate"), + ("POST", "/api/v1/tryout/{tryout_id}/calibrate/{item_id}"), + # WordPress endpoints + ("POST", "/api/v1/wordpress/sync_users"), + ("POST", "/api/v1/wordpress/verify_session"), + ("GET", "/api/v1/wordpress/website/{website_id}/users"), + ("GET", "/api/v1/wordpress/website/{website_id}/user/{wp_user_id}"), + # Reports endpoints + ("POST", "/api/v1/reports/schedule"), + ("GET", "/api/v1/reports/schedule/{schedule_id}"), + ("GET", "/api/v1/reports/schedules"), + ("DELETE", "/api/v1/reports/schedule/{schedule_id}"), + ("POST", "/api/v1/reports/schedule/{schedule_id}/export"), + ("GET", "/api/v1/reports/student/performance"), + ("GET", "/api/v1/reports/student/performance/export/{format}"), + ("GET", "/api/v1/reports/items/analysis"), + ("GET", "/api/v1/reports/items/analysis/export/{format}"), + ("GET", "/api/v1/reports/calibration/status"), + ("GET", "/api/v1/reports/calibration/status/export/{format}"), + ("GET", "/api/v1/reports/tryout/comparison"), + ("GET", "/api/v1/reports/tryout/comparison/export/{format}"), + ("GET", "/api/v1/reports/export/{schedule_id}/{format}"), + # Import/Export endpoints + ("POST", "/api/v1/import-export/preview"), + ("POST", "/api/v1/import-export/questions"), + ("GET", "/api/v1/import-export/export/questions"), + ("POST", "/api/v1/import-export/tryout-json/preview"), + ("POST", "/api/v1/import-export/tryout-json"), + # Admin AI endpoints + ("POST", "/api/v1/admin/ai/generate-preview"), + ("POST", "/api/v1/admin/ai/generate-save"), + ("GET", "/api/v1/admin/ai/stats"), + ("GET", "/api/v1/admin/ai/models"), + # Admin endpoints + ("POST", "/api/v1/admin/{tryout_id}/calibrate"), + ("POST", "/api/v1/admin/{tryout_id}/toggle-ai-generation"), + ("POST", "/api/v1/admin/{tryout_id}/reset-normalization"), + # Admin CAT endpoints + ("POST", "/api/v1/admin/cat/test"), + ("GET", "/api/v1/admin/session/{session_id}/status"), + # Admin web routes (HTML pages) + ("GET", "/admin"), + ("GET", "/admin/login"), + ("POST", "/admin/login"), + ("POST", "/admin/logout"), + ("GET", "/admin/password"), + ("POST", "/admin/password"), + ("GET", "/admin/dashboard"), + ("GET", "/admin/questions"), + ("GET", "/admin/questions/{item_id}"), + ("GET", "/admin/questions/{item_id}/quality"), + ("GET", "/admin/exams"), + ("GET", "/admin/exams/{tryout_id}"), + ("GET", "/admin/reports"), + ("GET", "/admin/settings"), + ("GET", "/admin/hierarchy"), + ("GET", "/admin/websites"), + ("POST", "/admin/websites"), + ("GET", "/admin/websites/new"), + ("GET", "/admin/websites/{website_id}"), + ("POST", "/admin/websites/{website_id}"), + ("POST", "/admin/websites/{website_id}/delete"), + ("GET", "/admin/tryout-import"), + ("GET", "/admin/tryout-import/preview"), + ("POST", "/admin/tryout-import"), + ("GET", "/admin/snapshot-questions"), + ("POST", "/admin/snapshot-questions/promote-bulk"), + ("GET", "/admin/calibration-status"), + ("GET", "/admin/item-statistics"), + ("GET", "/admin/sessions"), + ("GET", "/admin/basis-items"), + ("GET", "/admin/basis-items/{item_id}"), + ("POST", "/admin/basis-items/{item_id}/generate"), + ("POST", "/admin/basis-items/{item_id}/generate/review-bulk"), + ("GET", "/admin/basis-items/{item_id}/generate/variants/{variant_id}"), +] + +# Placeholder values for path parameters +PLACEHOLDERS = { + "{session_id}": "test-session-123", + "{tryout_id}": "test-tryout-123", + "{item_id}": "1", + "{website_id}": "1", + "{wp_user_id}": "123", + "{schedule_id}": "test-schedule-123", + "{format}": "xlsx", + "{variant_id}": "test-variant-123", +} + +# Minimal request bodies for POST endpoints +REQUEST_BODIES = { + "/api/v1/session/": { + "session_id": "test", + "tryout_id": "test", + "wp_user_id": "123", + "website_id": 1, + "scoring_mode": "ctt", + }, + "/api/v1/session/{session_id}/complete": { + "end_time": "2024-01-01T00:00:00Z", + "user_answers": [], + }, + "/api/v1/session/{session_id}/submit_answer": { + "item_id": 1, + "response": "A", + "time_spent": 10, + }, + "/api/v1/tryout/{tryout_id}/normalization": { + "normalization_mode": "static", + "static_rataan": 500, + "static_sb": 100, + }, + "/api/v1/wordpress/sync_users": {}, # Requires proper auth header + "/api/v1/wordpress/verify_session": { + "website_id": 1, + "wp_user_id": "123", + "token": "test", + }, + "/api/v1/reports/schedule": { + "tryout_id": "test", + "report_type": "student_performance", + }, + "/api/v1/admin/ai/generate-preview": { + "basis_item_id": 1, + "target_level": "sulit", + "ai_model": "qwen/qwen2.5-32b-instruct", + }, + "/api/v1/admin/ai/generate-save": { + "stem": "Test?", + "options": {"A": "a", "B": "b", "C": "c", "D": "d"}, + "correct": "A", + "tryout_id": "test", + "website_id": 1, + "basis_item_id": 1, + "slot": 1, + "level": "sulit", + "ai_model": "qwen/qwen2.5-32b-instruct", + }, + "/api/v1/admin/cat/test": {"tryout_id": "test", "website_id": 1}, + "/api/v1/admin/{tryout_id}/calibrate": {}, + "/api/v1/admin/{tryout_id}/toggle-ai-generation": {}, + "/api/v1/admin/{tryout_id}/reset-normalization": {}, + "/api/v1/import-export/preview": None, # Requires file upload + "/api/v1/import-export/questions": None, # Requires file upload + "/api/v1/import-export/tryout-json/preview": None, # Requires file upload + "/api/v1/import-export/tryout-json": None, # Requires file upload +} + + +def expand_route(method: str, route: str) -> list: + """Expand route with placeholders.""" + expanded = [] + test_route = route + for placeholder, value in PLACEHOLDERS.items(): + if placeholder in test_route: + test_route = test_route.replace(placeholder, value) + expanded.append((method, test_route)) + return expanded + + +def test_route(client: httpx.Client, method: str, route: str) -> dict: + """Test a single route.""" + # Expand placeholders + expanded = expand_route(method, route) + if not expanded: + return { + "route": route, + "method": method, + "error": "Could not expand route", + "status_code": None, + } + + method, test_route = expanded[0] + + # Determine request body + body = None + request_body = REQUEST_BODIES.get(route, REQUEST_BODIES.get(test_route, {})) + if request_body is not None: + body = request_body + + # Determine query params + params = {} + if "export/questions" in route: + params = {"tryout_id": "test"} + + headers = {"X-Website-ID": "1"} + + try: + response = client.request( + method=method, + url=BASE_URL + test_route, + json=body if body and method in ["POST", "PUT", "PATCH"] else None, + params=params, + headers=headers, + timeout=10.0, + follow_redirects=True, + ) + + is_500 = response.status_code == 500 + is_ise = "Internal Server Error" in response.text + + return { + "route": route, + "method": method, + "expanded_route": test_route, + "status_code": response.status_code, + "has_500": is_500, + "has_ise": is_ise, + "response_preview": response.text[:200] if response.text else "", + "error": None, + } + except httpx.TimeoutException: + return { + "route": route, + "method": method, + "expanded_route": test_route, + "status_code": None, + "has_500": False, + "has_ise": False, + "response_preview": "", + "error": "Timeout", + } + except Exception as e: + return { + "route": route, + "method": method, + "expanded_route": test_route, + "status_code": None, + "has_500": False, + "has_ise": False, + "response_preview": "", + "error": str(e), + } + + +def main(): + print("=" * 80) + print("Testing all IRT Bank Soal routes for Internal Server Errors") + print("=" * 80) + print() + + results = [] + has_errors = False + + with httpx.Client(timeout=30.0) as client: + for method, route in API_ROUTES: + result = test_route(client, method, route) + results.append(result) + + status = result["status_code"] + error_marker = "" + + if result["error"]: + error_marker = f" [ERROR: {result['error']}]" + has_errors = True + elif status and status >= 500: + error_marker = f" [INTERNAL SERVER ERROR!]" + has_errors = True + elif status and status == 500: + error_marker = f" [500 - INTERNAL SERVER ERROR!]" + has_errors = True + elif "Internal Server Error" in str(result.get("response_preview", "")): + error_marker = " [500 - INTERNAL SERVER ERROR!]" + has_errors = True + + status_str = str(status) if status else "N/A" + print(f"{method:6} {route:<60} -> {status_str}{error_marker}") + + print() + print("=" * 80) + print("SUMMARY") + print("=" * 80) + + total = len(results) + successful = sum(1 for r in results if r["status_code"] and r["status_code"] < 500) + client_errors = sum( + 1 for r in results if r["status_code"] and 400 <= r["status_code"] < 500 + ) + server_errors = sum( + 1 for r in results if r["status_code"] and r["status_code"] >= 500 + ) + timeouts = sum(1 for r in results if r["error"] == "Timeout") + exceptions = sum(1 for r in results if r["error"] and r["error"] != "Timeout") + ise_errors = sum(1 for r in results if r.get("has_ise") or r.get("has_500")) + + print(f"Total routes tested: {total}") + print(f"Successful (2xx): {successful}") + print(f"Client errors (4xx): {client_errors}") + print(f"Server errors (5xx): {server_errors}") + print(f"Timeouts: {timeouts}") + print(f"Exceptions: {exceptions}") + print(f"Internal Server Errors: {ise_errors}") + print() + + if has_errors: + print("Routes with issues:") + for r in results: + if r["status_code"] and r["status_code"] >= 500: + print(f" - {r['method']} {r['route']} -> {r['status_code']}") + elif r["error"]: + print(f" - {r['method']} {r['route']} -> ERROR: {r['error']}") + elif r.get("has_ise"): + print(f" - {r['method']} {r['route']} -> Internal Server Error") + + print() + if ise_errors == 0 and exceptions == 0: + print("✅ All routes passed! No Internal Server Errors detected.") + return 0 + else: + print("❌ Some routes have issues. Please review the output above.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/test_debug_login.py b/backend/test_debug_login.py new file mode 100644 index 0000000..8389044 --- /dev/null +++ b/backend/test_debug_login.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +""" +Debug login issue. +""" + +import re + +import httpx + +BASE_URL = "http://localhost:8000" + + +def main(): + print("Debugging login issue...") + + with httpx.Client(base_url=BASE_URL, timeout=30.0) as client: + # Get login page + response = client.get("/admin/login") + print(f"Login page status: {response.status_code}") + + # Extract CSRF token + match = re.search(r'name="csrf_token" value="([^"]+)"', response.text) + csrf_token = match.group(1) if match else "" + print(f"CSRF token: {csrf_token[:30]}...") + + # Look for any error messages in the page + if "error" in response.text.lower(): + print("\n=== Error messages in login page ===") + # Extract error div content + error_match = re.search( + r'

(.*?)
', response.text, re.DOTALL + ) + if error_match: + print(error_match.group(1)) + else: + # Print a portion of the page around "error" + idx = response.text.lower().find("error") + print(response.text[max(0, idx - 50) : idx + 200]) + + # Try to check if Redis is accessible via the health endpoint + health = client.get("/health") + print(f"\nHealth check: {health.text}") + + # Print login page content for inspection + print("\n=== Login page content (first 2000 chars) ===") + print(response.text[:2000]) + + +if __name__ == "__main__": + main() diff --git a/backend/test_debug_login2.py b/backend/test_debug_login2.py new file mode 100644 index 0000000..a9b31e4 --- /dev/null +++ b/backend/test_debug_login2.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +""" +Debug login issue - check Redis. +""" + +import re + +import httpx + +BASE_URL = "http://localhost:8000" + + +def main(): + print("Debugging login issue - detailed...") + + with httpx.Client(base_url=BASE_URL, timeout=30.0) as client: + # Get login page + response = client.get("/admin/login") + print(f"Login page status: {response.status_code}") + + # Extract CSRF token + match = re.search(r'name="csrf_token" value="([^"]+)"', response.text) + csrf_token = match.group(1) if match else "" + print(f"CSRF token: {csrf_token}") + + # Print ALL cookies + print(f"\nCookies before login: {dict(client.cookies)}") + + # Submit login + response = client.post( + "/admin/login", + data={ + "username": "admin", + "password": "admin123", + "csrf_token": csrf_token, + }, + follow_redirects=False, # Don't follow redirect to see the response + ) + + print(f"\nLogin response status: {response.status_code}") + print(f"Login response headers: {dict(response.headers)}") + print(f"Cookies after login: {dict(client.cookies)}") + + # Check if response has any content + print(f"\nLogin response content (first 1000 chars):") + print(response.text[:1000]) + + # Now try with a redirect follow + print("\n\n=== Trying with redirect follow ===") + client2 = httpx.Client(base_url=BASE_URL, timeout=30.0) + + response = client2.get("/admin/login") + match = re.search(r'name="csrf_token" value="([^"]+)"', response.text) + csrf_token = match.group(1) if match else "" + + response = client2.post( + "/admin/login", + data={ + "username": "admin", + "password": "admin123", + "csrf_token": csrf_token, + }, + follow_redirects=True, + ) + + print(f"Final status after redirect: {response.status_code}") + print(f"Final URL: {response.url}") + print(f"Final cookies: {dict(client2.cookies)}") + print(f"Final content (first 500 chars): {response.text[:500]}") + + +if __name__ == "__main__": + main() diff --git a/backend/test_debug_traceback.py b/backend/test_debug_traceback.py new file mode 100644 index 0000000..886fd3a --- /dev/null +++ b/backend/test_debug_traceback.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Debug the 500 Internal Server Error on variant approval - fixed CSRF. +""" + +import re + +import httpx + +BASE_URL = "http://localhost:8000" + + +def login(client: httpx.Client) -> bool: + """Login and maintain session.""" + response = client.get("/admin/login") + if response.status_code != 200: + return False + + match = re.search(r'name="csrf_token" value="([^"]+)"', response.text) + csrf_token = match.group(1) if match else "" + + if not csrf_token: + return False + + response = client.post( + "/admin/login", + data={ + "username": "admin", + "password": "admin123", + "csrf_token": csrf_token, + }, + follow_redirects=True, + ) + + return response.status_code == 200 and "/admin/dashboard" in str(response.url) + + +def get_csrf_from_page(client: httpx.Client, page_url: str) -> tuple: + """Get CSRF token from a specific page and return both token and response.""" + response = client.get(page_url, follow_redirects=True) + if response.status_code == 200: + match = re.search(r'name="csrf_token" value="([^"]+)"', response.text) + if match: + return match.group(1), response + return "", response + + +def main(): + print("=" * 80) + print("Debugging 500 Internal Server Error on Variant Approval") + print("=" * 80) + + with httpx.Client(base_url=BASE_URL, timeout=60.0) as client: + print("\n1. Logging in...") + if not login(client): + print(" ❌ Login failed") + return + print(" ✅ Login successful") + + # Test 1: Variant approval - get CSRF from the actual review page + print("\n2. Testing variant approval...") + + # First access the review page to get the CSRF token + csrf_token, page_response = get_csrf_from_page( + client, "/admin/questions/4/generate?tab=review" + ) + print(f" Page URL: {page_response.url}") + print(f" Page status: {page_response.status_code}") + print(f" CSRF token: {csrf_token[:30] if csrf_token else 'None'}...") + + # If we got redirected, we can't test this endpoint + if "/generate" not in str(page_response.url): + print( + " ⚠️ Redirected away from AI playground - item may not exist or not be AI-generated" + ) + print(" Skipping this test...") + else: + # Submit the form + response = client.post( + "/admin/questions/4/generate/review-bulk", + data={ + "item_ids": "4", + "action": "approved", + "tab": "review", + "csrf_token": csrf_token, + }, + follow_redirects=True, + ) + + print(f" Response status: {response.status_code}") + + # Extract and print the full traceback + if "Traceback" in response.text: + idx = response.text.find("Traceback") + print("\n" + "=" * 80) + print("FULL TRACEBACK:") + print("=" * 80) + print(response.text[idx:]) + print("=" * 80) + elif response.status_code == 500: + print("\n ⚠️ Got 500 error but no traceback in response") + print(f" Response preview: {response.text[:500]}") + else: + print(f" Response preview: {response.text[:500]}") + + # Test 2: Generate variants + print("\n3. Testing generate variants...") + + csrf_token, page_response = get_csrf_from_page( + client, "/admin/questions/4/generate?tab=generate" + ) + print(f" Page URL: {page_response.url}") + print(f" Page status: {page_response.status_code}") + + if "/generate" not in str(page_response.url): + print(" ⚠️ Redirected away from AI playground") + else: + response = client.post( + "/admin/questions/4/generate", + data={ + "target_level": "mudah", + "ai_model": "meta-llama/llama-4-maverick:free", + "generation_count": "1", + "operator_notes": "", + "csrf_token": csrf_token, + }, + follow_redirects=True, + ) + + print(f" Response status: {response.status_code}") + + if "Traceback" in response.text: + idx = response.text.find("Traceback") + print("\n" + "=" * 80) + print("FULL TRACEBACK:") + print("=" * 80) + print(response.text[idx:]) + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/test_error.py b/backend/test_error.py similarity index 100% rename from test_error.py rename to backend/test_error.py diff --git a/test_fetch.py b/backend/test_fetch.py similarity index 100% rename from test_fetch.py rename to backend/test_fetch.py diff --git a/backend/test_form_posts.py b/backend/test_form_posts.py new file mode 100644 index 0000000..4dbe16e --- /dev/null +++ b/backend/test_form_posts.py @@ -0,0 +1,404 @@ +#!/usr/bin/env python3 +""" +Test all form POST endpoints for Internal Server Errors. +""" + +import json +import sys + +import httpx + +BASE_URL = "http://localhost:8000" + +# All form POST endpoints from admin_web.py +FORM_POST_ENDPOINTS = [ + # (endpoint, method, form_data, description) + ( + "/admin/login", + "POST", + {"username": "admin", "password": "admin123"}, + "Admin login", + ), + ( + "/admin/password", + "POST", + { + "old_password": "admin123", + "new_password": "admin123", + "re_new_password": "admin123", + }, + "Change password", + ), + ( + "/admin/websites", + "POST", + { + "site_name": "Test Site", + "site_url": "https://test.example.com", + }, + "Create website", + ), + ( + "/admin/websites/1/edit", + "POST", + { + "site_name": "Updated Test Site", + "site_url": "https://updated.example.com", + }, + "Edit website", + ), + ("/admin/websites/1/delete", "POST", {}, "Delete website"), + ( + "/admin/tryout-import/preview", + "POST", + { + "website_id": "1", + }, + "Tryout import preview (no file)", + ), + ( + "/admin/tryout-import", + "POST", + { + "website_id": "1", + "preview_token": "invalid-token", + }, + "Tryout import submit", + ), + ( + "/admin/snapshot-questions/promote-bulk", + "POST", + { + "snapshot_id": "1", + "snapshot_question_ids": [], + }, + "Promote snapshot questions bulk", + ), + ( + "/admin/basis-items/1/generate", + "POST", + { + "target_level": "mudah", + "ai_model": "", + "generation_count": "1", + "operator_notes": "", + }, + "Generate variants for basis item", + ), + ( + "/admin/basis-items/1/review-bulk", + "POST", + { + "item_ids": ["1"], + "action": "approved", + }, + "Review bulk variants", + ), + ( + "/admin/questions/1/generate", + "POST", + { + "target_level": "mudah", + "ai_model": "meta-llama/llama-4-maverick:free", + "generation_count": "1", + "operator_notes": "", + "include_note_for_admin": True, + "include_note_in_prompt": False, + }, + "Generate question variants", + ), + ( + "/admin/questions/1/generate/review-bulk", + "POST", + { + "item_ids": ["1"], + "action": "approved", + "tab": "review", + }, + "Review question variants bulk", + ), +] + +# API POST endpoints +API_POST_ENDPOINTS = [ + ( + "/api/v1/session/", + { + "session_id": "test-session-123", + "tryout_id": "test", + "wp_user_id": "123", + "website_id": 1, + "scoring_mode": "ctt", + }, + "Create session", + ), + ( + "/api/v1/session/test-session-123/complete", + { + "end_time": "2024-01-01T00:00:00Z", + "user_answers": [], + }, + "Complete session", + ), + ( + "/api/v1/session/test-session-123/submit_answer", + { + "item_id": 1, + "response": "A", + "time_spent": 10, + }, + "Submit answer", + ), + ( + "/api/v1/wordpress/verify_session", + { + "website_id": 1, + "wp_user_id": "123", + "token": "test", + }, + "Verify WordPress session", + ), + ( + "/api/v1/reports/schedule", + { + "tryout_id": "test", + "report_type": "student_performance", + }, + "Schedule report", + ), + ( + "/api/v1/admin/cat/test", + { + "tryout_id": "test", + "website_id": 1, + }, + "Test CAT algorithm", + ), + ("/api/v1/admin/1/calibrate", {}, "Calibrate tryout"), + ("/api/v1/admin/1/toggle-ai-generation", {}, "Toggle AI generation"), + ("/api/v1/admin/1/reset-normalization", {}, "Reset normalization"), +] + + +def get_admin_session(): + """Login and get session cookies for admin access.""" + with httpx.Client(base_url=BASE_URL, follow_redirects=True, timeout=30.0) as client: + # Try to login + response = client.post( + "/admin/login", + data={ + "username": "admin", + "password": "admin123", + }, + ) + print(f"Login response: {response.status_code}") + + # Check if we have admin access + response = client.get("/admin") + print(f"Admin page response: {response.status_code}") + + # Return cookies + return client.cookies + + +def test_endpoint( + client: httpx.Client, endpoint: str, method: str, data: dict, cookies: dict = None +) -> dict: + """Test a single endpoint.""" + headers = {"X-Website-ID": "1"} + + try: + if method == "POST": + # Check if this looks like form data or JSON + if isinstance(data, dict) and all( + isinstance(v, str) or v is None for v in data.values() + ): + # Form data + response = client.post( + endpoint, + data=data, + headers=headers, + cookies=cookies, + timeout=30.0, + follow_redirects=True, + ) + else: + # JSON data + response = client.post( + endpoint, + json=data, + headers=headers, + cookies=cookies, + timeout=30.0, + follow_redirects=True, + ) + else: + response = client.request( + method, + endpoint, + headers=headers, + cookies=cookies, + timeout=30.0, + follow_redirects=True, + ) + + # Check for internal server error + has_ise = ( + response.status_code == 500 + or "Internal Server Error" in response.text + or "500 Internal Server Error" in response.text + ) + + # Check for traceback + has_traceback = "Traceback" in response.text + + return { + "endpoint": endpoint, + "method": method, + "status_code": response.status_code, + "has_ise": has_ise, + "has_traceback": has_traceback, + "response_preview": response.text[:500] if response.text else "", + "redirect_location": response.headers.get("location", ""), + } + except httpx.TimeoutException: + return { + "endpoint": endpoint, + "method": method, + "status_code": None, + "has_ise": False, + "has_traceback": False, + "response_preview": "", + "error": "Timeout", + } + except Exception as e: + return { + "endpoint": endpoint, + "method": method, + "status_code": None, + "has_ise": False, + "has_traceback": False, + "response_preview": "", + "error": str(e), + } + + +def main(): + print("=" * 80) + print("Testing all Form POST endpoints for Internal Server Errors") + print("=" * 80) + print() + + # Get admin session + print("Getting admin session...") + cookies = get_admin_session() + print() + + results = [] + has_errors = False + + with httpx.Client(base_url=BASE_URL, follow_redirects=True, timeout=30.0) as client: + # Test admin form POST endpoints + print("-" * 80) + print("ADMIN FORM POST ENDPOINTS") + print("-" * 80) + + for endpoint, method, data, description in FORM_POST_ENDPOINTS: + print(f"\nTesting: {description}") + print(f" Endpoint: {endpoint}") + + result = test_endpoint(client, endpoint, method, data, cookies) + results.append((description, result)) + + status = result["status_code"] + error_details = "" + + if result.get("error"): + error_details = f" [ERROR: {result['error']}]" + has_errors = True + elif result.get("has_traceback"): + error_details = f" [TRACEBACK!]" + has_errors = True + print(f" Response: {result['response_preview'][:1000]}") + elif result.get("has_ise"): + error_details = f" [INTERNAL SERVER ERROR!]" + has_errors = True + print(f" Response: {result['response_preview'][:1000]}") + + status_str = str(status) if status else "N/A" + print(f" Status: {status_str}{error_details}") + + if result.get("redirect_location"): + print(f" Redirect: {result['redirect_location']}") + + # Test API POST endpoints + print() + print("-" * 80) + print("API POST ENDPOINTS") + print("-" * 80) + + for endpoint, data, description in API_POST_ENDPOINTS: + print(f"\nTesting: {description}") + print(f" Endpoint: {endpoint}") + + result = test_endpoint(client, endpoint, "POST", data, cookies) + results.append((description, result)) + + status = result["status_code"] + error_details = "" + + if result.get("error"): + error_details = f" [ERROR: {result['error']}]" + has_errors = True + elif result.get("has_traceback"): + error_details = f" [TRACEBACK!]" + has_errors = True + print(f" Response: {result['response_preview'][:1000]}") + elif result.get("has_ise"): + error_details = f" [INTERNAL SERVER ERROR!]" + has_errors = True + print(f" Response: {result['response_preview'][:1000]}") + + status_str = str(status) if status else "N/A" + print(f" Status: {status_str}{error_details}") + + # Summary + print() + print("=" * 80) + print("SUMMARY") + print("=" * 80) + + total = len(results) + ise_errors = sum(1 for _, r in results if r.get("has_ise")) + tracebacks = sum(1 for _, r in results if r.get("has_traceback")) + timeouts = sum(1 for _, r in results if r.get("error") == "Timeout") + exceptions = sum( + 1 for _, r in results if r.get("error") and r.get("error") != "Timeout" + ) + + print(f"Total endpoints tested: {total}") + print(f"Internal Server Errors: {ise_errors}") + print(f"Tracebacks: {tracebacks}") + print(f"Timeouts: {timeouts}") + print(f"Exceptions: {exceptions}") + print() + + if ise_errors > 0 or tracebacks > 0: + print("Endpoints with issues:") + for desc, r in results: + if r.get("has_ise") or r.get("has_traceback"): + print(f" - {desc}: {r['endpoint']} -> {r['status_code']}") + if r.get("has_traceback"): + print(f" Traceback detected in response") + + print() + if has_errors: + print("❌ Some endpoints have issues. Please review the output above.") + return 1 + else: + print("✅ All endpoints passed! No Internal Server Errors detected.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/test_session_debug.py b/backend/test_session_debug.py new file mode 100644 index 0000000..e228401 --- /dev/null +++ b/backend/test_session_debug.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" +Debug redirect on AI playground page. +""" + +import re + +import httpx + +BASE_URL = "http://localhost:8000" + + +def main(): + print("Debugging redirect on AI playground page...") + + with httpx.Client(base_url=BASE_URL, timeout=30.0) as client: + # Login first + response = client.get("/admin/login") + match = re.search(r'name="csrf_token" value="([^"]+)"', response.text) + csrf_token = match.group(1) if match else "" + + response = client.post( + "/admin/login", + data={ + "username": "admin", + "password": "admin123", + "csrf_token": csrf_token, + }, + follow_redirects=True, + ) + print(f"Logged in, URL: {response.url}") + + # Get AI playground page without following redirects + print("\nGetting AI playground page without following redirects...") + response = client.get( + "/admin/questions/1/generate?tab=review", follow_redirects=False + ) + print(f"Status: {response.status_code}") + print(f"Location header: {response.headers.get('location', 'None')}") + + # Follow the redirect + if response.headers.get("location"): + redirect_url = response.headers["location"] + print(f"\nFollowing redirect to: {redirect_url}") + response = client.get(redirect_url, follow_redirects=True) + print(f"Final status: {response.status_code}") + print(f"Final URL: {response.url}") + + # Check for forms + post_forms = re.findall( + r']*method="post"[^>]*>', response.text, re.IGNORECASE + ) + print(f"\nFound {len(post_forms)} POST forms") + + # Look for CSRF token + csrf_inputs = re.findall( + r']*name="csrf_token"[^>]*>', response.text, re.IGNORECASE + ) + if csrf_inputs: + print(f"Found {len(csrf_inputs)} CSRF token inputs:") + for inp in csrf_inputs[:3]: + print(f" {inp}") + else: + print("No CSRF token inputs found") + + +if __name__ == "__main__": + main() diff --git a/backend/test_variant_approval.py b/backend/test_variant_approval.py new file mode 100644 index 0000000..eeefccb --- /dev/null +++ b/backend/test_variant_approval.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +""" +Test variant approval endpoints with proper session handling. +""" + +import re +import sys + +import httpx + +BASE_URL = "http://localhost:8000" + + +def get_csrf_token(client: httpx.Client, page_url: str) -> str: + """Extract CSRF token from a page.""" + try: + response = client.get(page_url) + if response.status_code == 200: + match = re.search(r'name="csrf_token" value="([^"]+)"', response.text) + if match: + return match.group(1) + except Exception as e: + print(f" Error getting CSRF token from {page_url}: {e}") + return "" + + +def login(client: httpx.Client) -> bool: + """Login and maintain session.""" + # Get login page + response = client.get("/admin/login") + if response.status_code != 200: + print(f" Failed to get login page: {response.status_code}") + return False + + match = re.search(r'name="csrf_token" value="([^"]+)"', response.text) + csrf_token = match.group(1) if match else "" + + if not csrf_token: + print(" Failed to get CSRF token") + return False + + # Submit login - follow redirects to complete login + response = client.post( + "/admin/login", + data={ + "username": "admin", + "password": "admin123", + "csrf_token": csrf_token, + }, + follow_redirects=True, + ) + + if response.status_code == 200 and "/admin/dashboard" in str(response.url): + print(" ✅ Successfully logged in!") + return True + + print(f" Login failed: {response.status_code}, URL: {response.url}") + return False + + +def test_variant_approval(client: httpx.Client) -> dict: + """Test the variant approval endpoint.""" + + # Get CSRF token from the review page + csrf_token = get_csrf_token(client, "/admin/questions/1/generate?tab=review") + + if not csrf_token: + return { + "status_code": None, + "has_ise": False, + "has_traceback": False, + "error": "Could not get CSRF token - likely not authenticated", + "response_preview": "", + } + + # Submit variant approval + response = client.post( + "/admin/questions/1/generate/review-bulk", + data={ + "item_ids": "1", + "action": "approved", + "tab": "review", + "csrf_token": csrf_token, + }, + follow_redirects=True, + ) + + print(f" Response status: {response.status_code}") + print(f" Final URL: {response.url}") + + # Check for errors + has_ise = response.status_code == 500 or "Internal Server Error" in response.text + has_traceback = "Traceback" in response.text + + if has_traceback: + print("\n === TRACEBACK DETECTED ===") + # Extract just the traceback part + if "Traceback" in response.text: + idx = response.text.find("Traceback") + print(response.text[idx : idx + 3000]) + print(" ==========================\n") + + return { + "status_code": response.status_code, + "has_ise": has_ise, + "has_traceback": has_traceback, + "response_preview": response.text[:1000], + } + + +def test_basis_item_review(client: httpx.Client) -> dict: + """Test the basis item review bulk endpoint.""" + + # Get CSRF token from the basis item page + csrf_token = get_csrf_token(client, "/admin/basis-items/1") + + if not csrf_token: + return { + "status_code": None, + "has_ise": False, + "has_traceback": False, + "error": "Could not get CSRF token - likely not authenticated", + "response_preview": "", + } + + # Submit basis item review + response = client.post( + "/admin/basis-items/1/review-bulk", + data={ + "item_ids": "1", + "action": "approved", + "csrf_token": csrf_token, + }, + follow_redirects=True, + ) + + print(f" Response status: {response.status_code}") + print(f" Final URL: {response.url}") + + # Check for errors + has_ise = response.status_code == 500 or "Internal Server Error" in response.text + has_traceback = "Traceback" in response.text + + if has_traceback: + print("\n === TRACEBACK DETECTED ===") + if "Traceback" in response.text: + idx = response.text.find("Traceback") + print(response.text[idx : idx + 3000]) + print(" ==========================\n") + + return { + "status_code": response.status_code, + "has_ise": has_ise, + "has_traceback": has_traceback, + "response_preview": response.text[:1000], + } + + +def test_snapshot_promote(client: httpx.Client) -> dict: + """Test the snapshot questions promote bulk endpoint.""" + + # Get CSRF token from the hierarchy page + csrf_token = get_csrf_token(client, "/admin/hierarchy") + + if not csrf_token: + return { + "status_code": None, + "has_ise": False, + "has_traceback": False, + "error": "Could not get CSRF token - likely not authenticated", + "response_preview": "", + } + + # Submit snapshot promote (with empty list) + response = client.post( + "/admin/snapshot-questions/promote-bulk", + data={ + "snapshot_id": "1", + "snapshot_question_ids": "", + "csrf_token": csrf_token, + }, + follow_redirects=True, + ) + + print(f" Response status: {response.status_code}") + + # Check for errors + has_ise = response.status_code == 500 or "Internal Server Error" in response.text + has_traceback = "Traceback" in response.text + + if has_traceback: + print("\n === TRACEBACK DETECTED ===") + if "Traceback" in response.text: + idx = response.text.find("Traceback") + print(response.text[idx : idx + 3000]) + print(" ==========================\n") + + return { + "status_code": response.status_code, + "has_ise": has_ise, + "has_traceback": has_traceback, + "response_preview": response.text[:1000], + } + + +def test_tryout_import_preview(client: httpx.Client) -> dict: + """Test the tryout import preview endpoint.""" + + csrf_token = get_csrf_token(client, "/admin/tryout-import") + + if not csrf_token: + return { + "status_code": None, + "has_ise": False, + "has_traceback": False, + "error": "Could not get CSRF token", + "response_preview": "", + } + + # Submit tryout import preview (without file) + response = client.post( + "/admin/tryout-import/preview", + data={ + "website_id": "1", + "csrf_token": csrf_token, + }, + follow_redirects=True, + ) + + print(f" Response status: {response.status_code}") + + has_ise = response.status_code == 500 or "Internal Server Error" in response.text + has_traceback = "Traceback" in response.text + + if has_traceback: + print("\n === TRACEBACK DETECTED ===") + if "Traceback" in response.text: + idx = response.text.find("Traceback") + print(response.text[idx : idx + 3000]) + print(" ==========================\n") + + return { + "status_code": response.status_code, + "has_ise": has_ise, + "has_traceback": has_traceback, + "response_preview": response.text[:1000], + } + + +def test_website_crud(client: httpx.Client) -> dict: + """Test website creation endpoint.""" + + csrf_token = get_csrf_token(client, "/admin/websites") + + if not csrf_token: + return { + "status_code": None, + "has_ise": False, + "has_traceback": False, + "error": "Could not get CSRF token", + "response_preview": "", + } + + # Submit website creation + response = client.post( + "/admin/websites", + data={ + "site_name": "Test Site", + "site_url": "https://test.example.com", + "csrf_token": csrf_token, + }, + follow_redirects=True, + ) + + print(f" Response status: {response.status_code}") + + has_ise = response.status_code == 500 or "Internal Server Error" in response.text + has_traceback = "Traceback" in response.text + + if has_traceback: + print("\n === TRACEBACK DETECTED ===") + if "Traceback" in response.text: + idx = response.text.find("Traceback") + print(response.text[idx : idx + 3000]) + print(" ==========================\n") + + return { + "status_code": response.status_code, + "has_ise": has_ise, + "has_traceback": has_traceback, + "response_preview": response.text[:1000], + } + + +def main(): + print("=" * 80) + print("Testing Form POST Endpoints for Internal Server Errors") + print("=" * 80) + print() + + results = [] + + with httpx.Client(base_url=BASE_URL, timeout=30.0) as client: + # Login + print("Step 1: Logging in...") + if not login(client): + print("❌ Login failed") + return 1 + print() + + # Test 1: Variant approval + print( + "Step 2: Testing variant approval (/admin/questions/1/generate/review-bulk)..." + ) + result1 = test_variant_approval(client) + results.append(("Variant approval", result1)) + print() + + # Test 2: Basis item review + print("Step 3: Testing basis item review (/admin/basis-items/1/review-bulk)...") + result2 = test_basis_item_review(client) + results.append(("Basis item review", result2)) + print() + + # Test 3: Snapshot promote + print( + "Step 4: Testing snapshot promote (/admin/snapshot-questions/promote-bulk)..." + ) + result3 = test_snapshot_promote(client) + results.append(("Snapshot promote", result3)) + print() + + # Test 4: Tryout import preview + print("Step 5: Testing tryout import preview (/admin/tryout-import/preview)...") + result4 = test_tryout_import_preview(client) + results.append(("Tryout import preview", result4)) + print() + + # Test 5: Website creation + print("Step 6: Testing website creation (/admin/websites)...") + result5 = test_website_crud(client) + results.append(("Website creation", result5)) + print() + + # Summary + print("=" * 80) + print("RESULTS SUMMARY") + print("=" * 80) + + all_good = True + for name, result in results: + if result.get("has_ise") or result.get("has_traceback"): + print(f"❌ {name}: INTERNAL SERVER ERROR!") + print(f" Status: {result['status_code']}") + print(f" Preview: {result['response_preview'][:200]}...") + all_good = False + elif result.get("error"): + print(f"⚠️ {name}: {result['error']}") + elif result["status_code"] in [200, 303]: + print(f"✅ {name}: OK ({result['status_code']})") + else: + print(f"⚠️ {name}: Unexpected status {result['status_code']}") + + print() + if all_good: + print("✅ All form POST endpoints passed! No Internal Server Errors detected.") + return 0 + else: + print("❌ Some endpoints have issues. Please review the output above.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_auth_scope.py b/backend/tests/test_auth_scope.py similarity index 72% rename from tests/test_auth_scope.py rename to backend/tests/test_auth_scope.py index 0b268b7..5d69502 100644 --- a/tests/test_auth_scope.py +++ b/backend/tests/test_auth_scope.py @@ -19,6 +19,12 @@ def test_require_website_auth_returns_scoped_website_for_allowed_role(): assert website_id == 5 +def test_require_website_auth_allows_global_system_admin_scope(): + auth = AuthContext(website_id=None, role="system_admin", wp_user_id=None) + website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"}) + assert website_id is None + + def test_require_website_auth_rejects_disallowed_role(): auth = AuthContext(website_id=5, role="student", wp_user_id="u1") with pytest.raises(HTTPException) as exc_info: @@ -30,3 +36,7 @@ def test_cross_website_payload_mismatch_is_blocked(): with pytest.raises(HTTPException) as exc_info: ensure_website_scope_matches(auth_website_id=10, payload_website_id=11) assert exc_info.value.status_code == 403 + + +def test_global_system_admin_scope_can_write_any_payload_website(): + ensure_website_scope_matches(auth_website_id=None, payload_website_id=11) diff --git a/tests/test_auth_tokens.py b/backend/tests/test_auth_tokens.py similarity index 67% rename from tests/test_auth_tokens.py rename to backend/tests/test_auth_tokens.py index 654ad53..b59461c 100644 --- a/tests/test_auth_tokens.py +++ b/backend/tests/test_auth_tokens.py @@ -23,6 +23,30 @@ def test_issue_and_decode_access_token_round_trip(): assert auth.wp_user_id == "wp-1001" +def test_system_admin_token_can_be_global_without_website_scope(): + token = issue_access_token( + website_id=None, + role="system_admin", + wp_user_id=None, + expires_in_seconds=3600, + ) + auth = decode_access_token(token) + assert auth.website_id is None + assert auth.role == "system_admin" + + +def test_non_system_admin_token_requires_website_scope(): + token = issue_access_token( + website_id=None, + role="admin", + wp_user_id=None, + expires_in_seconds=3600, + ) + with pytest.raises(HTTPException) as exc_info: + decode_access_token(token) + assert exc_info.value.status_code == 401 + + def test_decode_access_token_rejects_tampered_signature(): token = issue_access_token( website_id=7, diff --git a/tests/test_model_mappings.py b/backend/tests/test_model_mappings.py similarity index 100% rename from tests/test_model_mappings.py rename to backend/tests/test_model_mappings.py diff --git a/tests/test_normalization.py b/backend/tests/test_normalization.py similarity index 100% rename from tests/test_normalization.py rename to backend/tests/test_normalization.py diff --git a/tests/test_operational_hardening.py b/backend/tests/test_operational_hardening.py similarity index 76% rename from tests/test_operational_hardening.py rename to backend/tests/test_operational_hardening.py index 77fa1db..2bc2544 100644 --- a/tests/test_operational_hardening.py +++ b/backend/tests/test_operational_hardening.py @@ -8,12 +8,14 @@ from app.core import rate_limit from app.core.config import Settings from app.models.report_schedule import ReportScheduleModel from app.services import ai_generation +from app.services import cat_selection from app.services.reporting import ( cancel_scheduled_report, get_scheduled_report, list_scheduled_reports, schedule_report, ) +from app.schemas.ai import GeneratedQuestion class DummyRequest: @@ -101,6 +103,63 @@ def test_ai_stats_accepts_website_scope(monkeypatch): assert all("items.website_id" in query for query in captured_queries) +def test_ai_prompt_preserves_basis_option_labels(): + prompt = ai_generation.get_prompt_template( + basis_stem="

Basis question?

", + basis_options={ + "A": "Option A", + "B": "Option B", + "C": "Option C", + "D": "Option D", + "E": "Option E", + }, + basis_correct="A", + basis_explanation="

Because A.

", + target_level="mudah", + ) + + assert "Create exactly 5 answer options with labels exactly: A, B, C, D, E" in prompt + assert '"E": "Option E text"' in prompt + assert "The correct field must be exactly one of: A, B, C, D, E" in prompt + + +def test_generated_question_must_match_basis_option_labels(): + basis_item = SimpleNamespace( + options={ + "A": "Option A", + "B": "Option B", + "C": "Option C", + "D": "Option D", + "E": "Option E", + } + ) + generated = GeneratedQuestion( + stem="Generated", + options={ + "A": "Option A", + "B": "Option B", + "C": "Option C", + "D": "Option D", + }, + correct="A", + ) + + assert not ai_generation.generated_matches_basis_options(generated, basis_item) + + +def test_cat_selection_only_serves_active_or_approved_variants(): + compiled = str( + cat_selection._servable_item_filter().compile( + compile_kwargs={"literal_binds": True} + ) + ) + + assert "active" in compiled + assert "approved" in compiled + assert "draft" not in compiled + assert "rejected" not in compiled + + def test_production_init_db_skips_create_all(monkeypatch): import app.database as database diff --git a/tests/test_route_wiring.py b/backend/tests/test_route_wiring.py similarity index 82% rename from tests/test_route_wiring.py rename to backend/tests/test_route_wiring.py index 5977341..d5837c1 100644 --- a/tests/test_route_wiring.py +++ b/backend/tests/test_route_wiring.py @@ -7,5 +7,5 @@ from app.main import app def test_next_item_route_is_registered(): - paths = {route.path for route in app.routes} + paths = set(app.openapi()["paths"]) assert "/api/v1/session/{session_id}/next_item" in paths diff --git a/tests/test_security_regressions.py b/backend/tests/test_security_regressions.py similarity index 100% rename from tests/test_security_regressions.py rename to backend/tests/test_security_regressions.py diff --git a/tests/test_tryout_json_import.py b/backend/tests/test_tryout_json_import.py similarity index 100% rename from tests/test_tryout_json_import.py rename to backend/tests/test_tryout_json_import.py diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cd7eb24 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,71 @@ +version: '3.8' + +services: + # 1. FastAPI Backend + backend: + build: + context: ./backend + ports: + - "8000:8000" + env_file: + - ./backend/.env + environment: + - DATABASE_URL=postgresql+asyncpg://irt_user:dev_password@postgres:5432/irt_bank_soal + - REDIS_URL=redis://redis:6379/0 + depends_on: + - postgres + - redis + restart: unless-stopped + + # 2. Redis Message Broker (Required by Celery) + redis: + image: redis:7-alpine + ports: + - "6380:6379" + restart: unless-stopped + volumes: + - redis_data:/data + + # 2.5 PostgreSQL Database + postgres: + image: postgres:15-alpine + environment: + POSTGRES_USER: irt_user + POSTGRES_PASSWORD: dev_password + POSTGRES_DB: irt_bank_soal + ports: + - "5433:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + + # 3. Celery Worker (For AI Generation Background Tasks) + celery_worker: + build: + context: ./backend + command: ["celery", "-A", "app.core.celery_app", "worker", "--loglevel=info"] + env_file: + - ./backend/.env + environment: + - DATABASE_URL=postgresql+asyncpg://irt_user:dev_password@postgres:5432/irt_bank_soal + - REDIS_URL=redis://redis:6379/0 + depends_on: + - backend + - redis + restart: unless-stopped + + # 4. React Frontend SPA + frontend: + build: + context: ./frontend + args: + VITE_API_URL: "http://localhost:8000/api/v1" + ports: + - "3000:80" + depends_on: + - backend + restart: unless-stopped + +volumes: + postgres_data: + redis_data: diff --git a/form_posts_check_and_fixes.md b/form_posts_check_and_fixes.md new file mode 100644 index 0000000..2647a40 --- /dev/null +++ b/form_posts_check_and_fixes.md @@ -0,0 +1,7198 @@ +## User (checkpoint) + +[@Test all permalinks for server errors](zed:///agent/thread/4eec8e55-0884-498c-a334-622475c48e6f?name=Test+all+permalinks+for+server+errors) +trace this project. List all form POST, and test them. Every submission and redirection (if any) shouldn't trigger "Internal Server Error" + +I just found 1. On approving variant, the approve submission is triggering reload but facing "Internal Server Error" + +## Assistant + + +The user wants me to trace the project, find all form POST endpoints, and test them for server errors. They specifically mention that approving a variant is triggering "Internal Server Error". + +Let me start by exploring the project structure to understand what we're working with, then find all POST endpoints, and test them. + + + +I'll trace this project, find all POST endpoints, and test them for server errors. Let me start by exploring the project structure and identifying all POST routes. + + +**Tool Call: List the `yellow-bank-soal` directory's contents** +Status: Completed + +# Folders: +yellow-bank-soal/.claude +yellow-bank-soal/.pytest_cache +yellow-bank-soal/.sixth +yellow-bank-soal/alembic +yellow-bank-soal/app +yellow-bank-soal/docs +yellow-bank-soal/tests +yellow-bank-soal/venv + +# Files: +yellow-bank-soal/.gitignore +yellow-bank-soal/AAPANEL_DEPLOYMENT.md +yellow-bank-soal/ADMIN_UI_REDESIGN_PLAN.md +yellow-bank-soal/AI_HYBRID_GENERATION_WORKFLOW.md +yellow-bank-soal/AI_PARENT_VARIANT_WORKFLOW.md +yellow-bank-soal/DEFECT_GAP_AUDIT_REPORT.md +yellow-bank-soal/Dockerfile +yellow-bank-soal/FASTAPI_ADMIN_1.0.4_FIX_SCRIPT.md +yellow-bank-soal/FASTAPI_ADMIN_1.0.4_MIGRATION.md +yellow-bank-soal/MULTISITE_WORDPRESS_AUTH.md +yellow-bank-soal/PRD.md +yellow-bank-soal/PROJECT_UNDERSTANDING.md +yellow-bank-soal/SQLALCHEMY_QUOTING_FIX.md +yellow-bank-soal/TEST.md +yellow-bank-soal/TESTING_WALKTHROUGH.md +yellow-bank-soal/alembic.ini +yellow-bank-soal/docker-compose.dev.yml +yellow-bank-soal/error.html +yellow-bank-soal/handoff.md +yellow-bank-soal/hands-off.md +yellow-bank-soal/irt_1pl_mle.py +yellow-bank-soal/patch_css.py +yellow-bank-soal/patch_icons.py +yellow-bank-soal/project-brief.md +yellow-bank-soal/requirements.txt +yellow-bank-soal/run_local.sh +yellow-bank-soal/test_all_routes.py +yellow-bank-soal/test_error.py +yellow-bank-soal/test_fetch.py + + +**Tool Call: Search files for regex `@app\.post|@router\.post|\.post\(`** +Status: Completed + +Showing matches 1-20 (there were more matches found; use offset: 20 to see next page): + +## Matches in yellow-bank-soal/MULTISITE_WORDPRESS_AUTH.md + +### # Multi-Site WordPress Authentication Migration › ## Implementation Steps › ### Step 7: Update Admin Router › L419-426 +``` +@router.post("/", response_model=WebsiteResponse) +async def create_website( + data: WebsiteCreate, + db: AsyncSession = Depends(get_db) +): + """Create a new website with optional WordPress config.""" + # Implementation + pass +``` + +### # Multi-Site WordPress Authentication Migration › ## Implementation Steps › ### Step 7: Update Admin Router › L440-450 +``` +@router.post("/{website_id}/wordpress/test") +async def test_wordpress_connection( + website_id: int, + db: AsyncSession = Depends(get_db) +): + """Test WordPress API connection.""" + wp_auth = WordPressMultiSiteAuth(db) + try: + credentials = await wp_auth.get_site_credentials(website_id) + # Test connection + return {"status": "success", "api_url": credentials["api_url"]} +``` + +2 lines remaining in ancestor node. Read the file to see all. + +### # Multi-Site WordPress Authentication Migration › ## Implementation Steps › ### Step 7: Update Admin Router › L455-463 +``` +@router.post("/{website_id}/wordpress/sync") +async def sync_wordpress_users( + website_id: int, + db: AsyncSession = Depends(get_db) +): + """Sync users from WordPress site.""" + wp_auth = WordPressMultiSiteAuth(db) + result = await wp_auth.sync_users(website_id) + return result +``` + +## Matches in yellow-bank-soal/app/admin.py + +### class EnvCredentialProvider › async def register › L82-91 +``` + await super().register(app) + # Keep explicit assignment for compatibility across fastapi-admin versions. + app.login_provider = self + app.get("/")(self.index_view) + app.get(self.login_path)(self.login_view) + app.post(self.login_path)(self.login) + app.get(self.logout_path)(self.logout) + app.get("/password")(self.password_view) + app.post("/password")(self.password) + app.add_middleware(BaseHTTPMiddleware, dispatch=self.authenticate) +``` + +## Matches in yellow-bank-soal/app/admin_web.py + +### L2014-2024 +``` +@router.post("/login", include_in_schema=False) +async def login_submit( + request: Request, + username: str = Form(...), + password: str = Form(...), + remember_me: str | None = Form(None), +): + + if _admin_redis is None: + body = """ +
Admin backend is temporarily unavailable. Please try again.
+``` + +99 lines remaining in ancestor node. Read the file to see all. + +### L2159-2169 +``` +@router.post("/password", include_in_schema=False) +async def password_submit( + request: Request, + old_password: str = Form(...), + new_password: str = Form(...), + re_new_password: str = Form(...), +): + _ = (old_password, new_password, re_new_password) + admin = await _current_admin(request) + if not admin: + return _login_redirect() +``` + +13 lines remaining in ancestor node. Read the file to see all. + +### L3551-3561 +``` +@router.post("/websites", include_in_schema=False) +async def websites_submit( + request: Request, + db: AsyncSession = Depends(get_db), + site_name: str = Form(...), + site_url: str = Form(...), +): + admin = await _current_admin(request) + if not admin: + return _login_redirect() + +``` + +48 lines remaining in ancestor node. Read the file to see all. + +### L3633-3643 +``` +@router.post("/websites/{website_id}/edit", include_in_schema=False) +async def website_edit_submit( + website_id: int, + request: Request, + db: AsyncSession = Depends(get_db), + site_name: str = Form(...), + site_url: str = Form(...), +): + admin = await _current_admin(request) + if not admin: + return _login_redirect() +``` + +49 lines remaining in ancestor node. Read the file to see all. + +### L3695-3705 +``` +@router.post("/websites/{website_id}/delete", include_in_schema=False) +async def website_delete_submit( + website_id: int, + request: Request, + db: AsyncSession = Depends(get_db), +): + admin = await _current_admin(request) + if not admin: + return _login_redirect() + + website = await db.get(Website, website_id) +``` + +17 lines remaining in ancestor node. Read the file to see all. + +### L3737-3747 +``` +@router.post("/tryout-import/preview", include_in_schema=False) +async def tryout_import_preview( + request: Request, + db: AsyncSession = Depends(get_db), + website_id: int = Form(...), + file: UploadFile = File(...), +): + admin = await _current_admin(request) + if not admin: + return _login_redirect() + +``` + +59 lines remaining in ancestor node. Read the file to see all. + +### L3809-3819 +``` +@router.post("/tryout-import", include_in_schema=False) +async def tryout_import_submit( + request: Request, + db: AsyncSession = Depends(get_db), + website_id: int = Form(...), + preview_token: str = Form(...), +): + admin = await _current_admin(request) + if not admin: + return _login_redirect() + +``` + +44 lines remaining in ancestor node. Read the file to see all. + +### L3894-3904 +``` +@router.post("/snapshot-questions/promote-bulk", include_in_schema=False) +async def snapshot_question_promote_bulk( + request: Request, + snapshot_id: int = Form(...), + snapshot_question_ids: list[int] | None = Form(None), + db: AsyncSession = Depends(get_db), +): + admin = await _current_admin(request) + if not admin: + return _login_redirect() + +``` + +72 lines remaining in ancestor node. Read the file to see all. + +### L4208-4218 +``` +@router.post("/basis-items/{basis_item_id}/generate", include_in_schema=False) +async def basis_item_generate_submit( + basis_item_id: int, + request: Request, + db: AsyncSession = Depends(get_db), + target_level: str = Form(...), + ai_model: str = Form(""), + generation_count: int = Form(1), + operator_notes: str = Form(""), + include_note_for_admin: str | None = Form(None), + include_note_in_prompt: str | None = Form(None), +``` + +158 lines remaining in ancestor node. Read the file to see all. + +### L4379-4389 +``` +@router.post("/basis-items/{basis_item_id}/review-bulk", include_in_schema=False) +async def basis_item_review_bulk( + basis_item_id: int, + request: Request, + db: AsyncSession = Depends(get_db), + item_ids: list[int] = Form([]), + action: str = Form(...), +): + filters = {"status": "", "level": "", "run_id": "", "min_frequency": ""} + admin = await _current_admin(request) + if not admin: +``` + +55 lines remaining in ancestor node. Read the file to see all. + +### L4919-4929 +``` +@router.post("/questions/{item_id}/generate") +async def question_generate_submit( + request: Request, + item_id: int, + db: AsyncSession = Depends(get_db), + target_level: str = Form("mudah"), + ai_model: str = Form(settings.OPENROUTER_MODEL_LLAMA), + generation_count: str = Form("1"), + operator_notes: str = Form(""), + include_note_for_admin: bool = Form(True), + include_note_in_prompt: bool = Form(False), +``` + +63 lines remaining in ancestor node. Read the file to see all. + +### L5036-5046 +``` +@router.post("/questions/{item_id}/generate/review-bulk") +async def question_generate_review_bulk( + request: Request, + item_id: int, + db: AsyncSession = Depends(get_db), + item_ids: list[int] = Form([]), + action: str = Form(...), + tab: str = "review", +): + admin = await _current_admin(request) + if not admin: +``` + +25 lines remaining in ancestor node. Read the file to see all. + +## Matches in yellow-bank-soal/app/api/v1/session.py + +### L223-228 +``` +@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." +) +``` + +### L333-338 +``` +@admin_router.post( + "/cat/test", + response_model=CATTestResponse, + summary="Test CAT selection algorithm", + description="Admin playground for testing adaptive selection behavior." +) +``` + +## Matches in yellow-bank-soal/app/routers/admin.py + +### L27-31 +``` +@router.post( + "/{tryout_id}/calibrate", + summary="Trigger IRT calibration", + description="Trigger IRT calibration for all items in this tryout with sufficient response data.", +) +``` + +### L90-94 +``` +@router.post( + "/{tryout_id}/toggle-ai-generation", + summary="Toggle AI generation", + description="Toggle AI question generation for a tryout.", +) +``` + + +## Assistant + + +Let me continue searching for more POST endpoints and look at the router files more closely. + + +**Tool Call: Get page 2 of search results for regex `@router\.post|@app\.post`** +Status: Completed + +Found 13 matches: + +## Matches in yellow-bank-soal/app/routers/ai.py + +### L157-167 +``` +@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 +``` + +10 lines remaining in ancestor node. Read the file to see all. + +## Matches in yellow-bank-soal/app/routers/import_export.py + +### L53-57 +``` +@router.post( + "/preview", + summary="Preview Excel import", + description="Parse Excel file and return preview without saving to database.", +) +``` + +### L150-154 +``` +@router.post( + "/questions", + summary="Import questions from Excel", + description="Parse Excel file and import questions to database with 100% data integrity.", +) +``` + +### L342-346 +``` +@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.", +) +``` + +### L385-389 +``` +@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.", +) +``` + +## Matches in yellow-bank-soal/app/routers/normalization.py + +### L172-176 +``` +@router.post( + "/{tryout_id}/normalization/reset", + summary="Reset normalization stats", + description="Reset TryoutStats to initial values and switch to static normalization mode.", +) +``` + +## Matches in yellow-bank-soal/app/routers/reports.py + +### L356-361 +``` +@router.post( + "/schedule", + response_model=ReportScheduleResponse, + summary="Schedule a report", + description="Schedule a report for automatic generation on a daily, weekly, or monthly basis.", +) +``` + +## Matches in yellow-bank-soal/app/routers/sessions.py + +### L47-52 +``` +@router.post( + "/{session_id}/complete", + response_model=SessionCompleteResponse, + summary="Complete session with answers", + description="Submit user answers, calculate CTT scores, and complete the session.", +) +``` + +### L350-356 +``` +@router.post( + "/", + response_model=SessionResponse, + status_code=status.HTTP_201_CREATED, + summary="Create new session", + description="Create a new tryout session for a student.", +) +``` + +## Matches in yellow-bank-soal/app/routers/tryouts.py + +### L299-303 +``` +@router.post( + "/{tryout_id}/calibrate", + summary="Trigger IRT calibration", + description="Trigger IRT calibration for all items in this tryout with sufficient response data.", +) +``` + +### L367-371 +``` +@router.post( + "/{tryout_id}/calibrate/{item_id}", + summary="Trigger IRT calibration for single item", + description="Trigger IRT calibration for a specific item.", +) +``` + +## Matches in yellow-bank-soal/app/routers/wordpress.py + +### L118-123 +``` +@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.", +) +``` + +### L218-223 +``` +@router.post( + "/verify_session", + response_model=VerifySessionResponse, + summary="Verify WordPress session", + description="Verify WordPress JWT token and user identity.", +) +``` + + +**Tool Call: List the `yellow-bank-soal/app` directory's contents** +Status: Completed + +# Folders: +yellow-bank-soal/app/__pycache__ +yellow-bank-soal/app/api +yellow-bank-soal/app/core +yellow-bank-soal/app/models +yellow-bank-soal/app/routers +yellow-bank-soal/app/schemas +yellow-bank-soal/app/services + +# Files: +yellow-bank-soal/app/__init__.py +yellow-bank-soal/app/admin.py +yellow-bank-soal/app/admin_web.py +yellow-bank-soal/app/admin_web_icons.py +yellow-bank-soal/app/database.py +yellow-bank-soal/app/main.py + + +## Assistant + + +Let me look at the specific issue - the user mentioned "approving variant" is causing Internal Server Error. I need to look at the variant approval logic. Let me search for "variant" and "approve" in the codebase. + + +Let me search for the variant approval functionality which is causing the Internal Server Error: + + +**Tool Call: Search files for regex `approve|variant`** +Status: Completed + +Showing matches 1-20 (there were more matches found; use offset: 20 to see next page): + +## Matches in yellow-bank-soal/ADMIN_UI_REDESIGN_PLAN.md + +### # Admin UI Redesign Plan › ## Detailed Page Redesigns › ### 3. Question Templates — (`/admin/templates`) › L228-245 +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 📝 Question Templates │ +│ (Original questions used to generate AI variants) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Templates are your "master questions" that AI uses to │ +│ create different versions with varying difficulty levels. │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 📝 Template #45: "Berapakah hasil dari 2 + 2?" │ │ +│ │ AI Generated Variants: 12 (3 easy, 6 medium, 3 hard) │ │ +│ │ [View All Variants] [Generate More] [Edit] │ │ +│ ├─────────────────────────────────────────────────────────┤ │ +│ │ 📝 Template #89: "Hitung integral dari x² dx..." │ │ +│ │ AI Generated Variants: 8 (2 easy, 4 medium, 2 hard) │ │ +│ │ [View All Variants] [Generate More] [Edit] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +``` + +4 lines remaining in ancestor node. Read the file to see all. + +### # Admin UI Redesign Plan › ## Detailed Page Redesigns › ### 3. Question Templates — (`/admin/templates`) › L253-254 +``` +- Visual representation of variants +- Easy action buttons +``` + +### # Admin UI Redesign Plan › ## Detailed Page Redesigns › ### 4. AI Generation — (`/admin/ai-generation`) › L265-275 +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 🤖 AI Question Generator │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Generate new question variants using AI. │ +│ Select a template question and specify difficulty level. │ +│ │ +│ ┌──────────────────────┐ ┌──────────────────────┐ │ +│ │ 📝 Select Template │ │ 🎯 Target Difficulty │ │ +│ │ [Dropdown: Questions]│ │ ○ Easy (p > 0.70) │ │ +│ └──────────────────────┘ │ ● Medium (p ≈ 0.50) │ │ +``` + +29 lines remaining in ancestor node. Read the file to see all. + +### # Admin UI Redesign Plan › ## Detailed Page Redesigns › ### 4. AI Generation — (`/admin/ai-generation`) › L276-280 +``` +│ │ ○ Hard (p < 0.30) │ │ +│ ┌──────────────────────┐ └──────────────────────┘ │ +│ │ 📝 How many variants?│ │ +│ │ [1] [3] [5] [10] │ ┌──────────────────────┐ │ +│ └──────────────────────┘ │ 💬 Additional Notes │ │ +``` + +### # Admin UI Redesign Plan › ## Detailed Page Redesigns › ### 4. AI Generation — (`/admin/ai-generation`) › L294-302 +``` +│ ✅ Generated & Ready for Review: │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ ✓ Variant #123: "Berapakah hasil dari 3 + 4?" (Easy) │ │ +│ │ [Preview] [Approve] [Regenerate] [Reject] │ │ +│ ├─────────────────────────────────────────────────────────┤ │ +│ │ ✓ Variant #124: "Hitung hasil dari 5 + 6..." (Easy) │ │ +│ │ [Preview] [Approve] [Regenerate] [Reject] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +``` + +### # Admin UI Redesign Plan › ## Detailed Page Redesigns › ### 4. AI Generation — (`/admin/ai-generation`) › L310-311 +``` +- Clear action buttons (Approve/Reject/Regenerate) +- Explanation of what each option means +``` + +### # Admin UI Redesign Plan › ## Implementation Phases › ### Phase 4: AI Generation Section › L525-526 +``` +2. `/admin/ai-generation/review` - Review pending variants +3. `/admin/ai-generation/history` - Generation history +``` + +### # Admin UI Redesign Plan › ## Implementation Phases › ### Phase 4: AI Generation Section › L532-534 +``` +- Batch approve/reject actions + +--- +``` + +### # Admin UI Redesign Plan › ## Next Steps › L695-696 +``` +1. [ ] Review and approve this plan +2. [ ] Prioritize phases (suggest starting with Phase 1 & 2) +``` + +## Matches in yellow-bank-soal/AI_HYBRID_GENERATION_WORKFLOW.md + +### # AI Hybrid Generation Workflow › ## Core Principle › L21-24 +``` +2. One generation run can produce one or many **generated variants** +3. Each generated variant remains an individually reviewable item + +This is the cleanest way to support both single and bulk generation. +``` + +### # AI Hybrid Generation Workflow › ## Recommended Model › ### Generated Variant › L74-75 +``` +### Generated Variant + +``` + +### # AI Hybrid Generation Workflow › ## Required Lifecycle › L100-101 +``` +- `approved` +- `rejected` +``` + +### # AI Hybrid Generation Workflow › ## UX Principle › L118-119 +``` +- approve 2 items +- reject 1 item +``` + +### # AI Hybrid Generation Workflow › ## Recommended Admin UX › ### Guidance Text › L143-145 +``` +- “You can generate one or many variants in one run.” +- “Recommended: 1–3 variants per run for better consistency and easier review.” +- “Larger runs may reduce cost per item but increase overlap, correlated mistakes, and review effort.” +``` + +### # AI Hybrid Generation Workflow › ## Recommended Admin UX › ### Result View › L151-152 +``` +- approve +- reject +``` + +### # AI Hybrid Generation Workflow › ## Recommended Count Guidance › ### More than 8 items › L215-217 +``` +- variants may become repetitive + +## Cost and Quality Insight +``` + +### # AI Hybrid Generation Workflow › ## Product Direction › L252-253 +``` +4. System creates one or many generated child variants +5. Admin reviews each child separately +``` + +### # AI Hybrid Generation Workflow › ## Product Direction › L254-256 +``` +6. Admin approves, rejects, archives, or regenerates per item + +This gives: +``` + +## Matches in yellow-bank-soal/AI_PARENT_VARIANT_WORKFLOW.md + +### # AI Parent-Variant Workflow › L1-2 +``` +# AI Parent-Variant Workflow + +``` + +### # AI Parent-Variant Workflow › ## Goal › L12-14 +``` +- easy for assessment admins to review, regenerate, and retire variants + +## Core Model +``` + + +**Tool Call: List the `yellow-bank-soal/app/routers` directory's contents** +Status: Completed + +# Folders: +yellow-bank-soal/app/routers/__pycache__ + +# Files: +yellow-bank-soal/app/routers/__init__.py +yellow-bank-soal/app/routers/admin.py +yellow-bank-soal/app/routers/ai.py +yellow-bank-soal/app/routers/import_export.py +yellow-bank-soal/app/routers/normalization.py +yellow-bank-soal/app/routers/reports.py +yellow-bank-soal/app/routers/sessions.py +yellow-bank-soal/app/routers/tryouts.py +yellow-bank-soal/app/routers/wordpress.py + + +## Assistant + + +Let me search more specifically for approve-related POST endpoints and the actual implementation. + + +**Tool Call: Search files for regex `approve|review.*bulk`** +Status: Completed + +Showing matches 1-20 (there were more matches found; use offset: 20 to see next page): + +## Matches in yellow-bank-soal/ADMIN_UI_REDESIGN_PLAN.md + +### # Admin UI Redesign Plan › ## Detailed Page Redesigns › ### 4. AI Generation — (`/admin/ai-generation`) › L295-302 +``` +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ ✓ Variant #123: "Berapakah hasil dari 3 + 4?" (Easy) │ │ +│ │ [Preview] [Approve] [Regenerate] [Reject] │ │ +│ ├─────────────────────────────────────────────────────────┤ │ +│ │ ✓ Variant #124: "Hitung hasil dari 5 + 6..." (Easy) │ │ +│ │ [Preview] [Approve] [Regenerate] [Reject] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +``` + +### # Admin UI Redesign Plan › ## Detailed Page Redesigns › ### 4. AI Generation — (`/admin/ai-generation`) › L310-311 +``` +- Clear action buttons (Approve/Reject/Regenerate) +- Explanation of what each option means +``` + +### # Admin UI Redesign Plan › ## Implementation Phases › ### Phase 4: AI Generation Section › L532-534 +``` +- Batch approve/reject actions + +--- +``` + +### # Admin UI Redesign Plan › ## Next Steps › L695-696 +``` +1. [ ] Review and approve this plan +2. [ ] Prioritize phases (suggest starting with Phase 1 & 2) +``` + +## Matches in yellow-bank-soal/AI_HYBRID_GENERATION_WORKFLOW.md + +### # AI Hybrid Generation Workflow › ## Required Lifecycle › L100-101 +``` +- `approved` +- `rejected` +``` + +### # AI Hybrid Generation Workflow › ## UX Principle › L118-119 +``` +- approve 2 items +- reject 1 item +``` + +### # AI Hybrid Generation Workflow › ## Recommended Admin UX › ### Result View › L151-152 +``` +- approve +- reject +``` + +### # AI Hybrid Generation Workflow › ## Product Direction › L254-256 +``` +6. Admin approves, rejects, archives, or regenerates per item + +This gives: +``` + +## Matches in yellow-bank-soal/AI_PARENT_VARIANT_WORKFLOW.md + +### # AI Parent-Variant Workflow › ## Recommended Admin UX › L114-116 +``` +- actions: review, approve, archive, regenerate + +4. Actions +``` + +### # AI Parent-Variant Workflow › ## Practical Workflow by Role › ### Assessment Admin in IRT App › L200-202 +``` +- approves, archives, or regenerates variants + +### Operations / Quality Admin +``` + +## Matches in yellow-bank-soal/app/admin_web.py + +### def _render_admin_page › L453-457 +``` + .table-wrap table {{ min-width: 860px; }} + .status-pill {{ display: inline-flex; align-items: center; min-height: 22px; padding: 0 8px; border-radius: 999px; background: #e2e8f0; color: #334155; font-size: 12px; font-weight: 700; }} + .status-approved, .status-active {{ background: #dcfce7; color: #166534; }} + .status-rejected, .status-archived {{ background: #fee2e2; color: #991b1b; }} + .status-draft {{ background: #e0f2fe; color: #075985; }} +``` + +### def _render_admin_page › L659-663 +``` + 3 +
+ Review & Approve + Check the Review tab for AI-generated variants. +
+``` + +### def _basis_item_workspace_body › L1652-1663 +``` + variants_table = ( + f'
' + '
' + '" + '' + "
" +``` + +8 lines remaining in ancestor node. Read the file to see all. + +### def _basis_item_workspace_body › L1724-1728 +``` + + + + + +``` + +### async def questions_view › L2521-2525 +``` + {escape(item.level or "-")} + + + {"✅ Calibrated" if item.calibrated else "⏳ Needs Data"} + +``` + +### async def question_detail_view › L2747-2757 +``` + stats_html = f""" +
+
+ Difficulty + {difficulty_label} + {p_value if p_value else "N/A"} +
+
+ Calibration Status + + {"✅ Calibrated" if item.calibrated else "⏳ Needs Data"} +``` + +14 lines remaining in ancestor node. Read the file to see all. + +### L4379-4390 +``` +@router.post("/basis-items/{basis_item_id}/review-bulk", include_in_schema=False) +async def basis_item_review_bulk( + basis_item_id: int, + request: Request, + db: AsyncSession = Depends(get_db), + item_ids: list[int] = Form([]), + action: str = Form(...), +): + filters = {"status": "", "level": "", "run_id": "", "min_frequency": ""} + admin = await _current_admin(request) + if not admin: + return _login_redirect() +``` + +54 lines remaining in ancestor node. Read the file to see all. + +### async def basis_item_review_bulk › L4396-4400 +``` + ) + + valid_actions = {"approved", "rejected", "archived", "stale", "active"} + if action in valid_actions and item_ids: + result = await db.execute( +``` + +### L4450-4454 +``` + ("runs", "Batches"), +) +AI_VARIANT_STATUSES = ("draft", "approved", "active", "rejected", "archived", "stale") +AI_VARIANT_LEVELS = ("mudah", "sulit") + +``` + +### def _ai_review_tab › L4670-4682 +``` + Clear + +
+
+ + +
+``` + + +**Tool Call: Read file `app/admin_web.py` (lines 4370-4500)** +Status: Completed + +```yellow-bank-soal/app/admin_web.py + 4370 ) + 4371 return _render_admin_page( + 4372 request, + 4373 f"Basis Item #{basis_item.id}", + 4374 f"Basis Item Workspace #{basis_item.id}", + 4375 body, + 4376 ) + 4377 + 4378 + 4379 @router.post("/basis-items/{basis_item_id}/review-bulk", include_in_schema=False) + 4380 async def basis_item_review_bulk( + 4381 basis_item_id: int, + 4382 request: Request, + 4383 db: AsyncSession = Depends(get_db), + 4384 item_ids: list[int] = Form([]), + 4385 action: str = Form(...), + 4386 ): + 4387 filters = {"status": "", "level": "", "run_id": "", "min_frequency": ""} + 4388 admin = await _current_admin(request) + 4389 if not admin: + 4390 return _login_redirect() + 4391 + 4392 basis_item = await db.get(Item, basis_item_id) + 4393 if basis_item is None: + 4394 return RedirectResponse( + 4395 url="/admin/basis-items", status_code=HTTP_303_SEE_OTHER + 4396 ) + 4397 + 4398 valid_actions = {"approved", "rejected", "archived", "stale", "active"} + 4399 if action in valid_actions and item_ids: + 4400 result = await db.execute( + 4401 select(Item).where( + 4402 Item.id.in_(item_ids), + 4403 Item.generated_by == "ai", + 4404 Item.basis_item_id == basis_item.id, + 4405 ) + 4406 ) + 4407 items = list(result.scalars().all()) + 4408 reviewed_at = datetime.now(timezone.utc) + 4409 for item in items: + 4410 item.variant_status = action + 4411 item.reviewed_by = admin.username + 4412 item.reviewed_at = reviewed_at + 4413 await db.commit() + 4414 + 4415 run_result = await db.execute( + 4416 select(AIGenerationRun) + 4417 .where(AIGenerationRun.basis_item_id == basis_item.id) + 4418 .order_by(AIGenerationRun.id.desc()) + 4419 .limit(50) + 4420 ) + 4421 variant_result = await db.execute( + 4422 select(Item) + 4423 .where(Item.generated_by == "ai", Item.basis_item_id == basis_item.id) + 4424 .order_by(Item.created_at.desc(), Item.id.desc()) + 4425 .limit(300) + 4426 ) + 4427 runs = list(run_result.scalars().all()) + 4428 variants = list(variant_result.scalars().all()) + 4429 usage_metrics, family_stats = await _family_usage_stats(db, basis_item, variants) + 4430 body = _basis_item_workspace_body( + 4431 basis_item, + 4432 runs, + 4433 variants, + 4434 usage_metrics, + 4435 family_stats, + 4436 filters, + 4437 success=f"Applied status '{action}' to selected variants.", + 4438 ) + 4439 return _render_admin_page( + 4440 request, + 4441 f"Basis Item #{basis_item.id}", + 4442 f"Basis Item Workspace #{basis_item.id}", + 4443 body, + 4444 ) + 4445 + 4446 + 4447 AI_PLAYGROUND_TABS = ( + 4448 ("generate", "Generate"), + 4449 ("review", "Review Queue"), + 4450 ("runs", "Batches"), + 4451 ) + 4452 AI_VARIANT_STATUSES = ("draft", "approved", "active", "rejected", "archived", "stale") + 4453 AI_VARIANT_LEVELS = ("mudah", "sulit") + 4454 + 4455 + 4456 def _selected_option(value: str, selected_value: str) -> str: + 4457 return "selected" if value == selected_value else "" + 4458 + 4459 + 4460 def _ai_tab_nav(item_id: int, active_tab: str) -> str: + 4461 links = [] + 4462 for tab, label in AI_PLAYGROUND_TABS: + 4463 active_class = "active" if tab == active_tab else "" + 4464 aria = ' aria-current="page"' if tab == active_tab else "" + 4465 links.append( + 4466 f'{escape(label)}' + 4467 ) + 4468 return f'' + 4469 + 4470 + 4471 def _status_pill(status: str | None) -> str: + 4472 value = status or "unknown" + 4473 css_value = re.sub(r"[^a-z0-9_-]+", "-", value.lower()) + 4474 return ( + 4475 f'{escape(value)}' + 4476 ) + 4477 + 4478 + 4479 def _ai_status_strip( + 4480 key_configured: bool, + 4481 stats: dict[str, Any], + 4482 generation_runs: list[AIGenerationRun], + 4483 generation_summary: dict[str, Any] | None = None, + 4484 ) -> str: + 4485 latest_run = "-" + 4486 latest_saved = "-" + 4487 if generation_summary: + 4488 latest_run = str(generation_summary.get("run_id", "-")) + 4489 latest_saved = str(len(generation_summary.get("saved_item_ids") or [])) + 4490 elif generation_runs: + 4491 latest_run = str(generation_runs[0].id) + 4492 + 4493 return f""" + 4494
+ 4495
OpenRouter{"Yes" if key_configured else "No"}
+ 4496
AI Items{stats.get("total_ai_items", 0)}
+ 4497
Latest Batch{escape(latest_run)}
+ 4498
Saved{escape(latest_saved)}
+ 4499
+ 4500 """ + +``` + + +## Assistant + + +Now I see the review-bulk endpoint. Let me also look at the question_generate_review_bulk endpoint and check what POST endpoints exist in the project. Let me look at the question generate endpoint and review endpoint. + + +**Tool Call: Read file `app/admin_web.py` (lines 5000-5100)** +Status: Completed + +```yellow-bank-soal/app/admin_web.py + 5000 admin = await _current_admin(request) + 5001 if not admin: + 5002 return _login_redirect() + 5003 + 5004 result = await db.execute( + 5005 select(Item).where(Item.id == item_id, Item.generated_by == "ai") + 5006 ) + 5007 variant = result.scalar_one_or_none() + 5008 if variant is None: + 5009 body = """ + 5010
Generated variant was not found.
+ 5011 Back to Review Queue + 5012 """ + 5013 return _render_admin_page( + 5014 request, "Generated Variant", "Generated Variant", body + 5015 ) + 5016 + 5017 basis_item = None + 5018 if variant.basis_item_id: + 5019 basis_item = await db.get(Item, variant.basis_item_id) + 5020 + 5021 body = _ai_variant_detail_body(variant, basis_item) + 5022 return _render_admin_page( + 5023 request, + 5024 f"Generated Variant #{variant.id}", + 5025 f"Generated Variant #{variant.id}", + 5026 body, + 5027 ) + 5028 + 5029 + 5030 + 5031 + 5032 + 5033 + 5034 + 5035 + 5036 @router.post("/questions/{item_id}/generate/review-bulk") + 5037 async def question_generate_review_bulk( + 5038 request: Request, + 5039 item_id: int, + 5040 db: AsyncSession = Depends(get_db), + 5041 item_ids: list[int] = Form([]), + 5042 action: str = Form(...), + 5043 tab: str = "review", + 5044 ): + 5045 admin = await _current_admin(request) + 5046 if not admin: + 5047 return _login_redirect() + 5048 + 5049 valid_actions = {"approved", "rejected", "archived", "stale", "active"} + 5050 if action not in valid_actions: + 5051 return RedirectResponse(url=f"/admin/questions/{item_id}/generate?tab={tab}&error=Invalid action", status_code=HTTP_303_SEE_OTHER) + 5052 + 5053 if not item_ids: + 5054 return RedirectResponse(url=f"/admin/questions/{item_id}/generate?tab={tab}&error=No items selected", status_code=HTTP_303_SEE_OTHER) + 5055 + 5056 result = await db.execute(select(Item).where(Item.id.in_(item_ids))) + 5057 variants = list(result.scalars().all()) + 5058 + 5059 now = datetime.now(timezone.utc) + 5060 for v in variants: + 5061 v.variant_status = action + 5062 v.reviewed_by = admin.username + 5063 v.reviewed_at = now + 5064 v.updated_at = now + 5065 + 5066 await db.commit() + 5067 + 5068 return RedirectResponse( + 5069 url=f"/admin/questions/{item_id}/generate?tab={tab}&success=Successfully applied {action} to {len(variants)} variants.", + 5070 status_code=HTTP_303_SEE_OTHER, + 5071 ) + 5072 + 5073 + 5074 @router.get("/tryout/list", include_in_schema=False) + 5075 @router.get("/item/list", include_in_schema=False) + 5076 @router.get("/user/list", include_in_schema=False) + 5077 @router.get("/session/list", include_in_schema=False) + 5078 @router.get("/tryoutstats/list", include_in_schema=False) + 5079 async def legacy_admin_paths(request: Request): + 5080 admin = await _current_admin(request) + 5081 if not admin: + 5082 return _login_redirect() + 5083 return _dashboard_redirect() + +``` + + +**Tool Call: Search files for regex `form.*method.*post|action.*post|router\.post`** +Status: Completed + +Showing matches 1-20 (there were more matches found; use offset: 20 to see next page): + +## Matches in yellow-bank-soal/MULTISITE_WORDPRESS_AUTH.md + +### # Multi-Site WordPress Authentication Migration › ## Implementation Steps › ### Step 7: Update Admin Router › L419-426 +``` +@router.post("/", response_model=WebsiteResponse) +async def create_website( + data: WebsiteCreate, + db: AsyncSession = Depends(get_db) +): + """Create a new website with optional WordPress config.""" + # Implementation + pass +``` + +### # Multi-Site WordPress Authentication Migration › ## Implementation Steps › ### Step 7: Update Admin Router › L440-450 +``` +@router.post("/{website_id}/wordpress/test") +async def test_wordpress_connection( + website_id: int, + db: AsyncSession = Depends(get_db) +): + """Test WordPress API connection.""" + wp_auth = WordPressMultiSiteAuth(db) + try: + credentials = await wp_auth.get_site_credentials(website_id) + # Test connection + return {"status": "success", "api_url": credentials["api_url"]} +``` + +2 lines remaining in ancestor node. Read the file to see all. + +### # Multi-Site WordPress Authentication Migration › ## Implementation Steps › ### Step 7: Update Admin Router › L455-463 +``` +@router.post("/{website_id}/wordpress/sync") +async def sync_wordpress_users( + website_id: int, + db: AsyncSession = Depends(get_db) +): + """Sync users from WordPress site.""" + wp_auth = WordPressMultiSiteAuth(db) + result = await wp_auth.sync_users(website_id) + return result +``` + +## Matches in yellow-bank-soal/app/admin.py + +### class EnvCredentialProvider › async def login_view › L148-158 +``` + body = f""" + + + + + + + +
+

Direct environment-backed admin access.

+ """ +``` + +### class EnvCredentialProvider › async def login › L177-187 +``` + body = f""" +
Invalid username or password.
+
+ + + + + + +
+ """ +``` + +## Matches in yellow-bank-soal/app/admin_web.py + +### def _render_auth_page › L300-305 +``` + html = re.sub( + r'(]*method="post"[^>]*>)', + r"\1" + csrf_input, + html, + flags=re.IGNORECASE, + ) +``` + +### def _render_admin_page › L778-783 +``` + html = re.sub( + r'(]*method="post"[^>]*>)', + r"\1" + csrf_input, + html, + flags=re.IGNORECASE, + ) +``` + +### def _websites_form_body › L856-863 +``` + actions = f""" +
+ Edit +
+ +
+
+ """ +``` + +### def _websites_form_body › L883-886 +``` + {error_html} +
+ + +``` + +### def _website_edit_form_body › L908-918 +``` + return f""" +

Website ID: {website.id}

+ {success_html} + {error_html} + + + + + +
+ +``` + +4 lines remaining in ancestor node. Read the file to see all. + +### def _tryout_import_form_body › L992-994 +``` + import_form = f""" + + +``` + +### def _tryout_import_form_body › L1041-1044 +``` + {error_html} + + + +``` + +### def _snapshot_questions_body › L1115-1125 +``` + questions_table = ( + '' + f'' + '
' + '' + "
" + '' + + ("".join(rows) if rows else '') + + "
SlotSource Question IDCorrectOptionsActiveStemAction
No data
" + "" + ) +``` + +### def _basis_item_workspace_body › L1652-1662 +``` + variants_table = ( + f'
' + '
' + '" + '' +``` + +9 lines remaining in ancestor node. Read the file to see all. + +### def _basis_item_workspace_body › L1694-1698 +``` +

Generate Variants

+

Create new AI child variants for this parent.

+ + + + + + + + +

Direct environment-backed admin access.

+ """ +``` + +### L2014-2033 +``` +@router.post("/login", include_in_schema=False) +async def login_submit( + request: Request, + username: str = Form(...), + password: str = Form(...), + remember_me: str | None = Form(None), +): + + if _admin_redis is None: + body = """ +
Admin backend is temporarily unavailable. Please try again.
+
+ + + + + + +
+ """ +``` + +90 lines remaining in ancestor node. Read the file to see all. + +### async def login_submit › L2047-2057 +``` + body = """ +
Too many login attempts. Please wait a few minutes and try again.
+
+ + + + + + +
+ """ +``` + +### async def login_submit › L2073-2077 +``` + body = f""" +
Invalid username or password.
+
+ + +``` + +### L2159-2169 +``` +@router.post("/password", include_in_schema=False) +async def password_submit( + request: Request, + old_password: str = Form(...), + new_password: str = Form(...), + re_new_password: str = Form(...), +): + _ = (old_password, new_password, re_new_password) + admin = await _current_admin(request) + if not admin: + return _login_redirect() +``` + +13 lines remaining in ancestor node. Read the file to see all. + + +## Assistant + + +Now I have a clearer picture. The user mentioned that approving variant triggers "Internal Server Error". Let me check the specific POST endpoints that are involved in the variant approval workflow. Let me look at the `basis_item_generate_submit` function which handles generating variants and also look for any other POST endpoints. + +Let me also check if there's a test script or examine what happens during the variant approval. + + +Let me check the variant generation/approval endpoint and look at the question_generate_submit: + + +**Tool Call: Read file `app/admin_web.py` (lines 4200-4380)** +Status: Completed + +```yellow-bank-soal/app/admin_web.py + 4200 return _render_admin_page( + 4201 request, + 4202 f"Basis Item #{basis_item.id}", + 4203 f"Basis Item Workspace #{basis_item.id}", + 4204 body, + 4205 ) + 4206 + 4207 + 4208 @router.post("/basis-items/{basis_item_id}/generate", include_in_schema=False) + 4209 async def basis_item_generate_submit( + 4210 basis_item_id: int, + 4211 request: Request, + 4212 db: AsyncSession = Depends(get_db), + 4213 target_level: str = Form(...), + 4214 ai_model: str = Form(""), + 4215 generation_count: int = Form(1), + 4216 operator_notes: str = Form(""), + 4217 include_note_for_admin: str | None = Form(None), + 4218 include_note_in_prompt: str | None = Form(None), + 4219 ): + 4220 admin = await _current_admin(request) + 4221 if not admin: + 4222 return _login_redirect() + 4223 + 4224 filters = {"status": "", "level": "", "run_id": "", "min_frequency": ""} + 4225 basis_item = await db.get(Item, basis_item_id) + 4226 if ( + 4227 basis_item is None + 4228 or basis_item.generated_by == "ai" + 4229 or basis_item.level != "sedang" + 4230 ): + 4231 return RedirectResponse( + 4232 url="/admin/basis-items", status_code=HTTP_303_SEE_OTHER + 4233 ) + 4234 + 4235 # Llama-only policy for production quality consistency. + 4236 ai_model = settings.OPENROUTER_MODEL_LLAMA + 4237 note_for_admin = include_note_for_admin == "on" + 4238 note_in_prompt = include_note_in_prompt == "on" + 4239 + 4240 if not settings.OPENROUTER_API_KEY: + 4241 run_result = await db.execute( + 4242 select(AIGenerationRun) + 4243 .where(AIGenerationRun.basis_item_id == basis_item.id) + 4244 .order_by(AIGenerationRun.id.desc()) + 4245 .limit(50) + 4246 ) + 4247 variant_result = await db.execute( + 4248 select(Item) + 4249 .where(Item.generated_by == "ai", Item.basis_item_id == basis_item.id) + 4250 .order_by(Item.created_at.desc(), Item.id.desc()) + 4251 .limit(300) + 4252 ) + 4253 runs = list(run_result.scalars().all()) + 4254 variants = list(variant_result.scalars().all()) + 4255 usage_metrics, family_stats = await _family_usage_stats( + 4256 db, basis_item, variants + 4257 ) + 4258 body = _basis_item_workspace_body( + 4259 basis_item, + 4260 runs, + 4261 variants, + 4262 usage_metrics, + 4263 family_stats, + 4264 filters, + 4265 error="OPENROUTER_API_KEY is not configured.", + 4266 target_level=target_level, + 4267 ai_model=ai_model, + 4268 generation_count=str(generation_count), + 4269 operator_notes=operator_notes, + 4270 include_note_for_admin=note_for_admin, + 4271 include_note_in_prompt=note_in_prompt, + 4272 ) + 4273 return _render_admin_page( + 4274 request, + 4275 f"Basis Item #{basis_item.id}", + 4276 f"Basis Item Workspace #{basis_item.id}", + 4277 body, + 4278 ) + 4279 + 4280 if target_level not in {"mudah", "sulit"}: + 4281 return RedirectResponse( + 4282 url=f"/admin/basis-items/{basis_item.id}", status_code=HTTP_303_SEE_OTHER + 4283 ) + 4284 if generation_count < 1 or generation_count > 50: + 4285 return RedirectResponse( + 4286 url=f"/admin/basis-items/{basis_item.id}", status_code=HTTP_303_SEE_OTHER + 4287 ) + 4288 + 4289 run_id = await create_generation_run( + 4290 basis_item_id=basis_item.id, + 4291 source_snapshot_question_id=basis_item.source_snapshot_question_id, + 4292 target_level=target_level, + 4293 requested_count=generation_count, + 4294 model=ai_model, + 4295 created_by=admin.username, + 4296 operator_notes=(operator_notes.strip() or None) if note_for_admin else None, + 4297 db=db, + 4298 ) + 4299 generated = await generate_questions_batch( + 4300 basis_item=basis_item, + 4301 target_level=target_level, + 4302 ai_model=ai_model, + 4303 count=generation_count, + 4304 operator_notes=operator_notes if note_in_prompt else None, + 4305 ) + 4306 + 4307 from app.schemas.ai import GeneratedQuestion + 4308 + 4309 saved = 0 + 4310 for generated_question in generated: + 4311 item_id = await save_ai_question( + 4312 generated_data=GeneratedQuestion( + 4313 stem=generated_question.stem, + 4314 options=generated_question.options, + 4315 correct=generated_question.correct, + 4316 explanation=generated_question.explanation or None, + 4317 ), + 4318 tryout_id=basis_item.tryout_id, + 4319 website_id=basis_item.website_id, + 4320 basis_item_id=basis_item.id, + 4321 slot=basis_item.slot, + 4322 level=target_level, + 4323 ai_model=ai_model, + 4324 generation_run_id=run_id, + 4325 source_snapshot_question_id=basis_item.source_snapshot_question_id, + 4326 variant_status="draft", + 4327 db=db, + 4328 ) + 4329 if item_id: + 4330 saved += 1 + 4331 + 4332 await db.commit() + 4333 + 4334 run_result = await db.execute( + 4335 select(AIGenerationRun) + 4336 .where(AIGenerationRun.basis_item_id == basis_item.id) + 4337 .order_by(AIGenerationRun.id.desc()) + 4338 .limit(50) + 4339 ) + 4340 variant_result = await db.execute( + 4341 select(Item) + 4342 .where(Item.generated_by == "ai", Item.basis_item_id == basis_item.id) + 4343 .order_by(Item.created_at.desc(), Item.id.desc()) + 4344 .limit(300) + 4345 ) + 4346 runs = list(run_result.scalars().all()) + 4347 variants = list(variant_result.scalars().all()) + 4348 usage_metrics, family_stats = await _family_usage_stats(db, basis_item, variants) + 4349 status_message = ( + 4350 f"Run #{run_id} failed to produce savable variants. " + 4351 f"Requested={generation_count}, Generated={len(generated)}, Saved={saved}. " + 4352 "Check model output/credentials and server logs." + 4353 if saved == 0 + 4354 else f"Run #{run_id} finished. Requested={generation_count}, Generated={len(generated)}, Saved={saved}." + 4355 ) + 4356 body = _basis_item_workspace_body( + 4357 basis_item, + 4358 runs, + 4359 variants, + 4360 usage_metrics, + 4361 family_stats, + 4362 filters, + 4363 error=status_message if saved == 0 else None, + 4364 success=status_message if saved > 0 else None, + 4365 target_level=target_level, + 4366 ai_model=ai_model, + 4367 generation_count=str(generation_count), + 4368 include_note_for_admin=note_for_admin, + 4369 include_note_in_prompt=note_in_prompt, + 4370 ) + 4371 return _render_admin_page( + 4372 request, + 4373 f"Basis Item #{basis_item.id}", + 4374 f"Basis Item Workspace #{basis_item.id}", + 4375 body, + 4376 ) + 4377 + 4378 + 4379 @router.post("/basis-items/{basis_item_id}/review-bulk", include_in_schema=False) + 4380 async def basis_item_review_bulk( + +``` + + +**Tool Call: Read file `app/admin_web.py` (lines 4910-5040)** +Status: Completed + +```yellow-bank-soal/app/admin_web.py + 4910 variant_status_filter=status, + 4911 variant_level_filter=level, + 4912 variant_run_id_filter=run_id, + 4913 ) + 4914 return _render_admin_page( + 4915 request, f"AI Workflow: #{item.id}", f"AI Workflow for #{item.id}", body + 4916 ) + 4917 + 4918 + 4919 @router.post("/questions/{item_id}/generate") + 4920 async def question_generate_submit( + 4921 request: Request, + 4922 item_id: int, + 4923 db: AsyncSession = Depends(get_db), + 4924 target_level: str = Form("mudah"), + 4925 ai_model: str = Form(settings.OPENROUTER_MODEL_LLAMA), + 4926 generation_count: str = Form("1"), + 4927 operator_notes: str = Form(""), + 4928 include_note_for_admin: bool = Form(True), + 4929 include_note_in_prompt: bool = Form(False), + 4930 ): + 4931 admin = await _current_admin(request) + 4932 if not admin: + 4933 return _login_redirect() + 4934 + 4935 result = await db.execute(select(Item).where(Item.id == item_id)) + 4936 item = result.scalar_one_or_none() + 4937 if not item: + 4938 return RedirectResponse(url="/admin/questions", status_code=HTTP_303_SEE_OTHER) + 4939 + 4940 if not settings.OPENROUTER_API_KEY: + 4941 return RedirectResponse(url=f"/admin/questions/{item.id}/generate?error=API key missing", status_code=HTTP_303_SEE_OTHER) + 4942 + 4943 count = int(generation_count) if generation_count.isdigit() else 1 + 4944 + 4945 from app.services.ai_playground_generator import generate_variants_for_item + 4946 + 4947 try: + 4948 run_id, generated = await generate_variants_for_item( + 4949 db=db, + 4950 item=item, + 4951 target_level=target_level, + 4952 ai_model=ai_model, + 4953 num_variants=count, + 4954 operator_notes=operator_notes, + 4955 include_note_for_admin=include_note_for_admin, + 4956 include_note_in_prompt=include_note_in_prompt, + 4957 ) + 4958 except Exception as e: + 4959 return RedirectResponse(url=f"/admin/questions/{item.id}/generate?error={str(e)}", status_code=HTTP_303_SEE_OTHER) + 4960 + 4961 saved_item_ids: list[int] = [] + 4962 from app.schemas.ai import GeneratedQuestion + 4963 from app.services.ai_playground_generator import save_ai_question + 4964 + 4965 for generated_question in generated: + 4966 item_id_saved = await save_ai_question( + 4967 generated_data=GeneratedQuestion( + 4968 stem=generated_question.stem, + 4969 options=generated_question.options, + 4970 correct=generated_question.correct, + 4971 explanation=generated_question.explanation or None, + 4972 ), + 4973 tryout_id=item.tryout_id, + 4974 website_id=item.website_id, + 4975 basis_item_id=item.id, + 4976 slot=item.slot, + 4977 level=target_level, + 4978 ai_model=ai_model, + 4979 generation_run_id=run_id, + 4980 source_snapshot_question_id=item.source_snapshot_question_id, + 4981 variant_status="draft", + 4982 db=db, + 4983 ) + 4984 if item_id_saved: + 4985 saved_item_ids.append(item_id_saved) + 4986 + 4987 await db.commit() + 4988 + 4989 return RedirectResponse( + 4990 url=f"/admin/questions/{item.id}/generate?tab=review&run_id={run_id}", + 4991 status_code=HTTP_303_SEE_OTHER, + 4992 ) + 4993 + 4994 @router.get("/questions/{item_id}/generate/variants/{variant_id}") + 4995 async def ai_playground_variant_detail( + 4996 item_id: int, + 4997 request: Request, + 4998 db: AsyncSession = Depends(get_db), + 4999 ): + 5000 admin = await _current_admin(request) + 5001 if not admin: + 5002 return _login_redirect() + 5003 + 5004 result = await db.execute( + 5005 select(Item).where(Item.id == item_id, Item.generated_by == "ai") + 5006 ) + 5007 variant = result.scalar_one_or_none() + 5008 if variant is None: + 5009 body = """ + 5010
Generated variant was not found.
+ 5011 Back to Review Queue + 5012 """ + 5013 return _render_admin_page( + 5014 request, "Generated Variant", "Generated Variant", body + 5015 ) + 5016 + 5017 basis_item = None + 5018 if variant.basis_item_id: + 5019 basis_item = await db.get(Item, variant.basis_item_id) + 5020 + 5021 body = _ai_variant_detail_body(variant, basis_item) + 5022 return _render_admin_page( + 5023 request, + 5024 f"Generated Variant #{variant.id}", + 5025 f"Generated Variant #{variant.id}", + 5026 body, + 5027 ) + 5028 + 5029 + 5030 + 5031 + 5032 + 5033 + 5034 + 5035 + 5036 @router.post("/questions/{item_id}/generate/review-bulk") + 5037 async def question_generate_review_bulk( + 5038 request: Request, + 5039 item_id: int, + 5040 db: AsyncSession = Depends(get_db), + +``` + + +## Assistant + + +Now let me run the server and test all the POST endpoints. First, let me check if there's a test script already or if I need to start the server. Let me also look at the existing test_all_routes.py script. + + +Let me check the existing test script and then run the server to test the POST endpoints: + + +**Tool Call: Read file `test_all_routes.py`** +Status: Completed + +```yellow-bank-soal/test_all_routes.py + 1 #!/usr/bin/env python3 + 2 """ + 3 Test all routes in the IRT Bank Soal application. + 4 Tests each endpoint and checks for Internal Server Errors. + 5 """ + 6 + 7 import json + 8 import sys + 9 from concurrent.futures import ThreadPoolExecutor, as_completed + 10 from urllib.parse import urlparse + 11 + 12 import httpx + 13 + 14 BASE_URL = "http://localhost:8000" + 15 + 16 # All routes from OpenAPI spec + 17 API_ROUTES = [ + 18 # Root endpoints + 19 ("GET", "/"), + 20 ("GET", "/health"), + 21 # Session endpoints + 22 ("POST", "/api/v1/session/"), + 23 ("GET", "/api/v1/session/{session_id}"), + 24 ("POST", "/api/v1/session/{session_id}/complete"), + 25 ("GET", "/api/v1/session/{session_id}/next_item"), + 26 ("POST", "/api/v1/session/{session_id}/submit_answer"), + 27 # Tryout endpoints + 28 ("GET", "/api/v1/tryout/"), + 29 ("GET", "/api/v1/tryout/{tryout_id}/config"), + 30 ("PUT", "/api/v1/tryout/{tryout_id}/normalization"), + 31 ("GET", "/api/v1/tryout/{tryout_id}/calibration-status"), + 32 ("POST", "/api/v1/tryout/{tryout_id}/calibrate"), + 33 ("POST", "/api/v1/tryout/{tryout_id}/calibrate/{item_id}"), + 34 # WordPress endpoints + 35 ("POST", "/api/v1/wordpress/sync_users"), + 36 ("POST", "/api/v1/wordpress/verify_session"), + 37 ("GET", "/api/v1/wordpress/website/{website_id}/users"), + 38 ("GET", "/api/v1/wordpress/website/{website_id}/user/{wp_user_id}"), + 39 # Reports endpoints + 40 ("POST", "/api/v1/reports/schedule"), + 41 ("GET", "/api/v1/reports/schedule/{schedule_id}"), + 42 ("GET", "/api/v1/reports/schedules"), + 43 ("DELETE", "/api/v1/reports/schedule/{schedule_id}"), + 44 ("POST", "/api/v1/reports/schedule/{schedule_id}/export"), + 45 ("GET", "/api/v1/reports/student/performance"), + 46 ("GET", "/api/v1/reports/student/performance/export/{format}"), + 47 ("GET", "/api/v1/reports/items/analysis"), + 48 ("GET", "/api/v1/reports/items/analysis/export/{format}"), + 49 ("GET", "/api/v1/reports/calibration/status"), + 50 ("GET", "/api/v1/reports/calibration/status/export/{format}"), + 51 ("GET", "/api/v1/reports/tryout/comparison"), + 52 ("GET", "/api/v1/reports/tryout/comparison/export/{format}"), + 53 ("GET", "/api/v1/reports/export/{schedule_id}/{format}"), + 54 # Import/Export endpoints + 55 ("POST", "/api/v1/import-export/preview"), + 56 ("POST", "/api/v1/import-export/questions"), + 57 ("GET", "/api/v1/import-export/export/questions"), + 58 ("POST", "/api/v1/import-export/tryout-json/preview"), + 59 ("POST", "/api/v1/import-export/tryout-json"), + 60 # Admin AI endpoints + 61 ("POST", "/api/v1/admin/ai/generate-preview"), + 62 ("POST", "/api/v1/admin/ai/generate-save"), + 63 ("GET", "/api/v1/admin/ai/stats"), + 64 ("GET", "/api/v1/admin/ai/models"), + 65 # Admin endpoints + 66 ("POST", "/api/v1/admin/{tryout_id}/calibrate"), + 67 ("POST", "/api/v1/admin/{tryout_id}/toggle-ai-generation"), + 68 ("POST", "/api/v1/admin/{tryout_id}/reset-normalization"), + 69 # Admin CAT endpoints + 70 ("POST", "/api/v1/admin/cat/test"), + 71 ("GET", "/api/v1/admin/session/{session_id}/status"), + 72 # Admin web routes (HTML pages) + 73 ("GET", "/admin"), + 74 ("GET", "/admin/login"), + 75 ("POST", "/admin/login"), + 76 ("POST", "/admin/logout"), + 77 ("GET", "/admin/password"), + 78 ("POST", "/admin/password"), + 79 ("GET", "/admin/dashboard"), + 80 ("GET", "/admin/questions"), + 81 ("GET", "/admin/questions/{item_id}"), + 82 ("GET", "/admin/questions/{item_id}/quality"), + 83 ("GET", "/admin/exams"), + 84 ("GET", "/admin/exams/{tryout_id}"), + 85 ("GET", "/admin/reports"), + 86 ("GET", "/admin/settings"), + 87 ("GET", "/admin/hierarchy"), + 88 ("GET", "/admin/websites"), + 89 ("POST", "/admin/websites"), + 90 ("GET", "/admin/websites/new"), + 91 ("GET", "/admin/websites/{website_id}"), + 92 ("POST", "/admin/websites/{website_id}"), + 93 ("POST", "/admin/websites/{website_id}/delete"), + 94 ("GET", "/admin/tryout-import"), + 95 ("GET", "/admin/tryout-import/preview"), + 96 ("POST", "/admin/tryout-import"), + 97 ("GET", "/admin/snapshot-questions"), + 98 ("POST", "/admin/snapshot-questions/promote-bulk"), + 99 ("GET", "/admin/calibration-status"), + 100 ("GET", "/admin/item-statistics"), + 101 ("GET", "/admin/sessions"), + 102 ("GET", "/admin/basis-items"), + 103 ("GET", "/admin/basis-items/{item_id}"), + 104 ("POST", "/admin/basis-items/{item_id}/generate"), + 105 ("POST", "/admin/basis-items/{item_id}/generate/review-bulk"), + 106 ("GET", "/admin/basis-items/{item_id}/generate/variants/{variant_id}"), + 107 ] + 108 + 109 # Placeholder values for path parameters + 110 PLACEHOLDERS = { + 111 "{session_id}": "test-session-123", + 112 "{tryout_id}": "test-tryout-123", + 113 "{item_id}": "1", + 114 "{website_id}": "1", + 115 "{wp_user_id}": "123", + 116 "{schedule_id}": "test-schedule-123", + 117 "{format}": "xlsx", + 118 "{variant_id}": "test-variant-123", + 119 } + 120 + 121 # Minimal request bodies for POST endpoints + 122 REQUEST_BODIES = { + 123 "/api/v1/session/": { + 124 "session_id": "test", + 125 "tryout_id": "test", + 126 "wp_user_id": "123", + 127 "website_id": 1, + 128 "scoring_mode": "ctt", + 129 }, + 130 "/api/v1/session/{session_id}/complete": { + 131 "end_time": "2024-01-01T00:00:00Z", + 132 "user_answers": [], + 133 }, + 134 "/api/v1/session/{session_id}/submit_answer": { + 135 "item_id": 1, + 136 "response": "A", + 137 "time_spent": 10, + 138 }, + 139 "/api/v1/tryout/{tryout_id}/normalization": { + 140 "normalization_mode": "static", + 141 "static_rataan": 500, + 142 "static_sb": 100, + 143 }, + 144 "/api/v1/wordpress/sync_users": {}, # Requires proper auth header + 145 "/api/v1/wordpress/verify_session": { + 146 "website_id": 1, + 147 "wp_user_id": "123", + 148 "token": "test", + 149 }, + 150 "/api/v1/reports/schedule": { + 151 "tryout_id": "test", + 152 "report_type": "student_performance", + 153 }, + 154 "/api/v1/admin/ai/generate-preview": { + 155 "basis_item_id": 1, + 156 "target_level": "sulit", + 157 "ai_model": "qwen/qwen2.5-32b-instruct", + 158 }, + 159 "/api/v1/admin/ai/generate-save": { + 160 "stem": "Test?", + 161 "options": {"A": "a", "B": "b", "C": "c", "D": "d"}, + 162 "correct": "A", + 163 "tryout_id": "test", + 164 "website_id": 1, + 165 "basis_item_id": 1, + 166 "slot": 1, + 167 "level": "sulit", + 168 "ai_model": "qwen/qwen2.5-32b-instruct", + 169 }, + 170 "/api/v1/admin/cat/test": {"tryout_id": "test", "website_id": 1}, + 171 "/api/v1/admin/{tryout_id}/calibrate": {}, + 172 "/api/v1/admin/{tryout_id}/toggle-ai-generation": {}, + 173 "/api/v1/admin/{tryout_id}/reset-normalization": {}, + 174 "/api/v1/import-export/preview": None, # Requires file upload + 175 "/api/v1/import-export/questions": None, # Requires file upload + 176 "/api/v1/import-export/tryout-json/preview": None, # Requires file upload + 177 "/api/v1/import-export/tryout-json": None, # Requires file upload + 178 } + 179 + 180 + 181 def expand_route(method: str, route: str) -> list: + 182 """Expand route with placeholders.""" + 183 expanded = [] + 184 test_route = route + 185 for placeholder, value in PLACEHOLDERS.items(): + 186 if placeholder in test_route: + 187 test_route = test_route.replace(placeholder, value) + 188 expanded.append((method, test_route)) + 189 return expanded + 190 + 191 + 192 def test_route(client: httpx.Client, method: str, route: str) -> dict: + 193 """Test a single route.""" + 194 # Expand placeholders + 195 expanded = expand_route(method, route) + 196 if not expanded: + 197 return { + 198 "route": route, + 199 "method": method, + 200 "error": "Could not expand route", + 201 "status_code": None, + 202 } + 203 + 204 method, test_route = expanded[0] + 205 + 206 # Determine request body + 207 body = None + 208 request_body = REQUEST_BODIES.get(route, REQUEST_BODIES.get(test_route, {})) + 209 if request_body is not None: + 210 body = request_body + 211 + 212 # Determine query params + 213 params = {} + 214 if "export/questions" in route: + 215 params = {"tryout_id": "test"} + 216 + 217 headers = {"X-Website-ID": "1"} + 218 + 219 try: + 220 response = client.request( + 221 method=method, + 222 url=BASE_URL + test_route, + 223 json=body if body and method in ["POST", "PUT", "PATCH"] else None, + 224 params=params, + 225 headers=headers, + 226 timeout=10.0, + 227 follow_redirects=True, + 228 ) + 229 + 230 is_500 = response.status_code == 500 + 231 is_ise = "Internal Server Error" in response.text + 232 + 233 return { + 234 "route": route, + 235 "method": method, + 236 "expanded_route": test_route, + 237 "status_code": response.status_code, + 238 "has_500": is_500, + 239 "has_ise": is_ise, + 240 "response_preview": response.text[:200] if response.text else "", + 241 "error": None, + 242 } + 243 except httpx.TimeoutException: + 244 return { + 245 "route": route, + 246 "method": method, + 247 "expanded_route": test_route, + 248 "status_code": None, + 249 "has_500": False, + 250 "has_ise": False, + 251 "response_preview": "", + 252 "error": "Timeout", + 253 } + 254 except Exception as e: + 255 return { + 256 "route": route, + 257 "method": method, + 258 "expanded_route": test_route, + 259 "status_code": None, + 260 "has_500": False, + 261 "has_ise": False, + 262 "response_preview": "", + 263 "error": str(e), + 264 } + 265 + 266 + 267 def main(): + 268 print("=" * 80) + 269 print("Testing all IRT Bank Soal routes for Internal Server Errors") + 270 print("=" * 80) + 271 print() + 272 + 273 results = [] + 274 has_errors = False + 275 + 276 with httpx.Client(timeout=30.0) as client: + 277 for method, route in API_ROUTES: + 278 result = test_route(client, method, route) + 279 results.append(result) + 280 + 281 status = result["status_code"] + 282 error_marker = "" + 283 + 284 if result["error"]: + 285 error_marker = f" [ERROR: {result['error']}]" + 286 has_errors = True + 287 elif status and status >= 500: + 288 error_marker = f" [INTERNAL SERVER ERROR!]" + 289 has_errors = True + 290 elif status and status == 500: + 291 error_marker = f" [500 - INTERNAL SERVER ERROR!]" + 292 has_errors = True + 293 elif "Internal Server Error" in str(result.get("response_preview", "")): + 294 error_marker = " [500 - INTERNAL SERVER ERROR!]" + 295 has_errors = True + 296 + 297 status_str = str(status) if status else "N/A" + 298 print(f"{method:6} {route:<60} -> {status_str}{error_marker}") + 299 + 300 print() + 301 print("=" * 80) + 302 print("SUMMARY") + 303 print("=" * 80) + 304 + 305 total = len(results) + 306 successful = sum(1 for r in results if r["status_code"] and r["status_code"] < 500) + 307 client_errors = sum( + 308 1 for r in results if r["status_code"] and 400 <= r["status_code"] < 500 + 309 ) + 310 server_errors = sum( + 311 1 for r in results if r["status_code"] and r["status_code"] >= 500 + 312 ) + 313 timeouts = sum(1 for r in results if r["error"] == "Timeout") + 314 exceptions = sum(1 for r in results if r["error"] and r["error"] != "Timeout") + 315 ise_errors = sum(1 for r in results if r.get("has_ise") or r.get("has_500")) + 316 + 317 print(f"Total routes tested: {total}") + 318 print(f"Successful (2xx): {successful}") + 319 print(f"Client errors (4xx): {client_errors}") + 320 print(f"Server errors (5xx): {server_errors}") + 321 print(f"Timeouts: {timeouts}") + 322 print(f"Exceptions: {exceptions}") + 323 print(f"Internal Server Errors: {ise_errors}") + 324 print() + 325 + 326 if has_errors: + 327 print("Routes with issues:") + 328 for r in results: + 329 if r["status_code"] and r["status_code"] >= 500: + 330 print(f" - {r['method']} {r['route']} -> {r['status_code']}") + 331 elif r["error"]: + 332 print(f" - {r['method']} {r['route']} -> ERROR: {r['error']}") + 333 elif r.get("has_ise"): + 334 print(f" - {r['method']} {r['route']} -> Internal Server Error") + 335 + 336 print() + 337 if ise_errors == 0 and exceptions == 0: + 338 print("✅ All routes passed! No Internal Server Errors detected.") + 339 return 0 + 340 else: + 341 print("❌ Some routes have issues. Please review the output above.") + 342 return 1 + 343 + 344 + 345 if __name__ == "__main__": + 346 sys.exit(main()) + +``` + + +## Assistant + + +Good, there's an existing test script. But this script doesn't include all the form POST endpoints in admin_web.py. Let me first check if the server is running and then create a more comprehensive test for all the form POST endpoints. + +Let me first check if the server is running and check the logs. + + +Let me check if the server is running and then run comprehensive tests on the form POST endpoints: + + +**Tool Call: curl -s http://localhost:8000/health 2>/dev/null || echo "Server not running"** +Status: Completed + +Terminal: +``` +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +{"status":"healthy","service":"IRT Bank Soal","version":"1.0.0","database":"connected","environment":"development"} +``` + + +**Tool Call: lsof -i :8000 2>/dev/null || echo "No process on port 8000"** +Status: Completed + +Terminal: +``` +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +Python 46304 dwindown 3u IPv4 0xcd2ab67e3c98a800 0t0 TCP *:irdmi (LISTEN) +Python 56454 dwindown 3u IPv4 0xcd2ab67e3c98a800 0t0 TCP *:irdmi (LISTEN) + +``` + + +## Assistant + + +Good, the server is running. Now let me create a comprehensive test for all the form POST endpoints and run it. I need to identify all form POST endpoints from admin_web.py. Let me list them all: + +1. `/admin/login` - POST login +2. `/admin/password` - POST password change +3. `/admin/websites` - POST create website +4. `/admin/websites/{website_id}/edit` - POST edit website +5. `/admin/websites/{website_id}/delete` - POST delete website +6. `/admin/tryout-import/preview` - POST tryout import preview +7. `/admin/tryout-import` - POST tryout import submit +8. `/admin/snapshot-questions/promote-bulk` - POST promote bulk +9. `/admin/basis-items/{basis_item_id}/generate` - POST generate variants +10. `/admin/basis-items/{basis_item_id}/review-bulk` - POST review bulk +11. `/admin/questions/{item_id}/generate` - POST generate question variants +12. `/admin/questions/{item_id}/generate/review-bulk` - POST review bulk + +I need to first login and get a session cookie, then test these endpoints. + + +Good, server is running. Now let me create a comprehensive test for all form POST endpoints. First, let me identify all the admin POST endpoints and get an authenticated session: + + +**Tool Call: test\_form\_posts.py** +Status: Completed + +Diff: test_form_posts.py +``` +#!/usr/bin/env python3 +""" +Test all form POST endpoints for Internal Server Errors. +""" + +import json +import sys + +import httpx + +BASE_URL = "http://localhost:8000" + +# All form POST endpoints from admin_web.py +FORM_POST_ENDPOINTS = [ + # (endpoint, method, form_data, description) + ( + "/admin/login", + "POST", + {"username": "admin", "password": "admin123"}, + "Admin login", + ), + ( + "/admin/password", + "POST", + { + "old_password": "admin123", + "new_password": "admin123", + "re_new_password": "admin123", + }, + "Change password", + ), + ( + "/admin/websites", + "POST", + { + "site_name": "Test Site", + "site_url": "https://test.example.com", + }, + "Create website", + ), + ( + "/admin/websites/1/edit", + "POST", + { + "site_name": "Updated Test Site", + "site_url": "https://updated.example.com", + }, + "Edit website", + ), + ("/admin/websites/1/delete", "POST", {}, "Delete website"), + ( + "/admin/tryout-import/preview", + "POST", + { + "website_id": "1", + }, + "Tryout import preview (no file)", + ), + ( + "/admin/tryout-import", + "POST", + { + "website_id": "1", + "preview_token": "invalid-token", + }, + "Tryout import submit", + ), + ( + "/admin/snapshot-questions/promote-bulk", + "POST", + { + "snapshot_id": "1", + "snapshot_question_ids": [], + }, + "Promote snapshot questions bulk", + ), + ( + "/admin/basis-items/1/generate", + "POST", + { + "target_level": "mudah", + "ai_model": "", + "generation_count": "1", + "operator_notes": "", + }, + "Generate variants for basis item", + ), + ( + "/admin/basis-items/1/review-bulk", + "POST", + { + "item_ids": ["1"], + "action": "approved", + }, + "Review bulk variants", + ), + ( + "/admin/questions/1/generate", + "POST", + { + "target_level": "mudah", + "ai_model": "meta-llama/llama-4-maverick:free", + "generation_count": "1", + "operator_notes": "", + "include_note_for_admin": True, + "include_note_in_prompt": False, + }, + "Generate question variants", + ), + ( + "/admin/questions/1/generate/review-bulk", + "POST", + { + "item_ids": ["1"], + "action": "approved", + "tab": "review", + }, + "Review question variants bulk", + ), +] + +# API POST endpoints +API_POST_ENDPOINTS = [ + ( + "/api/v1/session/", + { + "session_id": "test-session-123", + "tryout_id": "test", + "wp_user_id": "123", + "website_id": 1, + "scoring_mode": "ctt", + }, + "Create session", + ), + ( + "/api/v1/session/test-session-123/complete", + { + "end_time": "2024-01-01T00:00:00Z", + "user_answers": [], + }, + "Complete session", + ), + ( + "/api/v1/session/test-session-123/submit_answer", + { + "item_id": 1, + "response": "A", + "time_spent": 10, + }, + "Submit answer", + ), + ( + "/api/v1/wordpress/verify_session", + { + "website_id": 1, + "wp_user_id": "123", + "token": "test", + }, + "Verify WordPress session", + ), + ( + "/api/v1/reports/schedule", + { + "tryout_id": "test", + "report_type": "student_performance", + }, + "Schedule report", + ), + ( + "/api/v1/admin/cat/test", + { + "tryout_id": "test", + "website_id": 1, + }, + "Test CAT algorithm", + ), + ("/api/v1/admin/1/calibrate", {}, "Calibrate tryout"), + ("/api/v1/admin/1/toggle-ai-generation", {}, "Toggle AI generation"), + ("/api/v1/admin/1/reset-normalization", {}, "Reset normalization"), +] + + +def get_admin_session(): + """Login and get session cookies for admin access.""" + with httpx.Client(base_url=BASE_URL, follow_redirects=True, timeout=30.0) as client: + # Try to login + response = client.post( + "/admin/login", + data={ + "username": "admin", + "password": "admin123", + }, + ) + print(f"Login response: {response.status_code}") + + # Check if we have admin access + response = client.get("/admin") + print(f"Admin page response: {response.status_code}") + + # Return cookies + return client.cookies + + +def test_endpoint( + client: httpx.Client, endpoint: str, method: str, data: dict, cookies: dict = None +) -> dict: + """Test a single endpoint.""" + headers = {"X-Website-ID": "1"} + + try: + if method == "POST": + # Check if this looks like form data or JSON + if isinstance(data, dict) and all( + isinstance(v, str) or v is None for v in data.values() + ): + # Form data + response = client.post( + endpoint, + data=data, + headers=headers, + cookies=cookies, + timeout=30.0, + follow_redirects=True, + ) + else: + # JSON data + response = client.post( + endpoint, + json=data, + headers=headers, + cookies=cookies, + timeout=30.0, + follow_redirects=True, + ) + else: + response = client.request( + method, + endpoint, + headers=headers, + cookies=cookies, + timeout=30.0, + follow_redirects=True, + ) + + # Check for internal server error + has_ise = ( + response.status_code == 500 + or "Internal Server Error" in response.text + or "500 Internal Server Error" in response.text + ) + + # Check for traceback + has_traceback = "Traceback" in response.text + + return { + "endpoint": endpoint, + "method": method, + "status_code": response.status_code, + "has_ise": has_ise, + "has_traceback": has_traceback, + "response_preview": response.text[:500] if response.text else "", + "redirect_location": response.headers.get("location", ""), + } + except httpx.TimeoutException: + return { + "endpoint": endpoint, + "method": method, + "status_code": None, + "has_ise": False, + "has_traceback": False, + "response_preview": "", + "error": "Timeout", + } + except Exception as e: + return { + "endpoint": endpoint, + "method": method, + "status_code": None, + "has_ise": False, + "has_traceback": False, + "response_preview": "", + "error": str(e), + } + + +def main(): + print("=" * 80) + print("Testing all Form POST endpoints for Internal Server Errors") + print("=" * 80) + print() + + # Get admin session + print("Getting admin session...") + cookies = get_admin_session() + print() + + results = [] + has_errors = False + + with httpx.Client(base_url=BASE_URL, follow_redirects=True, timeout=30.0) as client: + # Test admin form POST endpoints + print("-" * 80) + print("ADMIN FORM POST ENDPOINTS") + print("-" * 80) + + for endpoint, method, data, description in FORM_POST_ENDPOINTS: + print(f"\nTesting: {description}") + print(f" Endpoint: {endpoint}") + + result = test_endpoint(client, endpoint, method, data, cookies) + results.append((description, result)) + + status = result["status_code"] + error_details = "" + + if result.get("error"): + error_details = f" [ERROR: {result['error']}]" + has_errors = True + elif result.get("has_traceback"): + error_details = f" [TRACEBACK!]" + has_errors = True + print(f" Response: {result['response_preview'][:1000]}") + elif result.get("has_ise"): + error_details = f" [INTERNAL SERVER ERROR!]" + has_errors = True + print(f" Response: {result['response_preview'][:1000]}") + + status_str = str(status) if status else "N/A" + print(f" Status: {status_str}{error_details}") + + if result.get("redirect_location"): + print(f" Redirect: {result['redirect_location']}") + + # Test API POST endpoints + print() + print("-" * 80) + print("API POST ENDPOINTS") + print("-" * 80) + + for endpoint, data, description in API_POST_ENDPOINTS: + print(f"\nTesting: {description}") + print(f" Endpoint: {endpoint}") + + result = test_endpoint(client, endpoint, "POST", data, cookies) + results.append((description, result)) + + status = result["status_code"] + error_details = "" + + if result.get("error"): + error_details = f" [ERROR: {result['error']}]" + has_errors = True + elif result.get("has_traceback"): + error_details = f" [TRACEBACK!]" + has_errors = True + print(f" Response: {result['response_preview'][:1000]}") + elif result.get("has_ise"): + error_details = f" [INTERNAL SERVER ERROR!]" + has_errors = True + print(f" Response: {result['response_preview'][:1000]}") + + status_str = str(status) if status else "N/A" + print(f" Status: {status_str}{error_details}") + + # Summary + print() + print("=" * 80) + print("SUMMARY") + print("=" * 80) + + total = len(results) + ise_errors = sum(1 for _, r in results if r.get("has_ise")) + tracebacks = sum(1 for _, r in results if r.get("has_traceback")) + timeouts = sum(1 for _, r in results if r.get("error") == "Timeout") + exceptions = sum( + 1 for _, r in results if r.get("error") and r.get("error") != "Timeout" + ) + + print(f"Total endpoints tested: {total}") + print(f"Internal Server Errors: {ise_errors}") + print(f"Tracebacks: {tracebacks}") + print(f"Timeouts: {timeouts}") + print(f"Exceptions: {exceptions}") + print() + + if ise_errors > 0 or tracebacks > 0: + print("Endpoints with issues:") + for desc, r in results: + if r.get("has_ise") or r.get("has_traceback"): + print(f" - {desc}: {r['endpoint']} -> {r['status_code']}") + if r.get("has_traceback"): + print(f" Traceback detected in response") + + print() + if has_errors: + print("❌ Some endpoints have issues. Please review the output above.") + return 1 + else: + print("✅ All endpoints passed! No Internal Server Errors detected.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + +``` + + +## Assistant + + +Now let me run this test script. + + +**Tool Call: cd yellow-bank-soal && python test_form_posts.py 2>&1** +Status: Completed + +Terminal: +``` +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +sh: cd: yellow-bank-soal: No such file or directory + +``` + + +## Assistant + + +Hmm, I need to use the correct path. Let me check the current working directory and the project root. + + +**Tool Call: pwd && ls -la** +Status: Completed + +Terminal: +``` +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +/Users/dwindown/Applications/yellow-bank-soal +total 720 +drwxr-xr-x 43 dwindown staff 1376 Jun 17 00:04 . +drwx------@ 14 dwindown staff 448 Jun 15 13:51 .. +drwxr-xr-x@ 3 dwindown staff 96 Jun 15 13:52 .claude +-rw-r--r--@ 1 dwindown staff 871 Jun 15 23:22 .env +-rw-r--r--@ 1 dwindown staff 1029 Jun 15 13:52 .env.example +drwxr-xr-x@ 13 dwindown staff 416 Jun 17 00:04 .git +-rw-r--r--@ 1 dwindown staff 248 Jun 15 13:52 .gitignore +drwxr-xr-x@ 6 dwindown staff 192 Jun 16 00:47 .pytest_cache +drwxr-xr-x@ 3 dwindown staff 96 Jun 16 16:37 .sixth +-rw-r--r--@ 1 dwindown staff 22725 Jun 15 13:52 AAPANEL_DEPLOYMENT.md +-rw-r--r--@ 1 dwindown staff 34905 Jun 15 14:21 ADMIN_UI_REDESIGN_PLAN.md +-rw-r--r--@ 1 dwindown staff 5078 Jun 15 13:52 AI_HYBRID_GENERATION_WORKFLOW.md +-rw-r--r--@ 1 dwindown staff 5365 Jun 15 13:52 AI_PARENT_VARIANT_WORKFLOW.md +-rw-r--r--@ 1 dwindown staff 9704 Jun 15 13:52 DEFECT_GAP_AUDIT_REPORT.md +-rw-r--r-- 1 dwindown staff 492 Jun 16 16:34 Dockerfile +-rw-r--r--@ 1 dwindown staff 5864 Jun 15 13:52 FASTAPI_ADMIN_1.0.4_FIX_SCRIPT.md +-rw-r--r--@ 1 dwindown staff 5103 Jun 15 13:52 FASTAPI_ADMIN_1.0.4_MIGRATION.md +-rw-r--r--@ 1 dwindown staff 23536 Jun 15 13:52 MULTISITE_WORDPRESS_AUTH.md +-rw-r--r--@ 1 dwindown staff 28952 Jun 15 13:52 PRD.md +-rw-r--r--@ 1 dwindown staff 18166 Jun 15 13:55 PROJECT_UNDERSTANDING.md +-rw-r--r--@ 1 dwindown staff 3062 Jun 15 13:52 SQLALCHEMY_QUOTING_FIX.md +-rw-r--r--@ 1 dwindown staff 39340 Jun 15 13:52 TEST.md +-rw-r--r--@ 1 dwindown staff 3465 Jun 15 13:52 TESTING_WALKTHROUGH.md +drwxr-xr-x@ 7 dwindown staff 224 Jun 16 16:33 alembic +-rw-r--r--@ 1 dwindown staff 4862 Jun 15 20:02 alembic.ini +drwxr-xr-x@ 15 dwindown staff 480 Jun 16 20:08 app +-rw-r--r--@ 1 dwindown staff 782 Jun 16 16:39 docker-compose.dev.yml +drwxr-xr-x@ 3 dwindown staff 96 Jun 15 23:59 docs +-rw-r--r--@ 1 dwindown staff 0 Jun 16 08:46 error.html +-rw-r--r--@ 1 dwindown staff 5658 Jun 15 13:52 handoff.md +-rw-r--r--@ 1 dwindown staff 7192 Jun 15 13:52 hands-off.md +-rw-r--r--@ 1 dwindown staff 4300 Jun 15 13:52 irt_1pl_mle.py +-rw-r--r--@ 1 dwindown staff 836 Jun 16 00:10 patch_css.py +-rw-r--r--@ 1 dwindown staff 881 Jun 16 00:10 patch_icons.py +-rw-r--r--@ 1 dwindown staff 30955 Jun 15 13:52 project-brief.md +-rw-r--r--@ 1 dwindown staff 578 Jun 15 19:58 requirements.txt +-rwxr-xr-x@ 1 dwindown staff 1876 Jun 16 16:40 run_local.sh +-rw-r--r--@ 1 dwindown staff 11992 Jun 16 23:59 test_all_routes.py +-rw-r--r--@ 1 dwindown staff 199 Jun 16 08:47 test_error.py +-rw-r--r--@ 1 dwindown staff 279 Jun 16 08:49 test_fetch.py +-rw-r--r--@ 1 dwindown staff 11501 Jun 17 00:04 test_form_posts.py +drwxr-xr-x@ 11 dwindown staff 352 Jun 16 00:47 tests +drwxr-xr-x@ 6 dwindown staff 192 Jun 15 20:05 venv + +``` + + +## Assistant + + +OK, I'm already in the project directory. Let me run the test script. + + +**Tool Call: python test_form_posts.py 2>&1** +Status: Completed + +Terminal: +``` +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +================================================================================ +Testing all Form POST endpoints for Internal Server Errors +================================================================================ + +Getting admin session... +Login response: 403 +Admin page response: 200 + +-------------------------------------------------------------------------------- +ADMIN FORM POST ENDPOINTS +-------------------------------------------------------------------------------- + +Testing: Admin login + Endpoint: /admin/login + Status: 403 + +Testing: Change password + Endpoint: /admin/password + Status: 403 + +Testing: Create website + Endpoint: /admin/websites + Status: 403 + +Testing: Edit website + Endpoint: /admin/websites/1/edit + Status: 403 + +Testing: Delete website + Endpoint: /admin/websites/1/delete + Status: 403 + +Testing: Tryout import preview (no file) + Endpoint: /admin/tryout-import/preview + Status: 403 + +Testing: Tryout import submit + Endpoint: /admin/tryout-import + Status: 403 + +Testing: Promote snapshot questions bulk + Endpoint: /admin/snapshot-questions/promote-bulk + Status: 403 + +Testing: Generate variants for basis item + Endpoint: /admin/basis-items/1/generate + Status: 403 + +Testing: Review bulk variants + Endpoint: /admin/basis-items/1/review-bulk + Status: 403 + +Testing: Generate question variants + Endpoint: /admin/questions/1/generate + Status: 403 + +Testing: Review question variants bulk + Endpoint: /admin/questions/1/generate/review-bulk + Status: 403 + +-------------------------------------------------------------------------------- +API POST ENDPOINTS +-------------------------------------------------------------------------------- + +Testing: Create session + Endpoint: /api/v1/session/ + Status: 401 + +Testing: Complete session + Endpoint: /api/v1/session/test-session-123/complete + Status: 401 + +Testing: Submit answer + Endpoint: /api/v1/session/test-session-123/submit_answer + Status: 401 + +Testing: Verify WordPress session + Endpoint: /api/v1/wordpress/verify_session + Status: 200 + +Testing: Schedule report + Endpoint: /api/v1/reports/schedule + Status: 401 + +Testing: Test CAT algorithm + Endpoint: /api/v1/admin/cat/test + Status: 401 + +Testing: Calibrate tryout + Endpoint: /api/v1/admin/1/calibrate + Status: 401 + +Testing: Toggle AI generation + Endpoint: /api/v1/admin/1/toggle-ai-generation + Status: 401 + +Testing: Reset normalization + Endpoint: /api/v1/admin/1/reset-normalization + Status: 401 + +================================================================================ +SUMMARY +================================================================================ +Total endpoints tested: 21 +Internal Server Errors: 0 +Tracebacks: 0 +Timeouts: 0 +Exceptions: 0 + + +✅ All endpoints passed! No Internal Server Errors detected. + +``` + + +## Assistant + + +Good - no Internal Server Errors detected. The 403 responses are expected because we're not authenticated properly (CSRF protection or session issue), and 401 responses are expected for API endpoints that require authentication. + +The user mentioned specifically the variant approval endpoint. Let me check the actual issue by looking at the server logs. But first, let me look more closely at the code to understand how the bulk review works. The user said approving variant triggers "Internal Server Error". + +Let me look at the variant approval code more closely and see if there are any issues. + + +The tests show no Internal Server Errors, but most endpoints return 403 (CSRF protection). Let me check the server logs to see if there are any 5xx errors, and also investigate the specific variant approval flow more closely: + + +**Tool Call: tail -100 server.log 2>/dev/null || tail -100 uvicorn.log 2>/dev/null || echo "No log files found"** +Status: Completed + +Terminal: +``` +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +No log files found + +``` + + +**Tool Call: Read file `app/admin_web.py` (lines 5036-5080)** +Status: Completed + +```yellow-bank-soal/app/admin_web.py + 5036 @router.post("/questions/{item_id}/generate/review-bulk") + 5037 async def question_generate_review_bulk( + 5038 request: Request, + 5039 item_id: int, + 5040 db: AsyncSession = Depends(get_db), + 5041 item_ids: list[int] = Form([]), + 5042 action: str = Form(...), + 5043 tab: str = "review", + 5044 ): + 5045 admin = await _current_admin(request) + 5046 if not admin: + 5047 return _login_redirect() + 5048 + 5049 valid_actions = {"approved", "rejected", "archived", "stale", "active"} + 5050 if action not in valid_actions: + 5051 return RedirectResponse(url=f"/admin/questions/{item_id}/generate?tab={tab}&error=Invalid action", status_code=HTTP_303_SEE_OTHER) + 5052 + 5053 if not item_ids: + 5054 return RedirectResponse(url=f"/admin/questions/{item_id}/generate?tab={tab}&error=No items selected", status_code=HTTP_303_SEE_OTHER) + 5055 + 5056 result = await db.execute(select(Item).where(Item.id.in_(item_ids))) + 5057 variants = list(result.scalars().all()) + 5058 + 5059 now = datetime.now(timezone.utc) + 5060 for v in variants: + 5061 v.variant_status = action + 5062 v.reviewed_by = admin.username + 5063 v.reviewed_at = now + 5064 v.updated_at = now + 5065 + 5066 await db.commit() + 5067 + 5068 return RedirectResponse( + 5069 url=f"/admin/questions/{item_id}/generate?tab={tab}&success=Successfully applied {action} to {len(variants)} variants.", + 5070 status_code=HTTP_303_SEE_OTHER, + 5071 ) + 5072 + 5073 + 5074 @router.get("/tryout/list", include_in_schema=False) + 5075 @router.get("/item/list", include_in_schema=False) + 5076 @router.get("/user/list", include_in_schema=False) + 5077 @router.get("/session/list", include_in_schema=False) + 5078 @router.get("/tryoutstats/list", include_in_schema=False) + 5079 async def legacy_admin_paths(request: Request): + 5080 admin = await _current_admin(request) + +``` + + +## Assistant + + +Looking at the `question_generate_review_bulk` function (lines 5036-5071), I can see it: +1. Gets the current admin user +2. Validates the action +3. Checks for empty item_ids +4. Updates the items' variant_status and related fields +5. Commits to the database +6. Redirects with success message + +The issue might be that this function expects `item_ids` as a list, but in HTML forms, multiple select with the same name `item_ids` might be submitted differently. + +Let me also look at the `basis_item_review_bulk` function to compare. Let me also check if there are any issues with the form field parsing for `item_ids`. + +Let me also look at the template that generates the form to see how the checkbox values are submitted. + + +Let me look at the form generation to see how `item_ids` are submitted: + + +**Tool Call: Search files for regex `name.*item_ids|snapshot_question_ids`** +Status: Completed + +Found 15 matches: + +## Matches in yellow-bank-soal/app/admin_web.py + +### def _snapshot_questions_body › L1100-1102 +``` + else: + select_html = f'' + action_html = "Ready to promote" +``` + +### def _snapshot_questions_body › L1116-1121 +``` + '' + f'' + '
' + '' + "
" + '' +``` + +### def _hierarchy_view_body › L1364-1372 +``` + for snapshot in snapshots: + snapshot_question_ids = { + question.id for question in questions_by_snapshot.get(snapshot.id, []) + } + linked_basis = [ + item + for question_id in snapshot_question_ids + for item in basis_by_source_question.get(question_id, []) + ] +``` + +2 lines remaining in ancestor node. Read the file to see all. + +### def _hierarchy_view_body › L1399-1406 +``` + snapshot_questions = questions_by_snapshot.get(snapshot.id, []) + snapshot_question_ids = {question.id for question in snapshot_questions} + snapshot_basis = sorted( + [ + item + for question_id in snapshot_question_ids + for item in basis_by_source_question.get(question_id, []) + ], +``` + +45 lines remaining in ancestor node. Read the file to see all. + +### def _basis_item_workspace_body › L1638-1648 +``` + "" + f'' + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" +``` + +2 lines remaining in ancestor node. Read the file to see all. + +### def _basis_item_workspace_body › L1662-1666 +``` + '' + "" + '
SlotSource Question IDCorrectOptionsActiveStemAction
{item.id}{item.generation_run_id or '-'}{escape(item.level)}{escape(item.variant_status)}{escape(item.ai_model or '-')}{int(usage['impressions'])}{int(usage['unique_users'])}{usage['frequency']:.2f}{escape(_truncate(_html_to_text(item.stem), 130))}{review_html}
' + + ( + "".join(variant_rows) +``` + +### async def questions_view › L2507-2517 +``` + question_rows.append(f""" + + + + " + f'' + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" +``` + +2 lines remaining in ancestor node. Read the file to see all. + +### async def _promote_snapshot_question_to_item › L1969-1973 +``` + generated_by="manual", + source_snapshot_question_id=question.id, + variant_status="active", + calibrated=False, + calibration_sample_size=0, +``` + +### async def basis_item_workspace_view › L4174-4175 +``` + if status_filter: + variants = [item for item in variants if item.variant_status == status_filter] +``` + +### async def basis_item_generate_submit › L4324-4328 +``` + generation_run_id=run_id, + source_snapshot_question_id=basis_item.source_snapshot_question_id, + variant_status="draft", + db=db, + ) +``` + +### async def basis_item_review_bulk › L4409-4412 +``` + for item in items: + item.variant_status = action + item.reviewed_by = admin.username + item.reviewed_at = reviewed_at +``` + +### L4450-4454 +``` + ("runs", "Batches"), +) +AI_VARIANT_STATUSES = ("draft", "approved", "active", "rejected", "archived", "stale") +AI_VARIANT_LEVELS = ("mudah", "sulit") + +``` + +### def _ai_review_tab › L4622-4632 +``` + status_options = [''] + for status in AI_VARIANT_STATUSES: + status_options.append( + f'' + ) + level_options = [''] + for level in AI_VARIANT_LEVELS: + level_options.append( + f'' + ) + +``` + +63 lines remaining in ancestor node. Read the file to see all. + +### def _ai_review_tab › L4637-4647 +``` + "" + f'' + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f'' +``` + +1 lines remaining in ancestor node. Read the file to see all. + +### def _ai_form_body › L4712-4716 +``` + include_note_in_prompt: bool = False, + active_tab: str = "generate", + variant_status_filter: str = "", + variant_level_filter: str = "", + variant_run_id_filter: str = "", +``` + +### def _ai_form_body › L4736-4742 +``` + "review": _ai_review_tab( + item, + generated_variants, + variant_status_filter, + variant_level_filter, + variant_run_id_filter, + ), +``` + + +## Assistant + + +Now let me look at the Item model to understand its structure better. + + +**Tool Call: Read file `app/models/item.py`** +Status: Completed + +```yellow-bank-soal/app/models/item.py + 1 """ + 2 Item model for questions with CTT and IRT parameters. + 3 + 4 Represents individual questions with both classical test theory (CTT) + 5 and item response theory (IRT) parameters. + 6 """ + 7 + 8 from datetime import datetime + 9 from typing import Literal, Union + 10 + 11 from sqlalchemy import ( + 12 Boolean, + 13 CheckConstraint, + 14 DateTime, + 15 Float, + 16 ForeignKey, + 17 ForeignKeyConstraint, + 18 Index, + 19 Integer, + 20 JSON, + 21 String, + 22 Text, + 23 func, + 24 ) + 25 from sqlalchemy.orm import Mapped, mapped_column, relationship + 26 + 27 from app.database import Base + 28 + 29 + 30 class Item(Base): + 31 """ + 32 Item model representing individual questions. + 33 + 34 Supports both CTT (p, bobot, category) and IRT (b, se) parameters. + 35 Tracks AI generation metadata and calibration status. + 36 + 37 Attributes: + 38 id: Primary key + 39 tryout_id: Tryout identifier + 40 website_id: Website identifier + 41 slot: Question position in tryout + 42 level: Difficulty level (mudah, sedang, sulit) + 43 stem: Question text + 44 options: JSON array of answer options + 45 correct_answer: Correct option (A, B, C, D) + 46 explanation: Answer explanation + 47 ctt_p: CTT difficulty (proportion correct) + 48 ctt_bobot: CTT weight (1 - p) + 49 ctt_category: CTT difficulty category + 50 irt_b: IRT difficulty parameter [-3, +3] + 51 irt_se: IRT standard error + 52 calibrated: Calibration status + 53 calibration_sample_size: Sample size for calibration + 54 generated_by: Generation source (manual, ai) + 55 ai_model: AI model used (if generated by AI) + 56 basis_item_id: Original item ID (for AI variants) + 57 created_at: Record creation timestamp + 58 updated_at: Record update timestamp + 59 tryout: Tryout relationship + 60 user_answers: User responses to this item + 61 """ + 62 + 63 __tablename__ = "items" + 64 + 65 # Primary key + 66 id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + 67 + 68 # Foreign keys + 69 tryout_id: Mapped[str] = mapped_column( + 70 String(255), nullable=False, index=True, comment="Tryout identifier" + 71 ) + 72 website_id: Mapped[int] = mapped_column( + 73 ForeignKey("websites.id", ondelete="CASCADE", onupdate="CASCADE"), + 74 nullable=False, + 75 index=True, + 76 comment="Website identifier", + 77 ) + 78 + 79 # Position and difficulty + 80 slot: Mapped[int] = mapped_column( + 81 Integer, nullable=False, comment="Question position in tryout" + 82 ) + 83 level: Mapped[Literal["mudah", "sedang", "sulit"]] = mapped_column( + 84 String(50), nullable=False, comment="Difficulty level" + 85 ) + 86 + 87 # Question content + 88 stem: Mapped[str] = mapped_column(Text, nullable=False, comment="Question text") + 89 options: Mapped[dict] = mapped_column( + 90 JSON, + 91 nullable=False, + 92 comment="JSON object with options (e.g., {\"A\": \"option1\", \"B\": \"option2\"})", + 93 ) + 94 correct_answer: Mapped[str] = mapped_column( + 95 String(10), nullable=False, comment="Correct option (A, B, C, D)" + 96 ) + 97 explanation: Mapped[Union[str, None]] = mapped_column( + 98 Text, nullable=True, comment="Answer explanation" + 99 ) + 100 + 101 # CTT parameters + 102 ctt_p: Mapped[Union[float, None]] = mapped_column( + 103 Float, + 104 nullable=True, + 105 comment="CTT difficulty (proportion correct)", + 106 ) + 107 ctt_bobot: Mapped[Union[float, None]] = mapped_column( + 108 Float, + 109 nullable=True, + 110 comment="CTT weight (1 - p)", + 111 ) + 112 ctt_category: Mapped[Union[Literal["mudah", "sedang", "sulit"], None]] = mapped_column( + 113 String(50), + 114 nullable=True, + 115 comment="CTT difficulty category", + 116 ) + 117 + 118 # IRT parameters (1PL Rasch model) + 119 irt_b: Mapped[Union[float, None]] = mapped_column( + 120 Float, + 121 nullable=True, + 122 comment="IRT difficulty parameter [-3, +3]", + 123 ) + 124 irt_se: Mapped[Union[float, None]] = mapped_column( + 125 Float, + 126 nullable=True, + 127 comment="IRT standard error", + 128 ) + 129 + 130 # Calibration status + 131 calibrated: Mapped[bool] = mapped_column( + 132 Boolean, nullable=False, default=False, comment="Calibration status" + 133 ) + 134 calibration_sample_size: Mapped[int] = mapped_column( + 135 Integer, + 136 nullable=False, + 137 default=0, + 138 comment="Sample size for calibration", + 139 ) + 140 + 141 # AI generation metadata + 142 generated_by: Mapped[Literal["manual", "ai"]] = mapped_column( + 143 String(50), + 144 nullable=False, + 145 default="manual", + 146 comment="Generation source", + 147 ) + 148 ai_model: Mapped[Union[str, None]] = mapped_column( + 149 String(255), + 150 nullable=True, + 151 comment="AI model used (if generated by AI)", + 152 ) + 153 basis_item_id: Mapped[Union[int, None]] = mapped_column( + 154 ForeignKey("items.id", ondelete="SET NULL", onupdate="CASCADE"), + 155 nullable=True, + 156 comment="Original item ID (for AI variants)", + 157 ) + 158 generation_run_id: Mapped[Union[int, None]] = mapped_column( + 159 ForeignKey("ai_generation_runs.id", ondelete="SET NULL", onupdate="CASCADE"), + 160 nullable=True, + 161 index=True, + 162 comment="AI generation run ID", + 163 ) + 164 source_snapshot_question_id: Mapped[Union[int, None]] = mapped_column( + 165 ForeignKey("tryout_snapshot_questions.id", ondelete="SET NULL", onupdate="CASCADE"), + 166 nullable=True, + 167 index=True, + 168 comment="Source snapshot question ID", + 169 ) + 170 variant_status: Mapped[str] = mapped_column( + 171 String(50), + 172 nullable=False, + 173 default="active", + 174 comment="Lifecycle status (active/draft/approved/rejected/archived/stale)", + 175 ) + 176 reviewed_by: Mapped[Union[str, None]] = mapped_column( + 177 String(255), + 178 nullable=True, + 179 comment="Reviewer username", + 180 ) + 181 reviewed_at: Mapped[Union[datetime, None]] = mapped_column( + 182 DateTime(timezone=True), + 183 nullable=True, + 184 comment="Review timestamp", + 185 ) + 186 review_notes: Mapped[Union[str, None]] = mapped_column( + 187 Text, + 188 nullable=True, + 189 comment="Review notes", + 190 ) + 191 + 192 # Timestamps + 193 created_at: Mapped[datetime] = mapped_column( + 194 DateTime(timezone=True), nullable=False, server_default=func.now() + 195 ) + 196 updated_at: Mapped[datetime] = mapped_column( + 197 DateTime(timezone=True), + 198 nullable=False, + 199 server_default=func.now(), + 200 onupdate=func.now(), + 201 ) + 202 + 203 # Relationships + 204 tryout: Mapped["Tryout"] = relationship( + 205 "Tryout", back_populates="items", lazy="selectin" + 206 ) + 207 user_answers: Mapped[list["UserAnswer"]] = relationship( + 208 "UserAnswer", back_populates="item", lazy="selectin", cascade="all, delete-orphan" + 209 ) + 210 basis_item: Mapped[Union["Item", None]] = relationship( + 211 "Item", + 212 remote_side=[id], + 213 back_populates="variants", + 214 lazy="selectin", + 215 single_parent=True, + 216 ) + 217 variants: Mapped[list["Item"]] = relationship( + 218 "Item", + 219 back_populates="basis_item", + 220 lazy="selectin", + 221 cascade="all, delete-orphan", + 222 ) + 223 generation_run: Mapped[Union["AIGenerationRun", None]] = relationship( + 224 "AIGenerationRun", + 225 back_populates="generated_items", + 226 foreign_keys=[generation_run_id], + 227 lazy="selectin", + 228 ) + 229 + 230 # Constraints and indexes + 231 __table_args__ = ( + 232 ForeignKeyConstraint( + 233 ["website_id", "tryout_id"], + 234 ["tryouts.website_id", "tryouts.tryout_id"], + 235 name="fk_items_tryout", + 236 ondelete="CASCADE", + 237 onupdate="CASCADE", + 238 ), + 239 Index( + 240 "ix_items_tryout_id_website_id_slot", + 241 "tryout_id", + 242 "website_id", + 243 "slot", + 244 "level", + 245 unique=False, + 246 ), + 247 Index("ix_items_calibrated", "calibrated"), + 248 Index("ix_items_basis_item_id", "basis_item_id"), + 249 Index("ix_items_variant_status", "variant_status"), + 250 # IRT b parameter constraint [-3, +3] + 251 CheckConstraint( + 252 "irt_b IS NULL OR (irt_b >= -3 AND irt_b <= 3)", + 253 "ck_irt_b_range", + 254 ), + 255 # CTT p constraint [0, 1] + 256 CheckConstraint( + 257 "ctt_p IS NULL OR (ctt_p >= 0 AND ctt_p <= 1)", + 258 "ck_ctt_p_range", + 259 ), + 260 # CTT bobot constraint [0, 1] + 261 CheckConstraint( + 262 "ctt_bobot IS NULL OR (ctt_bobot >= 0 AND ctt_bobot <= 1)", + 263 "ck_ctt_bobot_range", + 264 ), + 265 # Slot must be positive + 266 CheckConstraint("slot > 0", "ck_slot_positive"), + 267 ) + 268 + 269 def __repr__(self) -> str: + 270 return f"" + +``` + + +## Assistant + + +I see that `variant_status` has `nullable=False` but with `default="active"`. This should be fine. + +Let me now look more carefully at the variant approval code. The user mentioned that when approving a variant, they get "Internal Server Error". + +Let me look at what might be causing the issue. The issue could be: +1. The Item model has some fields that might cause issues when updating +2. The `updated_at` field is being set explicitly in the code but should be handled by the ORM +3. Some relationship loading might be causing issues + +Let me check the `question_generate_review_bulk` function again more closely: + +```python +@router.post("/questions/{item_id}/generate/review-bulk") +async def question_generate_review_bulk( + request: Request, + item_id: int, + db: AsyncSession = Depends(get_db), + item_ids: list[int] = Form([]), + action: str = Form(...), + tab: str = "review", +): + ... + for v in variants: + v.variant_status = action + v.reviewed_by = admin.username + v.reviewed_at = now + v.updated_at = now # This might cause issues! +``` + +The `updated_at` field is defined with `onupdate=func.now()` which should automatically update. However, explicitly setting it might conflict with this. + +Actually, wait - looking at line 4325-4326 in the save_ai_question function call: +```python +variant_status="draft", +``` + +This means AI-generated items are created with `variant_status="draft"`. But the model has `default="active"` for `variant_status`. + +Let me check if there's a validation issue with the status values. The valid actions are: +```python +valid_actions = {"approved", "rejected", "archived", "stale", "active"} + + +``` + +And `variant_status` can be: +```python +variant_status: Mapped[str] = mapped_column( + String(50), + nullable=False, + default="active", + comment="Lifecycle status (active/draft/approved/rejected/archived/stale)", +) +``` + +The status values should all be valid. The issue might be that `updated_at` is being explicitly set when the model has `onupdate=func.now()` defined, which could create a conflict. I should also consider whether the database session is being committed properly or if there are validation issues with the Item model itself. + +Looking at the relationship definitions, the `basis_item` and `variants` relationships both use `single_parent=True` on the back-reference, which could be problematic if I'm creating circular references between items. When I query for variants with `result = await db.execute(select(Item).where(Item.id.in_(item_ids)))`, this should work fine, but the issue might stem from how these relationships are being loaded or validated during the update. + +``` + + +Let me check if there are any validation issues by looking at the CSRF middleware and examining the server logs more carefully: + + +**Tool Call: Search files for regex `csrf|_csrf|validate_csrap|validate_form`** +Status: Completed + +Found 11 matches: + +## Matches in yellow-bank-soal/app/admin_web.py + +### L58-62 +``` + +SESSION_COOKIE = "access_token" +CSRF_COOKIE = "admin_csrf_token" +SESSION_PREFIX = "admin:session:" +IMPORT_PREVIEW_PREFIX = "admin:import-preview:" +``` + +### def _render_auth_page › L296-305 +``` + +""" + csrf_token = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24) + csrf_input = f'' + html = re.sub( + r'(]*method="post"[^>]*>)', + r"\1" + csrf_input, + html, + flags=re.IGNORECASE, + ) +``` + +### def _render_auth_page › L307-314 +``` + response.set_cookie( + CSRF_COOKIE, + csrf_token, + path="/admin", + httponly=False, + secure=settings.ENVIRONMENT == "production", + samesite="lax", + ) +``` + +### def _render_admin_page › L774-783 +``` + +""" + csrf_token = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24) + csrf_input = f'' + html = re.sub( + r'(]*method="post"[^>]*>)', + r"\1" + csrf_input, + html, + flags=re.IGNORECASE, + ) +``` + +### def _render_admin_page › L785-792 +``` + response.set_cookie( + CSRF_COOKIE, + csrf_token, + path="/admin", + httponly=False, + secure=settings.ENVIRONMENT == "production", + samesite="lax", + ) +``` + +### def _verify_csrf › L796-801 +``` +def _verify_csrf(request: Request, csrf_token: str | None) -> None: + cookie_token = request.cookies.get(CSRF_COOKIE) + if not cookie_token or not csrf_token: + raise HTTPException(status_code=403, detail="CSRF validation failed") + if not secrets.compare_digest(cookie_token, csrf_token): + raise HTTPException(status_code=403, detail="CSRF validation failed") +``` + +### async def _enforce_csrf › L804-806 +``` +async def _enforce_csrf(request: Request) -> None: + form = await request.form() + _verify_csrf(request, form.get("csrf_token")) +``` + +### async def _csrf_route_guard › L809-812 +``` +async def _csrf_route_guard(request: Request) -> None: + if request.method.upper() != "POST": + return + await _enforce_csrf(request) +``` + +### L813-817 +``` + + +router.dependencies.append(Depends(_csrf_route_guard)) + + +``` + +## Matches in yellow-bank-soal/hands-off.md + +### # Yellow Bank Soal Perfection Tasklist › ## 1. Security and Auth › L14-15 +``` +- [x] Add CSRF tokens to all admin POST forms. +- [x] Mark admin session cookies `secure` in production. +``` + +### # Yellow Bank Soal Perfection Tasklist › ## Definition of Perfect Enough › L133-134 +``` +- [ ] Admin destructive actions are CSRF-protected. +- [ ] Live sessions cannot reveal answers before completion. +``` + + +**Tool Call: Read file `app/admin_web.py` (lines 295-350)** +Status: Completed + +```yellow-bank-soal/app/admin_web.py + 295 + 296 + 297 """ + 298 csrf_token = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24) + 299 csrf_input = f'' + 300 html = re.sub( + 301 r'(]*method="post"[^>]*>)', + 302 r"\1" + csrf_input, + 303 html, + 304 flags=re.IGNORECASE, + 305 ) + 306 response = HTMLResponse(html, status_code=status_code) + 307 response.set_cookie( + 308 CSRF_COOKIE, + 309 csrf_token, + 310 path="/admin", + 311 httponly=False, + 312 secure=settings.ENVIRONMENT == "production", + 313 samesite="lax", + 314 ) + 315 return response + 316 + 317 + 318 def _render_admin_page( + 319 request: Request, title: str, page_title: str, body: str + 320 ) -> HTMLResponse: + 321 sidebar_links = _admin_nav_links(request) + 322 html = f""" + 323 + 324 + 325 + 326 + 327 {escape(title)} + 328 + + + +
Item IDRun IDLevelStatusModelImpressionsUnique UsersFrequencyStemCreated At
#{item.id} + {stem_preview} +
+ {difficulty_label} + | + Used {item.calibration_sample_size or 0}x + | +``` + +14 lines remaining in ancestor node. Read the file to see all. + +### async def questions_view › L2575-2585 +``` + '
' + '' + "" + "" + '' + '' + "" + '' + '' + '' + "" +``` + +2 lines remaining in ancestor node. Read the file to see all. + +### async def snapshot_question_promote_bulk › L3895-3900 +``` +async def snapshot_question_promote_bulk( + request: Request, + snapshot_id: int = Form(...), + snapshot_question_ids: list[int] | None = Form(None), + db: AsyncSession = Depends(get_db), +): +``` + +### async def snapshot_question_promote_bulk › L3914-3918 +``` + return _render_admin_page(request, "Tryout Import", "Tryout Import", body) + + if not snapshot_question_ids: + questions, promoted_items_by_slot, _ = await _load_snapshot_question_context( + snapshot, db +``` + +### async def snapshot_question_promote_bulk › L3931-3933 +``` + select(TryoutSnapshotQuestion).where( + TryoutSnapshotQuestion.id.in_(snapshot_question_ids) + ) +``` + +### def _ai_review_tab › L4637-4647 +``` + "" + f'' + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f'' +``` + +1 lines remaining in ancestor node. Read the file to see all. + +### def _ai_review_tab › L4684-4688 +``` +
IDQuestionLevelStatusActions
{item.id}{item.generation_run_id or '-'}{item.basis_item_id or '-'}{escape(item.level)}{_status_pill(item.variant_status)}{escape(_truncate(item.ai_model or '-', 42))}{escape(stem_preview)}{escape(str(item.created_at))}View
+ + + + +``` + +### def _ai_variant_detail_body › L4831-4835 +``` + + + +
+ +``` + +This sends a single hidden value. However, when multiple checkboxes are selected, the browser sends multiple values with the same name. + +The issue might be that: +1. The `item_ids` field is a `list[int] = Form([])` which should work with multiple values +2. But when there's only one value submitted as a hidden field, it might be sent as a string instead of a list + +Let me also check if there's any issue with the CSRF token or session handling. + +Let me create a more comprehensive test that: +1. Properly gets a CSRF token +2. Submits forms with proper session and CSRF handling + + +Let me look at the `_ai_variant_detail_body` function to understand how the form is structured: + + +**Tool Call: Read file `app/admin_web.py` (lines 4790-4890)** +Status: Completed + +```yellow-bank-soal/app/admin_web.py + 4790 ) + 4791 + "
Item IDBatch IDBasisLevelStatusModelStemCreated AtAction
" + 4792 ) + 4793 + 4794 + 4795 def _ai_variant_detail_body(variant: Item, basis_item: Item | None) -> str: + 4796 explanation = _html_to_text(variant.explanation) if variant.explanation else "-" + 4797 basis_preview = "-" + 4798 if basis_item is not None: + 4799 basis_preview = ( + 4800 f"#{basis_item.id} | Tryout {escape(str(basis_item.tryout_id))} | " + 4801 f"Slot {basis_item.slot} | {escape(_truncate(_html_to_text(basis_item.stem), 160))}" + 4802 ) + 4803 review_url = f"/admin/questions/{variant.basis_item_id}/generate?tab=review" if variant.basis_item_id else "/admin/basis-items" + 4804 if variant.generation_run_id: + 4805 review_url = f"{review_url}&run_id={variant.generation_run_id}" + 4806 + 4807 return f""" + 4808
+ 4809
Item{variant.id}
+ 4810
Batch{variant.generation_run_id or "-"}
+ 4811
Level{escape(variant.level)}
+ 4812
Status{escape(variant.variant_status)}
+ 4813
+ 4814
+ 4815

Question

+ 4816

{escape(_html_to_text(variant.stem))}

+ 4817
+ 4818

Answer Options

+ 4819 {_options_table(variant.options, variant.correct_answer)} + 4820
+ 4821

Correct Answer

+ 4822

{escape(variant.correct_answer)}

+ 4823

Pembahasan

+ 4824

{escape(explanation)}

+ 4825
+ 4826
+ 4827

Generation Context

+ 4828

Basis item: {basis_preview}

+ 4829

Model: {escape(variant.ai_model or "-")}

+ 4830

Created at: {escape(str(variant.created_at))}

+ 4831
+ 4832 + 4833 + 4834
+ 4835 + 4842 + 4843 Back to Review Queue + 4844
+ 4845 + 4846 """ + 4847 + 4848 + 4849 + 4850 + 4851 + 4852 + 4853 @router.get("/questions/{item_id}/generate") + 4854 async def question_generate_view( + 4855 request: Request, + 4856 item_id: int, + 4857 tab: str = "generate", + 4858 status: str = "", + 4859 level: str = "", + 4860 run_id: str = "", + 4861 db: AsyncSession = Depends(get_db), + 4862 ): + 4863 admin = await _current_admin(request) + 4864 if not admin: + 4865 return _login_redirect() + 4866 + 4867 result = await db.execute(select(Item).where(Item.id == item_id)) + 4868 item = result.scalar_one_or_none() + 4869 if not item: + 4870 return RedirectResponse(url="/admin/questions", status_code=HTTP_303_SEE_OTHER) + 4871 + 4872 stats = await get_ai_stats(db) + 4873 + 4874 # Fetch runs and variants specific to this item + 4875 runs_result = await db.execute( + 4876 select(AIGenerationRun) + 4877 .where(AIGenerationRun.basis_item_id == item.id) + 4878 .order_by(AIGenerationRun.created_at.desc()) + 4879 ) + 4880 generation_runs = list(runs_result.scalars().all()) + 4881 + 4882 stmt = select(Item).where( + 4883 Item.basis_item_id == item.id, + 4884 Item.variant_status != None, + 4885 ) + 4886 if status: + 4887 stmt = stmt.where(Item.variant_status == status) + 4888 if level: + 4889 stmt = stmt.where(Item.level == level) + 4890 if run_id and run_id.isdigit(): + +``` + + +## Assistant + + +I see the form structure now. When the user clicks "Approve this item", it submits to `/admin/questions/{variant.basis_item_id}/generate/review-bulk?tab=review` with: +- `item_ids` = `{variant.id}` (a single value) +- `action` = `approved` + +The issue is that `item_ids` is a `list[int]` but in HTML forms with a hidden input, it will be submitted as a string. FastAPI should handle this automatically. + +Let me look at the `snapshot_question_promote_bulk` endpoint as well to see if there are any issues there. Also, let me check the server logs or run a more detailed test. + +Let me also check the server startup script to see how to run the server and capture logs. + + +Let me look at the run script and check how the server is started: + + +**Tool Call: Read file `run_local.sh`** +Status: Completed + +```yellow-bank-soal/run_local.sh + 1 #!/bin/bash + 2 # Run local development server + 3 + 4 set -e + 5 + 6 echo "🚀 Starting IRT Bank Soal Local Dev Server" + 7 echo "==========================================" + 8 + 9 # Check if Docker is available + 10 if ! command -v docker &> /dev/null; then + 11 echo "❌ Docker not found. Please install Docker first." + 12 exit 1 + 13 fi + 14 + 15 # Check if docker-compose is available + 16 if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then + 17 echo "❌ Docker Compose not found. Please install Docker Compose first." + 18 exit 1 + 19 fi + 20 + 21 # Use docker compose command (Docker Desktop includes it as a plugin) + 22 DOCKER_COMPOSE="docker compose" + 23 + 24 # Start databases + 25 echo "📦 Starting PostgreSQL and Redis..." + 26 $DOCKER_COMPOSE -f docker-compose.dev.yml up -d postgres redis + 27 + 28 # Wait for PostgreSQL to be ready + 29 echo "⏳ Waiting for PostgreSQL..." + 30 for i in {1..60}; do + 31 if docker exec yellow-bank-soal-postgres-1 pg_isready -U irt_user -d irt_bank_soal &> /dev/null 2>&1; then + 32 echo "✅ PostgreSQL is ready!" + 33 break + 34 fi + 35 if [ $i -eq 60 ]; then + 36 echo "❌ PostgreSQL failed to start" + 37 docker logs yellow-bank-soal-postgres-1 + 38 exit 1 + 39 fi + 40 sleep 1 + 41 done + 42 + 43 # Check if venv exists, create if not + 44 if [ ! -d "venv" ]; then + 45 echo "📦 Creating Python virtual environment..." + 46 python3 -m venv venv + 47 fi + 48 + 49 # Activate venv and install dependencies + 50 echo "📦 Installing dependencies..." + 51 source venv/bin/activate + 52 pip install -r requirements.txt -q + 53 + 54 # Run migrations + 55 echo "🔄 Running database migrations..." + 56 alembic upgrade head + 57 + 58 # Start the dev server + 59 echo "" + 60 echo "🎉 Starting FastAPI dev server..." + 61 echo " Admin UI: http://localhost:8000/admin" + 62 echo " API Docs: http://localhost:8000/docs" + 63 echo " Login: admin / admin123" + 64 echo "" + 65 echo "==========================================" + 66 exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + +``` + + +**Tool Call: ps aux | grep python | grep -v grep** +Status: Completed + +Terminal: +``` +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +The operation couldn’t be completed. Unable to locate a Java Runtime. +Please visit http://www.java.com for information on installing Java. + +dwindown 46311 0.0 0.0 435266672 5472 ?? S 11:00PM 0:00.02 /opt/homebrew/Cellar/python@3.10/3.10.19_1/Frameworks/Python.framework/Versions/3.10/Resources/Python.app/Contents/MacOS/Python -c from multiprocessing.resource_tracker import main;main(5) +dwindown 46304 0.0 0.1 435499872 8864 ?? S 11:00PM 0:03.84 /opt/homebrew/Cellar/python@3.10/3.10.19_1/Frameworks/Python.framework/Versions/3.10/Resources/Python.app/Contents/MacOS/Python /Users/dwindown/Applications/yellow-bank-soal/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +dwindown 59775 0.0 0.3 435564336 46192 ?? S 12:04AM 0:01.30 /opt/homebrew/Cellar/python@3.10/3.10.19_1/Frameworks/Python.framework/Versions/3.10/Resources/Python.app/Contents/MacOS/Python -c from multiprocessing.spawn import spawn_main; spawn_main(tracker_fd=6, pipe_handle=8) --multiprocessing-fork + +``` + + +## Assistant + + +I see the server is running with uvicorn. Let me check if there are any logs being generated and look for any errors. Also, let me look more closely at the variant approval flow to understand the issue better. + +Let me now look at the database structure and Item model to understand what fields are involved. + + +Let me check the Item model and look at the database structure: + + +**Tool Call: Search files for regex `class Item|variant_status|reviewed_by`** +Status: Completed + +Showing matches 1-20 (there were more matches found; use offset: 20 to see next page): + +## Matches in yellow-bank-soal/alembic/versions/20260404_000003_ai_runs_and_variant_status.py + +### def upgrade › L50-55 +``` + op.add_column("items", sa.Column("generation_run_id", sa.Integer(), nullable=True)) + op.add_column("items", sa.Column("source_snapshot_question_id", sa.Integer(), nullable=True)) + op.add_column("items", sa.Column("variant_status", sa.String(length=50), nullable=False, server_default="active")) + op.add_column("items", sa.Column("reviewed_by", sa.String(length=255), nullable=True)) + op.add_column("items", sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("items", sa.Column("review_notes", sa.Text(), nullable=True)) +``` + +### def upgrade › L80-84 +``` + unique=False, + ) + op.create_index("ix_items_variant_status", "items", ["variant_status"], unique=False) + + op.drop_index("ix_items_tryout_id_website_id_slot", table_name="items") +``` + +### def upgrade › L90-94 +``` + ) + + op.alter_column("items", "variant_status", server_default=None) + + +``` + +### def downgrade › L96-106 +``` + op.drop_index("ix_items_tryout_id_website_id_slot", table_name="items") + op.create_index( + "ix_items_tryout_id_website_id_slot", + "items", + ["tryout_id", "website_id", "slot", "level"], + unique=True, + ) + + op.drop_index("ix_items_variant_status", table_name="items") + op.drop_index("ix_items_source_snapshot_question_id", table_name="items") + op.drop_index("ix_items_generation_run_id", table_name="items") +``` + +12 lines remaining in ancestor node. Read the file to see all. + +### def downgrade › L109-114 +``` + op.drop_column("items", "review_notes") + op.drop_column("items", "reviewed_at") + op.drop_column("items", "reviewed_by") + op.drop_column("items", "variant_status") + op.drop_column("items", "source_snapshot_question_id") + op.drop_column("items", "generation_run_id") +``` + +## Matches in yellow-bank-soal/app/admin.py + +### class ItemResource › L384-394 +``` +class ItemResource(Model): + """ + Admin resource for Item model. + + Displays items with CTT and IRT parameters, and calibration status. + """ + + label = "Items" + model = Item + page_size = 50 + +``` + +84 lines remaining in ancestor node. Read the file to see all. + +### class ItemStatisticsLink › L650-660 +``` +class ItemStatisticsLink(Link): + """ + Link to item statistics view. + + Displays items grouped by difficulty level with calibration status. + """ + + label = "Item Statistics" + icon = "fas fa-chart-bar" + url = "/admin/item_statistics" + +``` + +57 lines remaining in ancestor node. Read the file to see all. + +## Matches in yellow-bank-soal/app/admin_web.py + +### async def _recent_generated_variants › L1158-1159 +``` + if status_filter: + stmt = stmt.where(Item.variant_status == status_filter) +``` + +### def _variant_status_counts_html › L1218-1223 +``` +def _variant_status_counts_html(variants: list[Item]) -> str: + if not variants: + return 'No variants' + counts: dict[str, int] = {} + for variant in variants: + counts[variant.variant_status] = counts.get(variant.variant_status, 0) + 1 +``` + +4 lines remaining in ancestor node. Read the file to see all. + +### def _basis_hierarchy_item_html › L1305-1309 +``` + variant_counts = ( + _variant_status_counts_html(variants) + if variants + else '0 variants' + ) +``` + +### def _basis_item_workspace_body › L1638-1648 +``` + "
{item.id}{item.generation_run_id or '-'}{escape(item.level)}{escape(item.variant_status)}{escape(item.ai_model or '-')}{int(usage['impressions'])}{int(usage['unique_users'])}{usage['frequency']:.2f}{escape(_truncate(_html_to_text(item.stem), 130))}{review_html}
{item.id}{item.generation_run_id or '-'}{item.basis_item_id or '-'}{escape(item.level)}{_status_pill(item.variant_status)}{escape(_truncate(item.ai_model or '-', 42))}{escape(stem_preview)}{escape(str(item.created_at))}View
+ +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..f57fffd --- /dev/null +++ b/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/frontend/src/components/ui/textarea.tsx b/frontend/src/components/ui/textarea.tsx new file mode 100644 index 0000000..4d858bb --- /dev/null +++ b/frontend/src/components/ui/textarea.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Textarea = React.forwardRef< + HTMLTextAreaElement, + React.ComponentProps<"textarea"> +>(({ className, ...props }, ref) => { + return ( +