Checkpoint React frontend migration
This commit is contained in:
365
ADMIN_TRYOUT_RESTRUCTURE_PLAN.md
Normal file
365
ADMIN_TRYOUT_RESTRUCTURE_PLAN.md
Normal file
@@ -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`<br>- Move import button to `/admin/tryouts` header<br>- Add normalization page spec (4.5)<br>- Rename import page spec (4.6)<br>- Update navigation and action buttons |
|
||||||
615
FRONTEND_MIGRATION_AUDIT_REPORT.md
Normal file
615
FRONTEND_MIGRATION_AUDIT_REPORT.md
Normal file
@@ -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.
|
||||||
31
FRONTEND_MIGRATION_CUTOVER.md
Normal file
31
FRONTEND_MIGRATION_CUTOVER.md
Normal file
@@ -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
|
||||||
439
UX_AUDIT_ADMIN_FLOW.md
Normal file
439
UX_AUDIT_ADMIN_FLOW.md
Normal file
@@ -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
|
||||||
|
<!-- Add to login page -->
|
||||||
|
<div class="login-header">
|
||||||
|
<img src="/static/logo.png" alt="IRT Bank Soal" class="login-logo">
|
||||||
|
<h1>IRT Bank Soal</h1>
|
||||||
|
<p>Adaptive Question Bank System</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Dashboard - Add "How It Works" Section
|
||||||
|
|
||||||
|
Add this block to dashboard after greeting:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="onboarding-flow">
|
||||||
|
<h3>How Your Exam System Works</h3>
|
||||||
|
<div class="flow-steps">
|
||||||
|
<div class="step">
|
||||||
|
<span class="step-num">1</span>
|
||||||
|
<span class="step-title">Add Website</span>
|
||||||
|
<span class="step-desc">Connect your WordPress site</span>
|
||||||
|
</div>
|
||||||
|
<div class="step-arrow">→</div>
|
||||||
|
<div class="step">
|
||||||
|
<span class="step-num">2</span>
|
||||||
|
<span class="step-title">Import Questions</span>
|
||||||
|
<span class="step-desc">Upload your exam questions</span>
|
||||||
|
</div>
|
||||||
|
<div class="step-arrow">→</div>
|
||||||
|
<div class="step">
|
||||||
|
<span class="step-num">3</span>
|
||||||
|
<span class="step-title">Generate Variants</span>
|
||||||
|
<span class="step-desc">AI creates different versions</span>
|
||||||
|
</div>
|
||||||
|
<div class="step-arrow">→</div>
|
||||||
|
<div class="step">
|
||||||
|
<span class="step-num">4</span>
|
||||||
|
<span class="step-title">Students Take Tests</span>
|
||||||
|
<span class="step-desc">Adaptive difficulty adjusts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/hierarchy" class="flow-link">View full data structure →</a>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Dashboard - Add "Get Started" for Empty State
|
||||||
|
|
||||||
|
When `tryouts_count == 0`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="getting-started">
|
||||||
|
<h2>🚀 Welcome to IRT Bank Soal!</h2>
|
||||||
|
<p>Get started in 3 simple steps:</p>
|
||||||
|
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step-card">
|
||||||
|
<span class="num">1</span>
|
||||||
|
<h3>Connect a Website</h3>
|
||||||
|
<p>Add your WordPress site to the system</p>
|
||||||
|
<a href="/admin/websites" class="btn">Add Website →</a>
|
||||||
|
</div>
|
||||||
|
<div class="step-card">
|
||||||
|
<span class="num">2</span>
|
||||||
|
<h3>Import Questions</h3>
|
||||||
|
<p>Upload questions from Excel or JSON</p>
|
||||||
|
<a href="/admin/tryout-import" class="btn">Import Questions →</a>
|
||||||
|
</div>
|
||||||
|
<div class="step-card">
|
||||||
|
<span class="num">3</span>
|
||||||
|
<h3>Generate Variants</h3>
|
||||||
|
<p>Use AI to create question variations</p>
|
||||||
|
<a href="/admin/basis-items" class="btn">Generate Variants →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
<nav class="breadcrumbs">
|
||||||
|
<a href="/admin/dashboard">Dashboard</a>
|
||||||
|
<span class="sep">›</span>
|
||||||
|
<a href="/admin/questions">Questions</a>
|
||||||
|
<span class="sep">›</span>
|
||||||
|
<span class="current">Question #123</span>
|
||||||
|
</nav>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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*
|
||||||
@@ -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.",
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
@@ -15,4 +15,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run migrations and start the app
|
# 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"]
|
||||||
@@ -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")
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -102,9 +102,7 @@ EMOJI_TO_ICON = {
|
|||||||
# Navigation icon mapping
|
# Navigation icon mapping
|
||||||
NAV_ICONS_SVG = {
|
NAV_ICONS_SVG = {
|
||||||
"Dashboard": ICON_DASHBOARD,
|
"Dashboard": ICON_DASHBOARD,
|
||||||
"Questions": ICON_QUESTIONS,
|
"Import": ICON_IMPORT,
|
||||||
"Import Questions": ICON_IMPORT,
|
|
||||||
"AI Generator": ICON_AI,
|
|
||||||
"Exams": ICON_EXAMS,
|
"Exams": ICON_EXAMS,
|
||||||
"Reports": ICON_REPORTS,
|
"Reports": ICON_REPORTS,
|
||||||
"Settings": ICON_SETTINGS,
|
"Settings": ICON_SETTINGS,
|
||||||
@@ -50,6 +50,9 @@ class NextItemResponse(BaseModel):
|
|||||||
options: Optional[dict] = None
|
options: Optional[dict] = None
|
||||||
slot: Optional[int] = None
|
slot: Optional[int] = None
|
||||||
level: Optional[str] = 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
|
selection_method: Optional[str] = None
|
||||||
reason: Optional[str] = None
|
reason: Optional[str] = None
|
||||||
current_theta: Optional[float] = None
|
current_theta: Optional[float] = None
|
||||||
@@ -212,6 +215,11 @@ async def get_next_item_endpoint(
|
|||||||
options=item.options,
|
options=item.options,
|
||||||
slot=item.slot,
|
slot=item.slot,
|
||||||
level=item.level,
|
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,
|
selection_method=result.selection_method,
|
||||||
reason=result.reason,
|
reason=result.reason,
|
||||||
current_theta=session.theta,
|
current_theta=session.theta,
|
||||||
@@ -21,7 +21,7 @@ settings = get_settings()
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AuthContext:
|
class AuthContext:
|
||||||
website_id: int
|
website_id: Optional[int]
|
||||||
role: str
|
role: str
|
||||||
wp_user_id: Optional[str] = None
|
wp_user_id: Optional[str] = None
|
||||||
|
|
||||||
@@ -36,13 +36,13 @@ def _b64url_decode(raw: str) -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
def issue_access_token(
|
def issue_access_token(
|
||||||
website_id: int,
|
website_id: int | None,
|
||||||
role: str = "student",
|
role: str = "student",
|
||||||
wp_user_id: str | None = None,
|
wp_user_id: str | None = None,
|
||||||
expires_in_seconds: int = 3600,
|
expires_in_seconds: int = 3600,
|
||||||
) -> str:
|
) -> str:
|
||||||
payload = {
|
payload = {
|
||||||
"website_id": int(website_id),
|
"website_id": int(website_id) if website_id is not None else None,
|
||||||
"role": role,
|
"role": role,
|
||||||
"wp_user_id": wp_user_id,
|
"wp_user_id": wp_user_id,
|
||||||
"exp": int(time.time()) + int(expires_in_seconds),
|
"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")
|
website_id = payload.get("website_id")
|
||||||
role = payload.get("role")
|
role = payload.get("role")
|
||||||
if website_id is None or not role:
|
if not role:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Access token missing required claims",
|
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(
|
return AuthContext(
|
||||||
website_id=int(website_id),
|
website_id=int(website_id) if website_id is not None else None,
|
||||||
role=str(role),
|
role=str(role),
|
||||||
wp_user_id=payload.get("wp_user_id"),
|
wp_user_id=payload.get("wp_user_id"),
|
||||||
)
|
)
|
||||||
@@ -106,6 +111,7 @@ def decode_access_token(token: str) -> AuthContext:
|
|||||||
|
|
||||||
def get_auth_context(
|
def get_auth_context(
|
||||||
authorization: str | None = Header(None, alias="Authorization"),
|
authorization: str | None = Header(None, alias="Authorization"),
|
||||||
|
x_website_id: str | None = Header(None, alias="X-Website-ID"),
|
||||||
) -> AuthContext:
|
) -> AuthContext:
|
||||||
if authorization is None:
|
if authorization is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -118,25 +124,45 @@ def get_auth_context(
|
|||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid Authorization header format. Use: Bearer {token}",
|
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(
|
def require_website_auth(
|
||||||
auth: AuthContext,
|
auth: AuthContext,
|
||||||
allowed_roles: set[str] | None = None,
|
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:
|
if allowed_roles is not None and auth.role not in allowed_roles:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Insufficient permissions for this endpoint",
|
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
|
return auth.website_id
|
||||||
|
|
||||||
|
|
||||||
def ensure_website_scope_matches(
|
def ensure_website_scope_matches(
|
||||||
auth_website_id: int,
|
auth_website_id: int | None,
|
||||||
payload_website_id: int,
|
payload_website_id: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if auth_website_id is None:
|
||||||
|
return
|
||||||
if int(auth_website_id) != int(payload_website_id):
|
if int(auth_website_id) != int(payload_website_id):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
@@ -4,10 +4,10 @@ Application configuration using Pydantic Settings.
|
|||||||
Loads configuration from environment variables with validation.
|
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 import Field, field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -98,8 +98,8 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# CORS - stored as list, accepts comma-separated string from env
|
# CORS - stored as list, accepts comma-separated string from env
|
||||||
ALLOWED_ORIGINS: List[str] = Field(
|
ALLOWED_ORIGINS: Annotated[List[str], NoDecode] = Field(
|
||||||
default=["http://localhost:3000"],
|
default=["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:5173"],
|
||||||
description="List of allowed CORS origins",
|
description="List of allowed CORS origins",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,11 +31,13 @@ from app.database import close_db, init_db
|
|||||||
from app.routers import (
|
from app.routers import (
|
||||||
admin_router,
|
admin_router,
|
||||||
ai_router,
|
ai_router,
|
||||||
|
auth_router,
|
||||||
import_export_router,
|
import_export_router,
|
||||||
reports_router,
|
reports_router,
|
||||||
sessions_router,
|
sessions_router,
|
||||||
tryouts_router,
|
tryouts_router,
|
||||||
wordpress_router,
|
wordpress_router,
|
||||||
|
websites_router,
|
||||||
)
|
)
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -190,6 +192,10 @@ async def health_check():
|
|||||||
|
|
||||||
|
|
||||||
# Include API routers with version prefix
|
# Include API routers with version prefix
|
||||||
|
app.include_router(
|
||||||
|
auth_router,
|
||||||
|
prefix=f"{settings.API_V1_STR}",
|
||||||
|
)
|
||||||
app.include_router(
|
app.include_router(
|
||||||
import_export_router,
|
import_export_router,
|
||||||
)
|
)
|
||||||
@@ -213,6 +219,10 @@ app.include_router(
|
|||||||
reports_router,
|
reports_router,
|
||||||
prefix=f"{settings.API_V1_STR}",
|
prefix=f"{settings.API_V1_STR}",
|
||||||
)
|
)
|
||||||
|
app.include_router(
|
||||||
|
websites_router,
|
||||||
|
prefix=f"{settings.API_V1_STR}",
|
||||||
|
)
|
||||||
|
|
||||||
if settings.ENABLE_ADMIN:
|
if settings.ENABLE_ADMIN:
|
||||||
app.include_router(
|
app.include_router(
|
||||||
@@ -89,6 +89,9 @@ class Session(Base):
|
|||||||
end_time: Mapped[Union[datetime, None]] = mapped_column(
|
end_time: Mapped[Union[datetime, None]] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True, comment="Session end timestamp"
|
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(
|
is_completed: Mapped[bool] = mapped_column(
|
||||||
Boolean, nullable=False, default=False, comment="Completion status"
|
Boolean, nullable=False, default=False, comment="Completion status"
|
||||||
)
|
)
|
||||||
@@ -4,18 +4,22 @@ API routers package.
|
|||||||
|
|
||||||
from app.routers.admin import router as admin_router
|
from app.routers.admin import router as admin_router
|
||||||
from app.routers.ai import router as ai_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.import_export import router as import_export_router
|
||||||
from app.routers.reports import router as reports_router
|
from app.routers.reports import router as reports_router
|
||||||
from app.routers.sessions import router as sessions_router
|
from app.routers.sessions import router as sessions_router
|
||||||
from app.routers.tryouts import router as tryouts_router
|
from app.routers.tryouts import router as tryouts_router
|
||||||
from app.routers.wordpress import router as wordpress_router
|
from app.routers.wordpress import router as wordpress_router
|
||||||
|
from app.routers.websites import router as websites_router
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"admin_router",
|
"admin_router",
|
||||||
"ai_router",
|
"ai_router",
|
||||||
|
"auth_router",
|
||||||
"import_export_router",
|
"import_export_router",
|
||||||
"reports_router",
|
"reports_router",
|
||||||
"sessions_router",
|
"sessions_router",
|
||||||
"tryouts_router",
|
"tryouts_router",
|
||||||
"wordpress_router",
|
"wordpress_router",
|
||||||
|
"websites_router",
|
||||||
]
|
]
|
||||||
1077
backend/app/routers/admin.py
Normal file
1077
backend/app/routers/admin.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ import logging
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from sqlalchemy import and_, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.config import get_settings
|
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.database import get_db
|
||||||
from app.models.item import Item
|
from app.models.item import Item
|
||||||
from app.schemas.ai import (
|
from app.schemas.ai import (
|
||||||
|
AIBatchGeneratedItem,
|
||||||
|
AIGenerateBatchRequest,
|
||||||
|
AIGenerateBatchResponse,
|
||||||
AIGeneratePreviewRequest,
|
AIGeneratePreviewRequest,
|
||||||
AIGeneratePreviewResponse,
|
AIGeneratePreviewResponse,
|
||||||
AISaveRequest,
|
AISaveRequest,
|
||||||
@@ -30,8 +33,13 @@ from app.schemas.ai import (
|
|||||||
)
|
)
|
||||||
from app.services.ai_generation import (
|
from app.services.ai_generation import (
|
||||||
SUPPORTED_MODELS,
|
SUPPORTED_MODELS,
|
||||||
|
combine_usage,
|
||||||
|
create_generation_run,
|
||||||
generate_question,
|
generate_question,
|
||||||
|
generate_questions_batch,
|
||||||
|
generated_matches_basis_options,
|
||||||
get_ai_stats,
|
get_ai_stats,
|
||||||
|
get_model_pricing,
|
||||||
save_ai_question,
|
save_ai_question,
|
||||||
validate_ai_model,
|
validate_ai_model,
|
||||||
)
|
)
|
||||||
@@ -42,6 +50,19 @@ settings = get_settings()
|
|||||||
router = APIRouter(prefix="/admin/ai", tags=["admin", "ai-generation"])
|
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(
|
@router.post(
|
||||||
"/generate-preview",
|
"/generate-preview",
|
||||||
response_model=AIGeneratePreviewResponse,
|
response_model=AIGeneratePreviewResponse,
|
||||||
@@ -107,12 +128,7 @@ async def generate_preview(
|
|||||||
)
|
)
|
||||||
ensure_website_scope_matches(website_id, basis_item.website_id)
|
ensure_website_scope_matches(website_id, basis_item.website_id)
|
||||||
|
|
||||||
# Validate basis item is sedang level
|
_validate_original_basis_item(basis_item)
|
||||||
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}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate question
|
# Generate question
|
||||||
try:
|
try:
|
||||||
@@ -137,6 +153,7 @@ async def generate_preview(
|
|||||||
options=generated.options,
|
options=generated.options,
|
||||||
correct=generated.correct,
|
correct=generated.correct,
|
||||||
explanation=generated.explanation,
|
explanation=generated.explanation,
|
||||||
|
usage=generated.usage,
|
||||||
ai_model=request.ai_model,
|
ai_model=request.ai_model,
|
||||||
basis_item_id=request.basis_item_id,
|
basis_item_id=request.basis_item_id,
|
||||||
target_level=request.target_level,
|
target_level=request.target_level,
|
||||||
@@ -171,7 +188,6 @@ async def generate_preview(
|
|||||||
200: {"description": "Question saved successfully"},
|
200: {"description": "Question saved successfully"},
|
||||||
400: {"description": "Invalid request data"},
|
400: {"description": "Invalid request data"},
|
||||||
404: {"description": "Basis item or tryout not found"},
|
404: {"description": "Basis item or tryout not found"},
|
||||||
409: {"description": "Item already exists at this slot/level"},
|
|
||||||
500: {"description": "Database save failed"},
|
500: {"description": "Database save failed"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -185,8 +201,8 @@ async def generate_save(
|
|||||||
Save AI-generated question to database.
|
Save AI-generated question to database.
|
||||||
|
|
||||||
- **stem**: Question text
|
- **stem**: Question text
|
||||||
- **options**: Dict with A, B, C, D options
|
- **options**: Dict with the same option labels as the basis item
|
||||||
- **correct**: Correct answer (A/B/C/D)
|
- **correct**: Correct answer label from the generated options
|
||||||
- **explanation**: Answer explanation (optional)
|
- **explanation**: Answer explanation (optional)
|
||||||
- **tryout_id**: Tryout identifier
|
- **tryout_id**: Tryout identifier
|
||||||
- **website_id**: Website identifier
|
- **website_id**: Website identifier
|
||||||
@@ -216,26 +232,7 @@ async def generate_save(
|
|||||||
detail=f"Basis item not found: {request.basis_item_id}",
|
detail=f"Basis item not found: {request.basis_item_id}",
|
||||||
)
|
)
|
||||||
ensure_website_scope_matches(website_id, basis_item.website_id)
|
ensure_website_scope_matches(website_id, basis_item.website_id)
|
||||||
|
_validate_original_basis_item(basis_item)
|
||||||
# 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}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create GeneratedQuestion from request
|
# Create GeneratedQuestion from request
|
||||||
from app.schemas.ai import GeneratedQuestion
|
from app.schemas.ai import GeneratedQuestion
|
||||||
@@ -246,6 +243,21 @@ async def generate_save(
|
|||||||
correct=request.correct,
|
correct=request.correct,
|
||||||
explanation=request.explanation,
|
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
|
# Save to database
|
||||||
item_id = await save_ai_question(
|
item_id = await save_ai_question(
|
||||||
@@ -256,6 +268,9 @@ async def generate_save(
|
|||||||
slot=request.slot,
|
slot=request.slot,
|
||||||
level=request.level,
|
level=request.level,
|
||||||
ai_model=request.ai_model,
|
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,
|
db=db,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -268,6 +283,111 @@ async def generate_save(
|
|||||||
return AISaveResponse(
|
return AISaveResponse(
|
||||||
success=True,
|
success=True,
|
||||||
item_id=item_id,
|
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.
|
List supported AI models.
|
||||||
"""
|
"""
|
||||||
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
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 {
|
return {
|
||||||
"models": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": settings.OPENROUTER_MODEL_CHEAP,
|
"id": i.id,
|
||||||
"name": "Mistral Small 4",
|
"tryout_id": i.tryout_id,
|
||||||
"description": "Cheap and fast option for routine variant generation",
|
"level": i.level,
|
||||||
},
|
"stem_text": i.stem_text if hasattr(i, 'stem_text') else i.stem[:100],
|
||||||
{
|
"ai_model": i.ai_model,
|
||||||
"id": settings.OPENROUTER_MODEL_QWEN,
|
"basis_item_id": i.basis_item_id,
|
||||||
"name": "Qwen 2.5 32B Instruct",
|
"created_at": i.created_at,
|
||||||
"description": "Balanced default for structured soal generation",
|
"status": i.variant_status,
|
||||||
},
|
}
|
||||||
{
|
for i in items
|
||||||
"id": settings.OPENROUTER_MODEL_LLAMA,
|
|
||||||
"name": "Llama 3.3 70B",
|
|
||||||
"description": "Premium fallback when you want better quality over cost",
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/review/{item_id}",
|
||||||
|
summary="Approve or reject AI generated question",
|
||||||
|
description="Update the variant_status of an AI generated question.",
|
||||||
|
)
|
||||||
|
async def admin_review_ai_question(
|
||||||
|
item_id: int,
|
||||||
|
status: str, # "active", "rejected"
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
) -> dict:
|
||||||
|
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||||
|
|
||||||
|
result = await db.execute(select(Item).where(Item.id == item_id))
|
||||||
|
item = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
|
if website_id is not None and item.website_id != website_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized for this website")
|
||||||
|
|
||||||
|
if status not in ["active", "rejected"]:
|
||||||
|
raise HTTPException(status_code=400, detail="Status must be active or rejected")
|
||||||
|
|
||||||
|
item.variant_status = status
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"success": True, "item_id": item_id, "status": status}
|
||||||
60
backend/app/routers/auth.py
Normal file
60
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Authentication endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.auth import issue_access_token
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/admin-login",
|
||||||
|
summary="Admin Login",
|
||||||
|
description="Login for standalone app administration.",
|
||||||
|
)
|
||||||
|
async def admin_login(request: LoginRequest) -> Dict[str, Any]:
|
||||||
|
"""Authenticate an app admin and issue a JWT token."""
|
||||||
|
if not settings.ENABLE_ADMIN:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Admin functionality is disabled.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not settings.ADMIN_USERNAME or not settings.ADMIN_PASSWORD:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Admin credentials not configured.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
request.username != settings.ADMIN_USERNAME
|
||||||
|
or request.password != settings.ADMIN_PASSWORD
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid credentials",
|
||||||
|
)
|
||||||
|
|
||||||
|
token = issue_access_token(
|
||||||
|
website_id=None,
|
||||||
|
role="system_admin",
|
||||||
|
expires_in_seconds=86400 * 7, # 7 days
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"role": "system_admin",
|
||||||
|
}
|
||||||
@@ -292,12 +292,6 @@ async def export_questions(
|
|||||||
"""
|
"""
|
||||||
Export questions to Excel file.
|
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:
|
Args:
|
||||||
tryout_id: Tryout identifier
|
tryout_id: Tryout identifier
|
||||||
website_id: Website ID from header
|
website_id: Website ID from header
|
||||||
@@ -394,6 +388,11 @@ async def import_tryout_json(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
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(
|
await enforce_rate_limit(
|
||||||
request,
|
request,
|
||||||
scope="import.tryout_json",
|
scope="import.tryout_json",
|
||||||
@@ -7,7 +7,7 @@ Endpoints:
|
|||||||
- POST /session: Create new session
|
- POST /session: Create new session
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@@ -25,6 +25,7 @@ from app.models.item import Item
|
|||||||
from app.models.session import Session
|
from app.models.session import Session
|
||||||
from app.models.tryout import Tryout
|
from app.models.tryout import Tryout
|
||||||
from app.models.tryout_stats import TryoutStats
|
from app.models.tryout_stats import TryoutStats
|
||||||
|
from app.models.user import User
|
||||||
from app.models.user_answer import UserAnswer
|
from app.models.user_answer import UserAnswer
|
||||||
from app.schemas.session import (
|
from app.schemas.session import (
|
||||||
SessionCompleteRequest,
|
SessionCompleteRequest,
|
||||||
@@ -83,14 +84,15 @@ async def complete_session(
|
|||||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||||
|
|
||||||
# Get session with tryout relationship
|
# Get session with tryout relationship
|
||||||
result = await db.execute(
|
session_query = (
|
||||||
select(Session)
|
select(Session)
|
||||||
.options(selectinload(Session.tryout))
|
.options(selectinload(Session.tryout))
|
||||||
.where(
|
.where(Session.session_id == session_id)
|
||||||
Session.session_id == session_id,
|
|
||||||
Session.website_id == website_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()
|
session = result.scalar_one_or_none()
|
||||||
|
|
||||||
if session is None:
|
if session is None:
|
||||||
@@ -110,18 +112,25 @@ async def complete_session(
|
|||||||
detail="Session does not belong to this authenticated user",
|
detail="Session does not belong to this authenticated user",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
effective_website_id = session.website_id
|
||||||
|
|
||||||
# Get tryout configuration
|
# Get tryout configuration
|
||||||
tryout = session.tryout
|
tryout = session.tryout
|
||||||
|
|
||||||
# Get all items for this tryout to calculate bobot
|
# Get all items for this tryout to calculate bobot
|
||||||
items_result = await db.execute(
|
items_result = await db.execute(
|
||||||
select(Item).where(
|
select(Item).where(
|
||||||
Item.website_id == website_id,
|
Item.website_id == effective_website_id,
|
||||||
Item.tryout_id == session.tryout_id,
|
Item.tryout_id == session.tryout_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
items = {item.id: item for item in items_result.scalars().all()}
|
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
|
# Process each answer
|
||||||
submitted_item_ids = [answer.item_id for answer in request.user_answers]
|
submitted_item_ids = [answer.item_id for answer in request.user_answers]
|
||||||
if len(submitted_item_ids) != len(set(submitted_item_ids)):
|
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",
|
detail="Duplicate item answers are not allowed in a session completion",
|
||||||
)
|
)
|
||||||
|
|
||||||
existing_answers_result = await db.execute(
|
existing_answered_item_ids = {answer.item_id for answer in existing_answer_records}
|
||||||
select(UserAnswer.item_id).where(UserAnswer.session_id == session.session_id)
|
|
||||||
)
|
|
||||||
existing_answered_item_ids = {row[0] for row in existing_answers_result.all()}
|
|
||||||
duplicate_existing_ids = sorted(set(submitted_item_ids) & existing_answered_item_ids)
|
duplicate_existing_ids = sorted(set(submitted_item_ids) & existing_answered_item_ids)
|
||||||
if duplicate_existing_ids:
|
if duplicate_existing_ids:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -148,7 +154,15 @@ async def complete_session(
|
|||||||
total_bobot_earned = 0.0
|
total_bobot_earned = 0.0
|
||||||
user_answer_records = []
|
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)
|
item = items.get(answer_input.item_id)
|
||||||
|
|
||||||
if item is None:
|
if item is None:
|
||||||
@@ -172,7 +186,7 @@ async def complete_session(
|
|||||||
user_answer = UserAnswer(
|
user_answer = UserAnswer(
|
||||||
session_id=session.session_id,
|
session_id=session.session_id,
|
||||||
wp_user_id=session.wp_user_id,
|
wp_user_id=session.wp_user_id,
|
||||||
website_id=website_id,
|
website_id=effective_website_id,
|
||||||
tryout_id=session.tryout_id,
|
tryout_id=session.tryout_id,
|
||||||
item_id=item.id,
|
item_id=item.id,
|
||||||
response=answer_input.response.upper(),
|
response=answer_input.response.upper(),
|
||||||
@@ -187,7 +201,7 @@ async def complete_session(
|
|||||||
# Calculate total_bobot_max for NM calculation
|
# Calculate total_bobot_max for NM calculation
|
||||||
try:
|
try:
|
||||||
total_bobot_max = await get_total_bobot_max(
|
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:
|
except ValueError:
|
||||||
# Fallback: calculate from items we have
|
# Fallback: calculate from items we have
|
||||||
@@ -209,7 +223,7 @@ async def complete_session(
|
|||||||
# Get current stats for dynamic normalization
|
# Get current stats for dynamic normalization
|
||||||
stats_result = await db.execute(
|
stats_result = await db.execute(
|
||||||
select(TryoutStats).where(
|
select(TryoutStats).where(
|
||||||
TryoutStats.website_id == website_id,
|
TryoutStats.website_id == effective_website_id,
|
||||||
TryoutStats.tryout_id == session.tryout_id,
|
TryoutStats.tryout_id == session.tryout_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -226,7 +240,7 @@ async def complete_session(
|
|||||||
# Hybrid: use dynamic if enough data, otherwise static
|
# Hybrid: use dynamic if enough data, otherwise static
|
||||||
stats_result = await db.execute(
|
stats_result = await db.execute(
|
||||||
select(TryoutStats).where(
|
select(TryoutStats).where(
|
||||||
TryoutStats.website_id == website_id,
|
TryoutStats.website_id == effective_website_id,
|
||||||
TryoutStats.tryout_id == session.tryout_id,
|
TryoutStats.tryout_id == session.tryout_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -253,7 +267,7 @@ async def complete_session(
|
|||||||
session.sb_used = sb
|
session.sb_used = sb
|
||||||
|
|
||||||
# Update tryout stats incrementally
|
# 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
|
# Commit all changes
|
||||||
try:
|
try:
|
||||||
@@ -276,6 +290,7 @@ async def complete_session(
|
|||||||
tryout_id=session.tryout_id,
|
tryout_id=session.tryout_id,
|
||||||
start_time=session.start_time,
|
start_time=session.start_time,
|
||||||
end_time=session.end_time,
|
end_time=session.end_time,
|
||||||
|
expires_at=session.expires_at,
|
||||||
is_completed=session.is_completed,
|
is_completed=session.is_completed,
|
||||||
scoring_mode_used=session.scoring_mode_used,
|
scoring_mode_used=session.scoring_mode_used,
|
||||||
total_benar=session.total_benar,
|
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"})
|
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||||
|
|
||||||
result = await db.execute(
|
session_query = select(Session).where(Session.session_id == session_id)
|
||||||
select(Session).where(
|
if website_id is not None:
|
||||||
Session.session_id == session_id,
|
session_query = session_query.where(Session.website_id == website_id)
|
||||||
Session.website_id == website_id,
|
|
||||||
)
|
result = await db.execute(session_query)
|
||||||
)
|
|
||||||
session = result.scalar_one_or_none()
|
session = result.scalar_one_or_none()
|
||||||
|
|
||||||
if session is 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"})
|
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||||
|
|
||||||
ensure_website_scope_matches(website_id, request.website_id)
|
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:
|
if auth.role == "student" and request.wp_user_id != auth.wp_user_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
@@ -384,7 +399,7 @@ async def create_session(
|
|||||||
# Verify tryout exists
|
# Verify tryout exists
|
||||||
tryout_result = await db.execute(
|
tryout_result = await db.execute(
|
||||||
select(Tryout).where(
|
select(Tryout).where(
|
||||||
Tryout.website_id == website_id,
|
Tryout.website_id == effective_website_id,
|
||||||
Tryout.tryout_id == request.tryout_id,
|
Tryout.tryout_id == request.tryout_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -393,7 +408,7 @@ async def create_session(
|
|||||||
if tryout is None:
|
if tryout is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
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
|
# Check if session already exists
|
||||||
@@ -408,14 +423,26 @@ async def create_session(
|
|||||||
detail=f"Session {request.session_id} already exists",
|
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
|
# Create new session
|
||||||
session = Session(
|
session = Session(
|
||||||
session_id=request.session_id,
|
session_id=request.session_id,
|
||||||
wp_user_id=request.wp_user_id,
|
wp_user_id=request.wp_user_id,
|
||||||
website_id=website_id,
|
website_id=effective_website_id,
|
||||||
tryout_id=request.tryout_id,
|
tryout_id=request.tryout_id,
|
||||||
scoring_mode_used=request.scoring_mode,
|
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,
|
is_completed=False,
|
||||||
total_benar=0,
|
total_benar=0,
|
||||||
total_bobot_earned=0.0,
|
total_bobot_earned=0.0,
|
||||||
@@ -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.item import Item
|
||||||
from app.models.tryout import Tryout
|
from app.models.tryout import Tryout
|
||||||
from app.models.tryout_stats import TryoutStats
|
from app.models.tryout_stats import TryoutStats
|
||||||
|
from app.models.tryout_snapshot_question import TryoutSnapshotQuestion
|
||||||
from app.schemas.tryout import (
|
from app.schemas.tryout import (
|
||||||
NormalizationUpdateRequest,
|
NormalizationUpdateRequest,
|
||||||
NormalizationUpdateResponse,
|
NormalizationUpdateResponse,
|
||||||
TryoutConfigBrief,
|
TryoutConfigBrief,
|
||||||
TryoutConfigResponse,
|
TryoutConfigResponse,
|
||||||
|
TryoutConfigUpdateRequest,
|
||||||
TryoutStatsResponse,
|
TryoutStatsResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,14 +55,15 @@ async def get_tryout_config(
|
|||||||
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||||
|
|
||||||
# Get tryout with stats
|
# Get tryout with stats
|
||||||
result = await db.execute(
|
query = (
|
||||||
select(Tryout)
|
select(Tryout)
|
||||||
.options(selectinload(Tryout.stats))
|
.options(selectinload(Tryout.stats))
|
||||||
.where(
|
.where(Tryout.tryout_id == tryout_id)
|
||||||
Tryout.website_id == website_id,
|
|
||||||
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()
|
tryout = result.scalar_one_or_none()
|
||||||
|
|
||||||
if tryout is 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(
|
@router.put(
|
||||||
"/{tryout_id}/normalization",
|
"/{tryout_id}/normalization",
|
||||||
response_model=NormalizationUpdateResponse,
|
response_model=NormalizationUpdateResponse,
|
||||||
@@ -134,12 +204,11 @@ async def update_normalization(
|
|||||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||||
|
|
||||||
# Get tryout
|
# Get tryout
|
||||||
result = await db.execute(
|
query = select(Tryout).where(Tryout.tryout_id == tryout_id)
|
||||||
select(Tryout).where(
|
if website_id is not None:
|
||||||
Tryout.website_id == website_id,
|
query = query.where(Tryout.website_id == website_id)
|
||||||
Tryout.tryout_id == tryout_id,
|
|
||||||
)
|
result = await db.execute(query)
|
||||||
)
|
|
||||||
tryout = result.scalar_one_or_none()
|
tryout = result.scalar_one_or_none()
|
||||||
|
|
||||||
if tryout is None:
|
if tryout is None:
|
||||||
@@ -160,12 +229,11 @@ async def update_normalization(
|
|||||||
tryout.static_sb = request.static_sb
|
tryout.static_sb = request.static_sb
|
||||||
|
|
||||||
# Get current stats for participant count
|
# Get current stats for participant count
|
||||||
stats_result = await db.execute(
|
stats_query = select(TryoutStats).where(TryoutStats.tryout_id == tryout_id)
|
||||||
select(TryoutStats).where(
|
if website_id is not None:
|
||||||
TryoutStats.website_id == website_id,
|
stats_query = stats_query.where(TryoutStats.website_id == website_id)
|
||||||
TryoutStats.tryout_id == tryout_id,
|
|
||||||
)
|
stats_result = await db.execute(stats_query)
|
||||||
)
|
|
||||||
stats = stats_result.scalar_one_or_none()
|
stats = stats_result.scalar_one_or_none()
|
||||||
current_participant_count = stats.participant_count if stats else 0
|
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"})
|
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
|
||||||
|
|
||||||
# Get tryouts with stats
|
# Get tryouts with stats and items
|
||||||
result = await db.execute(
|
query = select(Tryout).options(selectinload(Tryout.stats), selectinload(Tryout.items))
|
||||||
select(Tryout)
|
if website_id is not None:
|
||||||
.options(selectinload(Tryout.stats))
|
query = query.where(Tryout.website_id == website_id)
|
||||||
.where(Tryout.website_id == website_id)
|
|
||||||
)
|
result = await db.execute(query)
|
||||||
tryouts = result.scalars().all()
|
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 [
|
return [
|
||||||
TryoutConfigBrief(
|
TryoutConfigBrief(
|
||||||
|
website_id=t.website_id,
|
||||||
tryout_id=t.tryout_id,
|
tryout_id=t.tryout_id,
|
||||||
name=t.name,
|
name=t.name,
|
||||||
scoring_mode=t.scoring_mode,
|
scoring_mode=t.scoring_mode,
|
||||||
selection_mode=t.selection_mode,
|
selection_mode=t.selection_mode,
|
||||||
normalization_mode=t.normalization_mode,
|
normalization_mode=t.normalization_mode,
|
||||||
participant_count=t.stats.participant_count if t.stats else 0,
|
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
|
for t in tryouts
|
||||||
]
|
]
|
||||||
@@ -254,12 +342,11 @@ async def get_calibration_status(
|
|||||||
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||||
|
|
||||||
# Verify tryout exists
|
# Verify tryout exists
|
||||||
tryout_result = await db.execute(
|
query = select(Tryout).where(Tryout.tryout_id == tryout_id)
|
||||||
select(Tryout).where(
|
if website_id is not None:
|
||||||
Tryout.website_id == website_id,
|
query = query.where(Tryout.website_id == website_id)
|
||||||
Tryout.tryout_id == tryout_id,
|
|
||||||
)
|
tryout_result = await db.execute(query)
|
||||||
)
|
|
||||||
tryout = tryout_result.scalar_one_or_none()
|
tryout = tryout_result.scalar_one_or_none()
|
||||||
|
|
||||||
if tryout is None:
|
if tryout is None:
|
||||||
@@ -269,16 +356,16 @@ async def get_calibration_status(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get calibration statistics
|
# Get calibration statistics
|
||||||
stats_result = await db.execute(
|
stats_query = select(
|
||||||
select(
|
func.count().label("total_items"),
|
||||||
func.count().label("total_items"),
|
func.sum(cast(Item.calibrated, Integer)).label("calibrated_items"),
|
||||||
func.sum(cast(Item.calibrated, Integer)).label("calibrated_items"),
|
func.avg(Item.calibration_sample_size).label("avg_sample_size"),
|
||||||
func.avg(Item.calibration_sample_size).label("avg_sample_size"),
|
).where(Item.tryout_id == tryout_id)
|
||||||
).where(
|
|
||||||
Item.website_id == website_id,
|
if website_id is not None:
|
||||||
Item.tryout_id == tryout_id,
|
stats_query = stats_query.where(Item.website_id == website_id)
|
||||||
)
|
|
||||||
)
|
stats_result = await db.execute(stats_query)
|
||||||
stats = stats_result.first()
|
stats = stats_result.first()
|
||||||
|
|
||||||
total_items = stats.total_items or 0
|
total_items = stats.total_items or 0
|
||||||
@@ -331,12 +418,11 @@ async def trigger_calibration(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Verify tryout exists
|
# Verify tryout exists
|
||||||
tryout_result = await db.execute(
|
query = select(Tryout).where(Tryout.tryout_id == tryout_id)
|
||||||
select(Tryout).where(
|
if website_id is not None:
|
||||||
Tryout.website_id == website_id,
|
query = query.where(Tryout.website_id == website_id)
|
||||||
Tryout.tryout_id == tryout_id,
|
|
||||||
)
|
tryout_result = await db.execute(query)
|
||||||
)
|
|
||||||
tryout = tryout_result.scalar_one_or_none()
|
tryout = tryout_result.scalar_one_or_none()
|
||||||
|
|
||||||
if tryout is 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
|
from app.services.irt_calibration import calibrate_item, CALIBRATION_SAMPLE_THRESHOLD
|
||||||
|
|
||||||
# Verify tryout exists
|
# Verify tryout exists
|
||||||
tryout_result = await db.execute(
|
query = select(Tryout).where(Tryout.tryout_id == tryout_id)
|
||||||
select(Tryout).where(
|
if website_id is not None:
|
||||||
Tryout.website_id == website_id,
|
query = query.where(Tryout.website_id == website_id)
|
||||||
Tryout.tryout_id == tryout_id,
|
|
||||||
)
|
tryout_result = await db.execute(query)
|
||||||
)
|
|
||||||
tryout = tryout_result.scalar_one_or_none()
|
tryout = tryout_result.scalar_one_or_none()
|
||||||
|
|
||||||
if tryout is None:
|
if tryout is None:
|
||||||
@@ -410,13 +495,14 @@ async def trigger_item_calibration(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Verify item belongs to this tryout
|
# Verify item belongs to this tryout
|
||||||
item_result = await db.execute(
|
item_query = select(Item).where(
|
||||||
select(Item).where(
|
Item.id == item_id,
|
||||||
Item.id == item_id,
|
Item.tryout_id == tryout_id,
|
||||||
Item.website_id == website_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()
|
item = item_result.scalar_one_or_none()
|
||||||
|
|
||||||
if item is None:
|
if item is None:
|
||||||
84
backend/app/routers/websites.py
Normal file
84
backend/app/routers/websites.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Website
|
||||||
|
from app.core.auth import AuthContext, get_auth_context, require_website_auth
|
||||||
|
|
||||||
|
router = APIRouter(tags=["websites"])
|
||||||
|
|
||||||
|
class WebsiteBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
domain: str
|
||||||
|
|
||||||
|
class WebsiteResponse(WebsiteBase):
|
||||||
|
id: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
@router.get("/websites", response_model=List[WebsiteResponse])
|
||||||
|
async def get_websites(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
):
|
||||||
|
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||||
|
result = await db.execute(select(Website).order_by(Website.id.asc()))
|
||||||
|
websites = result.scalars().all()
|
||||||
|
# Map old columns (site_name, site_url) to new response format
|
||||||
|
return [
|
||||||
|
WebsiteResponse(
|
||||||
|
id=w.id,
|
||||||
|
name=w.site_name,
|
||||||
|
domain=w.site_url
|
||||||
|
) for w in websites
|
||||||
|
]
|
||||||
|
|
||||||
|
@router.post("/websites", response_model=WebsiteResponse)
|
||||||
|
async def create_website(
|
||||||
|
payload: WebsiteBase,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
):
|
||||||
|
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||||
|
website = Website(site_name=payload.name, site_url=payload.domain)
|
||||||
|
db.add(website)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(website)
|
||||||
|
return WebsiteResponse(id=website.id, name=website.site_name, domain=website.site_url)
|
||||||
|
|
||||||
|
@router.put("/websites/{website_id}", response_model=WebsiteResponse)
|
||||||
|
async def update_website(
|
||||||
|
website_id: int,
|
||||||
|
payload: WebsiteBase,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
):
|
||||||
|
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||||
|
website = await db.get(Website, website_id)
|
||||||
|
if not website:
|
||||||
|
raise HTTPException(status_code=404, detail="Website not found")
|
||||||
|
|
||||||
|
website.site_name = payload.name
|
||||||
|
website.site_url = payload.domain
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(website)
|
||||||
|
return WebsiteResponse(id=website.id, name=website.site_name, domain=website.site_url)
|
||||||
|
|
||||||
|
@router.delete("/websites/{website_id}")
|
||||||
|
async def delete_website(
|
||||||
|
website_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
auth: AuthContext = Depends(get_auth_context),
|
||||||
|
):
|
||||||
|
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
|
||||||
|
website = await db.get(Website, website_id)
|
||||||
|
if not website:
|
||||||
|
raise HTTPException(status_code=404, detail="Website not found")
|
||||||
|
|
||||||
|
await db.delete(website)
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "success", "message": "Website deleted"}
|
||||||
180
backend/app/schemas/ai.py
Normal file
180
backend/app/schemas/ai.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schemas for AI generation endpoints.
|
||||||
|
|
||||||
|
Request/response models for admin AI generation playground.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
OPTION_LABELS = tuple("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||||
|
|
||||||
|
|
||||||
|
class AIGeneratePreviewRequest(BaseModel):
|
||||||
|
basis_item_id: int = Field(
|
||||||
|
..., description="ID of the basis item (must be sedang level)"
|
||||||
|
)
|
||||||
|
target_level: Literal["mudah", "sulit"] = Field(
|
||||||
|
..., description="Target difficulty level for generated question"
|
||||||
|
)
|
||||||
|
ai_model: str = Field(
|
||||||
|
default="qwen/qwen2.5-32b-instruct",
|
||||||
|
description="AI model to use for generation",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AIModelPricing(BaseModel):
|
||||||
|
prompt: Optional[float] = Field(
|
||||||
|
default=None, description="Input token price in USD per token"
|
||||||
|
)
|
||||||
|
completion: Optional[float] = Field(
|
||||||
|
default=None, description="Output token price in USD per token"
|
||||||
|
)
|
||||||
|
prompt_per_million: Optional[float] = Field(
|
||||||
|
default=None, description="Input token price in USD per 1M tokens"
|
||||||
|
)
|
||||||
|
completion_per_million: Optional[float] = Field(
|
||||||
|
default=None, description="Output token price in USD per 1M tokens"
|
||||||
|
)
|
||||||
|
currency: str = "USD"
|
||||||
|
source: str = "openrouter"
|
||||||
|
|
||||||
|
|
||||||
|
class AIUsageInfo(BaseModel):
|
||||||
|
prompt_tokens: Optional[int] = None
|
||||||
|
completion_tokens: Optional[int] = None
|
||||||
|
total_tokens: Optional[int] = None
|
||||||
|
cost_usd: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AIGeneratePreviewResponse(BaseModel):
|
||||||
|
success: bool = Field(..., description="Whether generation was successful")
|
||||||
|
stem: Optional[str] = None
|
||||||
|
options: Optional[Dict[str, str]] = None
|
||||||
|
correct: Optional[str] = None
|
||||||
|
explanation: Optional[str] = None
|
||||||
|
ai_model: Optional[str] = None
|
||||||
|
basis_item_id: Optional[int] = None
|
||||||
|
target_level: Optional[str] = None
|
||||||
|
usage: Optional[AIUsageInfo] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
cached: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AISaveRequest(BaseModel):
|
||||||
|
stem: str = Field(..., description="Question stem")
|
||||||
|
options: Dict[str, str] = Field(
|
||||||
|
..., description="Answer options. Labels must match the basis item exactly."
|
||||||
|
)
|
||||||
|
correct: str = Field(..., description="Correct answer option label")
|
||||||
|
explanation: Optional[str] = None
|
||||||
|
tryout_id: str = Field(..., description="Tryout identifier")
|
||||||
|
website_id: int = Field(..., description="Website identifier")
|
||||||
|
basis_item_id: int = Field(..., description="Basis item ID")
|
||||||
|
slot: int = Field(..., description="Question slot position")
|
||||||
|
level: Literal["mudah", "sedang", "sulit"] = Field(
|
||||||
|
..., description="Difficulty level"
|
||||||
|
)
|
||||||
|
variant_status: Literal["active", "draft"] = Field(
|
||||||
|
default="active",
|
||||||
|
description="Lifecycle status for the saved variant. Workspace approvals save active variants.",
|
||||||
|
)
|
||||||
|
ai_model: str = Field(
|
||||||
|
default="qwen/qwen2.5-32b-instruct",
|
||||||
|
description="AI model used for generation",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("correct")
|
||||||
|
@classmethod
|
||||||
|
def validate_correct(cls, v: str) -> str:
|
||||||
|
label = v.upper()
|
||||||
|
if label not in OPTION_LABELS:
|
||||||
|
raise ValueError("Correct answer must be an option label A-Z")
|
||||||
|
return label
|
||||||
|
|
||||||
|
@field_validator("options")
|
||||||
|
@classmethod
|
||||||
|
def validate_options(cls, v: Dict[str, str]) -> Dict[str, str]:
|
||||||
|
normalized = {
|
||||||
|
str(key).strip().upper(): str(value).strip()
|
||||||
|
for key, value in v.items()
|
||||||
|
if str(key).strip() and str(value).strip()
|
||||||
|
}
|
||||||
|
if len(normalized) < 2:
|
||||||
|
raise ValueError("Options must contain at least two non-empty choices")
|
||||||
|
invalid_keys = sorted(set(normalized) - set(OPTION_LABELS))
|
||||||
|
if invalid_keys:
|
||||||
|
raise ValueError(f"Options contain invalid labels: {', '.join(invalid_keys)}")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class AISaveResponse(BaseModel):
|
||||||
|
success: bool = Field(..., description="Whether save was successful")
|
||||||
|
item_id: Optional[int] = None
|
||||||
|
run_id: Optional[int] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AIGenerateBatchRequest(BaseModel):
|
||||||
|
basis_item_id: int = Field(
|
||||||
|
..., description="ID of the basis item (must be sedang level)"
|
||||||
|
)
|
||||||
|
target_level: Literal["mudah", "sulit"] = Field(
|
||||||
|
..., description="Target difficulty level for generated questions"
|
||||||
|
)
|
||||||
|
ai_model: str = Field(
|
||||||
|
default="qwen/qwen2.5-32b-instruct",
|
||||||
|
description="AI model to use for generation",
|
||||||
|
)
|
||||||
|
count: int = Field(default=3, ge=1, le=10, description="Number of variants to generate")
|
||||||
|
operator_notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AIBatchGeneratedItem(BaseModel):
|
||||||
|
item_id: int
|
||||||
|
stem: str
|
||||||
|
options: Dict[str, str]
|
||||||
|
correct: str
|
||||||
|
explanation: Optional[str] = None
|
||||||
|
level: str
|
||||||
|
variant_status: str
|
||||||
|
usage: Optional[AIUsageInfo] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AIGenerateBatchResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
run_id: Optional[int] = None
|
||||||
|
item_ids: list[int] = Field(default_factory=list)
|
||||||
|
items: list[AIBatchGeneratedItem] = Field(default_factory=list)
|
||||||
|
generated_count: int = 0
|
||||||
|
usage: Optional[AIUsageInfo] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AIStatsResponse(BaseModel):
|
||||||
|
total_ai_items: int = Field(..., description="Total AI-generated items")
|
||||||
|
items_by_model: Dict[str, int] = Field(
|
||||||
|
default_factory=dict, description="Items count by AI model"
|
||||||
|
)
|
||||||
|
cache_hit_rate: float = Field(
|
||||||
|
default=0.0, description="Cache hit rate (0.0 to 1.0)"
|
||||||
|
)
|
||||||
|
total_cache_hits: int = Field(default=0, description="Total cache hits")
|
||||||
|
total_requests: int = Field(default=0, description="Total generation requests")
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratedQuestion(BaseModel):
|
||||||
|
stem: str
|
||||||
|
options: Dict[str, str]
|
||||||
|
correct: str
|
||||||
|
explanation: Optional[str] = None
|
||||||
|
usage: Optional[AIUsageInfo] = None
|
||||||
|
|
||||||
|
@field_validator("correct")
|
||||||
|
@classmethod
|
||||||
|
def validate_correct(cls, v: str) -> str:
|
||||||
|
label = v.upper()
|
||||||
|
if label not in OPTION_LABELS:
|
||||||
|
raise ValueError("Correct answer must be an option label A-Z")
|
||||||
|
return label
|
||||||
@@ -52,6 +52,7 @@ class SessionCompleteResponse(BaseModel):
|
|||||||
tryout_id: str
|
tryout_id: str
|
||||||
start_time: datetime
|
start_time: datetime
|
||||||
end_time: Optional[datetime]
|
end_time: Optional[datetime]
|
||||||
|
expires_at: Optional[datetime] = None
|
||||||
is_completed: bool
|
is_completed: bool
|
||||||
scoring_mode_used: str
|
scoring_mode_used: str
|
||||||
|
|
||||||
@@ -99,6 +100,7 @@ class SessionResponse(BaseModel):
|
|||||||
tryout_id: str
|
tryout_id: str
|
||||||
start_time: datetime
|
start_time: datetime
|
||||||
end_time: Optional[datetime]
|
end_time: Optional[datetime]
|
||||||
|
expires_at: Optional[datetime] = None
|
||||||
is_completed: bool
|
is_completed: bool
|
||||||
scoring_mode_used: str
|
scoring_mode_used: str
|
||||||
|
|
||||||
@@ -64,16 +64,39 @@ class TryoutStatsResponse(BaseModel):
|
|||||||
class TryoutConfigBrief(BaseModel):
|
class TryoutConfigBrief(BaseModel):
|
||||||
"""Brief tryout config for list responses."""
|
"""Brief tryout config for list responses."""
|
||||||
|
|
||||||
|
website_id: int
|
||||||
tryout_id: str
|
tryout_id: str
|
||||||
name: str
|
name: str
|
||||||
scoring_mode: str
|
scoring_mode: str
|
||||||
selection_mode: str
|
selection_mode: str
|
||||||
normalization_mode: str
|
normalization_mode: str
|
||||||
participant_count: Optional[int] = None
|
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}
|
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):
|
class NormalizationUpdateRequest(BaseModel):
|
||||||
"""Request schema for updating normalization settings."""
|
"""Request schema for updating normalization settings."""
|
||||||
|
|
||||||
@@ -9,6 +9,8 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import ast
|
import ast
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict, Literal, Optional, Union
|
from typing import Any, Dict, Literal, Optional, Union
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -20,13 +22,14 @@ from app.models.item import Item
|
|||||||
from app.models.ai_generation_run import AIGenerationRun
|
from app.models.ai_generation_run import AIGenerationRun
|
||||||
from app.models.tryout import Tryout
|
from app.models.tryout import Tryout
|
||||||
from app.models.user_answer import UserAnswer
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
# OpenRouter API configuration
|
# OpenRouter API configuration
|
||||||
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"
|
||||||
|
|
||||||
# Supported AI models
|
# Supported AI models
|
||||||
SUPPORTED_MODELS = {
|
SUPPORTED_MODELS = {
|
||||||
@@ -42,6 +45,159 @@ LEVEL_DESCRIPTIONS = {
|
|||||||
"sulit": "harder (more complex concepts, multi-step reasoning)",
|
"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(
|
def get_prompt_template(
|
||||||
basis_stem: str,
|
basis_stem: str,
|
||||||
@@ -65,6 +221,10 @@ def get_prompt_template(
|
|||||||
Formatted prompt string
|
Formatted prompt string
|
||||||
"""
|
"""
|
||||||
level_desc = LEVEL_DESCRIPTIONS.get(target_level, target_level)
|
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(
|
options_text = "\n".join(
|
||||||
[f" {key}: {value}" for key, value in basis_options.items()]
|
[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:
|
REQUIREMENTS:
|
||||||
1. Keep the SAME topic/subject matter as the basis question
|
1. Keep the SAME topic/subject matter as the basis question
|
||||||
2. Use similar context and terminology
|
2. Use similar context and terminology
|
||||||
3. Create exactly 4 answer options (A, B, C, D)
|
3. Create exactly {option_count} answer options with labels exactly: {option_label_text}
|
||||||
4. Only ONE correct answer
|
4. Preserve the basis option count and option labels. Do not omit, add, rename, or merge answer options.
|
||||||
5. Include a clear explanation of why the correct answer is correct
|
5. Only ONE correct answer, and it must be one of: {option_label_text}
|
||||||
6. Make the question noticeably {level_desc} - not just a minor variation
|
6. Include a clear explanation of why the correct answer is correct
|
||||||
7. Follow and preserve any HTML formatting (e.g., <p>, <br>, <b>) present in the basis question
|
7. Make the question noticeably {level_desc} - not just a minor variation
|
||||||
|
8. Follow and preserve the basis question's inline HTML style. Keep structural and inline tags such as <p>, <br>, <strong>, <b>, <em>, <i>, <u>, <sub>, <sup>, and simple inline attributes such as text alignment when the basis uses them.
|
||||||
|
9. Do not escape HTML tags as text. Return HTML markup in the JSON string values exactly as markup.
|
||||||
|
|
||||||
OUTPUT FORMAT:
|
OUTPUT FORMAT:
|
||||||
Return ONLY a valid JSON object with this exact structure (no markdown, no code blocks):
|
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
|
return prompt
|
||||||
|
|
||||||
@@ -164,18 +326,13 @@ def validate_and_create_question(data: Dict[str, Any]) -> Optional[GeneratedQues
|
|||||||
|
|
||||||
options = _normalize_options(data.get("options"))
|
options = _normalize_options(data.get("options"))
|
||||||
if not options:
|
if not options:
|
||||||
logger.warning("Options cannot be normalized to A/B/C/D map")
|
logger.warning("Options cannot be normalized to a labeled option 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())}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
correct = _normalize_correct_answer(
|
correct = _normalize_correct_answer(
|
||||||
data.get("correct") or data.get("correct_answer") or data.get("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}")
|
logger.warning(f"Invalid correct answer: {correct}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -258,7 +415,7 @@ def _try_parse_json_like(candidate: str) -> Any:
|
|||||||
def _normalize_options(raw_options: Any) -> dict[str, str]:
|
def _normalize_options(raw_options: Any) -> dict[str, str]:
|
||||||
if isinstance(raw_options, dict):
|
if isinstance(raw_options, dict):
|
||||||
normalized = {str(k).strip().upper(): str(v).strip() for k, v in raw_options.items()}
|
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):
|
if isinstance(raw_options, list):
|
||||||
mapped: dict[str, str] = {}
|
mapped: dict[str, str] = {}
|
||||||
@@ -269,9 +426,9 @@ def _normalize_options(raw_options: Any) -> dict[str, str]:
|
|||||||
else:
|
else:
|
||||||
key = ""
|
key = ""
|
||||||
text = str(opt).strip()
|
text = str(opt).strip()
|
||||||
if not key and idx < 4:
|
if not key and idx < len(OPTION_LABELS):
|
||||||
key = ["A", "B", "C", "D"][idx]
|
key = OPTION_LABELS[idx]
|
||||||
if key in {"A", "B", "C", "D"} and text:
|
if key in OPTION_LABELS and text:
|
||||||
mapped[key] = text
|
mapped[key] = text
|
||||||
return mapped
|
return mapped
|
||||||
|
|
||||||
@@ -282,24 +439,44 @@ def _normalize_correct_answer(raw_correct: Any) -> str:
|
|||||||
if raw_correct is None:
|
if raw_correct is None:
|
||||||
return ""
|
return ""
|
||||||
raw_text = str(raw_correct).strip().upper()
|
raw_text = str(raw_correct).strip().upper()
|
||||||
if raw_text in {"A", "B", "C", "D"}:
|
if raw_text in OPTION_LABELS:
|
||||||
return raw_text
|
return raw_text
|
||||||
if raw_text.isdigit():
|
if raw_text.isdigit():
|
||||||
idx = int(raw_text)
|
idx = int(raw_text)
|
||||||
if 1 <= idx <= 4:
|
if 1 <= idx <= len(OPTION_LABELS):
|
||||||
return ["A", "B", "C", "D"][idx - 1]
|
return OPTION_LABELS[idx - 1]
|
||||||
if 0 <= idx <= 3:
|
if 0 <= idx < len(OPTION_LABELS):
|
||||||
return ["A", "B", "C", "D"][idx]
|
return OPTION_LABELS[idx]
|
||||||
if raw_text in {"OPTION A", "OPTION B", "OPTION C", "OPTION D"}:
|
if raw_text.startswith("OPTION ") and raw_text[-1:] in OPTION_LABELS:
|
||||||
return raw_text[-1]
|
return raw_text[-1]
|
||||||
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(
|
async def call_openrouter_api(
|
||||||
prompt: str,
|
prompt: str,
|
||||||
model: str,
|
model: str,
|
||||||
max_retries: int = 3,
|
max_retries: int = 3,
|
||||||
) -> Optional[str]:
|
) -> Optional[OpenRouterCallResult]:
|
||||||
"""
|
"""
|
||||||
Call OpenRouter API to generate question.
|
Call OpenRouter API to generate question.
|
||||||
|
|
||||||
@@ -309,7 +486,7 @@ async def call_openrouter_api(
|
|||||||
max_retries: Maximum retry attempts
|
max_retries: Maximum retry attempts
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
API response text or None if failed
|
OpenRouterCallResult with response text and usage, or None if failed
|
||||||
"""
|
"""
|
||||||
if not settings.OPENROUTER_API_KEY:
|
if not settings.OPENROUTER_API_KEY:
|
||||||
logger.error("OPENROUTER_API_KEY not configured")
|
logger.error("OPENROUTER_API_KEY not configured")
|
||||||
@@ -362,7 +539,12 @@ async def call_openrouter_api(
|
|||||||
choices = data.get("choices", [])
|
choices = data.get("choices", [])
|
||||||
if choices:
|
if choices:
|
||||||
message = choices[0].get("message", {})
|
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")
|
logger.warning("No choices in OpenRouter response")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -423,19 +605,20 @@ async def generate_question(
|
|||||||
operator_notes=operator_notes,
|
operator_notes=operator_notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
max_generation_attempts = 2
|
max_generation_attempts = 3
|
||||||
for attempt in range(1, max_generation_attempts + 1):
|
for attempt in range(1, max_generation_attempts + 1):
|
||||||
response_text = await call_openrouter_api(prompt, ai_model)
|
api_result = await call_openrouter_api(prompt, ai_model)
|
||||||
if not response_text:
|
if not api_result:
|
||||||
logger.error("No response from OpenRouter API")
|
logger.error("No response from OpenRouter API")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
generated = parse_ai_response(response_text)
|
generated = parse_ai_response(api_result.content)
|
||||||
if generated:
|
if generated and generated_matches_basis_options(generated, basis_item):
|
||||||
|
generated = generated.model_copy(update={"usage": api_result.usage})
|
||||||
return generated
|
return generated
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to parse AI response (attempt %s/%s), retrying",
|
"Failed to parse or validate AI response (attempt %s/%s), retrying",
|
||||||
attempt,
|
attempt,
|
||||||
max_generation_attempts,
|
max_generation_attempts,
|
||||||
)
|
)
|
||||||
@@ -53,6 +53,30 @@ class TerminationCheck:
|
|||||||
DEFAULT_SE_THRESHOLD = 0.5
|
DEFAULT_SE_THRESHOLD = 0.5
|
||||||
# Default max items if not configured
|
# Default max items if not configured
|
||||||
DEFAULT_MAX_ITEMS = 50
|
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(
|
async def get_next_item_fixed(
|
||||||
@@ -99,7 +123,8 @@ async def get_next_item_fixed(
|
|||||||
select(Item)
|
select(Item)
|
||||||
.where(
|
.where(
|
||||||
Item.tryout_id == tryout_id,
|
Item.tryout_id == tryout_id,
|
||||||
Item.website_id == website_id
|
Item.website_id == website_id,
|
||||||
|
_servable_item_filter(),
|
||||||
)
|
)
|
||||||
.order_by(Item.slot, Item.level)
|
.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)))
|
query = query.where(not_(Item.id.in_(answered_item_ids)))
|
||||||
|
|
||||||
result = await db.execute(query)
|
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:
|
if not items:
|
||||||
return NextItemResult(
|
return NextItemResult(
|
||||||
@@ -187,6 +221,7 @@ async def get_next_item_adaptive(
|
|||||||
.where(
|
.where(
|
||||||
Item.tryout_id == tryout_id,
|
Item.tryout_id == tryout_id,
|
||||||
Item.website_id == website_id,
|
Item.website_id == website_id,
|
||||||
|
_servable_item_filter(),
|
||||||
Item.calibrated == True # Only calibrated items for IRT
|
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')
|
query = query.where(Item.generated_by == 'manual')
|
||||||
|
|
||||||
result = await db.execute(query)
|
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:
|
if not items:
|
||||||
return NextItemResult(
|
return NextItemResult(
|
||||||
@@ -553,7 +597,8 @@ async def get_available_levels_for_slot(
|
|||||||
.where(
|
.where(
|
||||||
Item.tryout_id == tryout_id,
|
Item.tryout_id == tryout_id,
|
||||||
Item.website_id == website_id,
|
Item.website_id == website_id,
|
||||||
Item.slot == slot
|
Item.slot == slot,
|
||||||
|
_servable_item_filter(),
|
||||||
)
|
)
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
@@ -599,7 +644,8 @@ async def simulate_cat_selection(
|
|||||||
select(Item)
|
select(Item)
|
||||||
.where(
|
.where(
|
||||||
Item.tryout_id == tryout_id,
|
Item.tryout_id == tryout_id,
|
||||||
Item.website_id == website_id
|
Item.website_id == website_id,
|
||||||
|
_servable_item_filter(),
|
||||||
)
|
)
|
||||||
.order_by(Item.slot)
|
.order_by(Item.slot)
|
||||||
)
|
)
|
||||||
@@ -17,7 +17,7 @@ from typing import Any
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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"
|
SOURCE_FORMAT = "sejoli_json"
|
||||||
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
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)
|
db.add(snapshot)
|
||||||
await db.flush()
|
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(
|
existing_result = await db.execute(
|
||||||
select(TryoutSnapshotQuestion).where(
|
select(TryoutSnapshotQuestion).where(
|
||||||
TryoutSnapshotQuestion.website_id == website_id,
|
TryoutSnapshotQuestion.website_id == website_id,
|
||||||
275
backend/test_all_post_endpoints.py
Normal file
275
backend/test_all_post_endpoints.py
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Comprehensive test of all form POST endpoints with proper authentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
BASE_URL = "http://localhost:8000"
|
||||||
|
|
||||||
|
|
||||||
|
def login(client: httpx.Client) -> bool:
|
||||||
|
"""Login and maintain session."""
|
||||||
|
response = client.get("/admin/login")
|
||||||
|
if response.status_code != 200:
|
||||||
|
return False
|
||||||
|
|
||||||
|
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||||
|
csrf_token = match.group(1) if match else ""
|
||||||
|
|
||||||
|
if not csrf_token:
|
||||||
|
return False
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin123",
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.status_code == 200 and "/admin/dashboard" in str(response.url)
|
||||||
|
|
||||||
|
|
||||||
|
def get_csrf_token(client: httpx.Client, page_url: str) -> str:
|
||||||
|
"""Extract CSRF token from a page."""
|
||||||
|
response = client.get(page_url)
|
||||||
|
if response.status_code == 200:
|
||||||
|
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint(client: httpx.Client, name: str, url: str, data: dict) -> dict:
|
||||||
|
"""Test a single POST endpoint."""
|
||||||
|
csrf_token = get_csrf_token(client, url)
|
||||||
|
|
||||||
|
# Get the base URL (strip query params) for CSRF token extraction
|
||||||
|
base_url = url.split("?")[0] if "?" in url else url
|
||||||
|
|
||||||
|
# If we're on a different page, get CSRF token from there
|
||||||
|
if not csrf_token:
|
||||||
|
# Try to get CSRF from dashboard if it's a subpage
|
||||||
|
csrf_token = get_csrf_token(client, "/admin/dashboard")
|
||||||
|
|
||||||
|
if not csrf_token:
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"status_code": None,
|
||||||
|
"has_ise": False,
|
||||||
|
"has_traceback": False,
|
||||||
|
"error": "Could not get CSRF token",
|
||||||
|
"response_preview": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add CSRF token to data
|
||||||
|
test_data = data.copy()
|
||||||
|
test_data["csrf_token"] = csrf_token
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
data=test_data,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
has_ise = response.status_code == 500 or "Internal Server Error" in response.text
|
||||||
|
has_traceback = "Traceback" in response.text
|
||||||
|
|
||||||
|
if has_traceback:
|
||||||
|
idx = response.text.find("Traceback")
|
||||||
|
traceback_text = response.text[idx : idx + 2000]
|
||||||
|
print(f"\n ⚠️ TRACEBACK on {name}:")
|
||||||
|
print(f" {traceback_text[:500]}...")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"has_ise": has_ise,
|
||||||
|
"has_traceback": has_traceback,
|
||||||
|
"error": None,
|
||||||
|
"response_preview": response.text[:500],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 80)
|
||||||
|
print("Testing All Form POST Endpoints for Internal Server Errors")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
with httpx.Client(base_url=BASE_URL, timeout=60.0) as client:
|
||||||
|
print("\nStep 1: Logging in...")
|
||||||
|
if not login(client):
|
||||||
|
print("❌ Login failed")
|
||||||
|
return 1
|
||||||
|
print("✅ Login successful")
|
||||||
|
|
||||||
|
# Test 1: Variant approval (with item ID 4)
|
||||||
|
print("\nStep 2: Testing variant approval...")
|
||||||
|
result = test_endpoint(
|
||||||
|
client,
|
||||||
|
"Variant approval (/admin/questions/4/generate/review-bulk)",
|
||||||
|
"/admin/questions/4/generate?tab=review",
|
||||||
|
{"item_ids": "4", "action": "approved", "tab": "review"},
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
print(
|
||||||
|
f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test 2: Basis item review
|
||||||
|
print("\nStep 3: Testing basis item review...")
|
||||||
|
result = test_endpoint(
|
||||||
|
client,
|
||||||
|
"Basis item review (/admin/basis-items/4/review-bulk)",
|
||||||
|
"/admin/basis-items/4",
|
||||||
|
{"item_ids": "4", "action": "approved"},
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
print(
|
||||||
|
f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test 3: Generate variants for question
|
||||||
|
print("\nStep 4: Testing generate variants...")
|
||||||
|
result = test_endpoint(
|
||||||
|
client,
|
||||||
|
"Generate variants (/admin/questions/4/generate)",
|
||||||
|
"/admin/questions/4/generate?tab=generate",
|
||||||
|
{
|
||||||
|
"target_level": "mudah",
|
||||||
|
"ai_model": "meta-llama/llama-4-maverick:free",
|
||||||
|
"generation_count": "1",
|
||||||
|
"operator_notes": "",
|
||||||
|
"include_note_for_admin": "on",
|
||||||
|
"include_note_in_prompt": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
print(
|
||||||
|
f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test 5: Website creation
|
||||||
|
print("\nStep 5: Testing website creation...")
|
||||||
|
result = test_endpoint(
|
||||||
|
client,
|
||||||
|
"Website creation (/admin/websites)",
|
||||||
|
"/admin/websites",
|
||||||
|
{"site_name": "Test Site API", "site_url": "https://test-api.example.com"},
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
print(
|
||||||
|
f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test 6: Website deletion (with test ID)
|
||||||
|
print("\nStep 6: Testing website deletion...")
|
||||||
|
# First create a website
|
||||||
|
result_create = test_endpoint(
|
||||||
|
client,
|
||||||
|
"Create test website",
|
||||||
|
"/admin/websites",
|
||||||
|
{
|
||||||
|
"site_name": "Delete Test Site",
|
||||||
|
"site_url": "https://delete-test.example.com",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now delete it (using website ID 2 if exists)
|
||||||
|
result = test_endpoint(
|
||||||
|
client,
|
||||||
|
"Website deletion (/admin/websites/2/delete)",
|
||||||
|
"/admin/websites/2/delete",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
print(
|
||||||
|
f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test 7: Tryout import preview (without file - should get validation error not server error)
|
||||||
|
print("\nStep 7: Testing tryout import preview...")
|
||||||
|
result = test_endpoint(
|
||||||
|
client,
|
||||||
|
"Tryout import preview (/admin/tryout-import/preview)",
|
||||||
|
"/admin/tryout-import",
|
||||||
|
{"website_id": "1"},
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
print(f" Status: {result['status_code']} (validation error expected: 422)")
|
||||||
|
|
||||||
|
# Test 8: Snapshot promote bulk
|
||||||
|
print("\nStep 8: Testing snapshot promote bulk...")
|
||||||
|
result = test_endpoint(
|
||||||
|
client,
|
||||||
|
"Snapshot promote (/admin/snapshot-questions/promote-bulk)",
|
||||||
|
"/admin/snapshot-questions",
|
||||||
|
{"snapshot_id": "1", "snapshot_question_ids": ""},
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
print(
|
||||||
|
f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test 9: AI generation basis item
|
||||||
|
print("\nStep 9: Testing AI generation for basis item...")
|
||||||
|
result = test_endpoint(
|
||||||
|
client,
|
||||||
|
"Basis item generate (/admin/basis-items/4/generate)",
|
||||||
|
"/admin/basis-items/4",
|
||||||
|
{
|
||||||
|
"target_level": "mudah",
|
||||||
|
"ai_model": "",
|
||||||
|
"generation_count": "1",
|
||||||
|
"operator_notes": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
print(
|
||||||
|
f" Status: {result['status_code']} {'✅' if result['status_code'] in [200, 303] else '❌'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("RESULTS SUMMARY")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
for result in results:
|
||||||
|
if result.get("has_traceback"):
|
||||||
|
errors.append(f"❌ {result['name']}: TRACEBACK")
|
||||||
|
print(f"❌ {result['name']}: TRACEBACK")
|
||||||
|
elif result.get("has_ise"):
|
||||||
|
errors.append(f"❌ {result['name']}: INTERNAL SERVER ERROR")
|
||||||
|
print(f"❌ {result['name']}: INTERNAL SERVER ERROR")
|
||||||
|
elif result.get("error"):
|
||||||
|
print(f"⚠️ {result['name']}: {result['error']}")
|
||||||
|
elif result["status_code"] in [200, 303]:
|
||||||
|
print(f"✅ {result['name']}: OK ({result['status_code']})")
|
||||||
|
elif result["status_code"] == 422:
|
||||||
|
print(f"✅ {result['name']}: Validation Error (expected)")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ {result['name']}: Status {result['status_code']}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if errors:
|
||||||
|
print("❌ Some endpoints have INTERNAL SERVER ERRORS:")
|
||||||
|
for error in errors:
|
||||||
|
print(f" {error}")
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
print("✅ All form POST endpoints tested successfully!")
|
||||||
|
print(" No Internal Server Errors detected.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
346
backend/test_all_routes.py
Normal file
346
backend/test_all_routes.py
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test all routes in the IRT Bank Soal application.
|
||||||
|
Tests each endpoint and checks for Internal Server Errors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
BASE_URL = "http://localhost:8000"
|
||||||
|
|
||||||
|
# All routes from OpenAPI spec
|
||||||
|
API_ROUTES = [
|
||||||
|
# Root endpoints
|
||||||
|
("GET", "/"),
|
||||||
|
("GET", "/health"),
|
||||||
|
# Session endpoints
|
||||||
|
("POST", "/api/v1/session/"),
|
||||||
|
("GET", "/api/v1/session/{session_id}"),
|
||||||
|
("POST", "/api/v1/session/{session_id}/complete"),
|
||||||
|
("GET", "/api/v1/session/{session_id}/next_item"),
|
||||||
|
("POST", "/api/v1/session/{session_id}/submit_answer"),
|
||||||
|
# Tryout endpoints
|
||||||
|
("GET", "/api/v1/tryout/"),
|
||||||
|
("GET", "/api/v1/tryout/{tryout_id}/config"),
|
||||||
|
("PUT", "/api/v1/tryout/{tryout_id}/normalization"),
|
||||||
|
("GET", "/api/v1/tryout/{tryout_id}/calibration-status"),
|
||||||
|
("POST", "/api/v1/tryout/{tryout_id}/calibrate"),
|
||||||
|
("POST", "/api/v1/tryout/{tryout_id}/calibrate/{item_id}"),
|
||||||
|
# WordPress endpoints
|
||||||
|
("POST", "/api/v1/wordpress/sync_users"),
|
||||||
|
("POST", "/api/v1/wordpress/verify_session"),
|
||||||
|
("GET", "/api/v1/wordpress/website/{website_id}/users"),
|
||||||
|
("GET", "/api/v1/wordpress/website/{website_id}/user/{wp_user_id}"),
|
||||||
|
# Reports endpoints
|
||||||
|
("POST", "/api/v1/reports/schedule"),
|
||||||
|
("GET", "/api/v1/reports/schedule/{schedule_id}"),
|
||||||
|
("GET", "/api/v1/reports/schedules"),
|
||||||
|
("DELETE", "/api/v1/reports/schedule/{schedule_id}"),
|
||||||
|
("POST", "/api/v1/reports/schedule/{schedule_id}/export"),
|
||||||
|
("GET", "/api/v1/reports/student/performance"),
|
||||||
|
("GET", "/api/v1/reports/student/performance/export/{format}"),
|
||||||
|
("GET", "/api/v1/reports/items/analysis"),
|
||||||
|
("GET", "/api/v1/reports/items/analysis/export/{format}"),
|
||||||
|
("GET", "/api/v1/reports/calibration/status"),
|
||||||
|
("GET", "/api/v1/reports/calibration/status/export/{format}"),
|
||||||
|
("GET", "/api/v1/reports/tryout/comparison"),
|
||||||
|
("GET", "/api/v1/reports/tryout/comparison/export/{format}"),
|
||||||
|
("GET", "/api/v1/reports/export/{schedule_id}/{format}"),
|
||||||
|
# Import/Export endpoints
|
||||||
|
("POST", "/api/v1/import-export/preview"),
|
||||||
|
("POST", "/api/v1/import-export/questions"),
|
||||||
|
("GET", "/api/v1/import-export/export/questions"),
|
||||||
|
("POST", "/api/v1/import-export/tryout-json/preview"),
|
||||||
|
("POST", "/api/v1/import-export/tryout-json"),
|
||||||
|
# Admin AI endpoints
|
||||||
|
("POST", "/api/v1/admin/ai/generate-preview"),
|
||||||
|
("POST", "/api/v1/admin/ai/generate-save"),
|
||||||
|
("GET", "/api/v1/admin/ai/stats"),
|
||||||
|
("GET", "/api/v1/admin/ai/models"),
|
||||||
|
# Admin endpoints
|
||||||
|
("POST", "/api/v1/admin/{tryout_id}/calibrate"),
|
||||||
|
("POST", "/api/v1/admin/{tryout_id}/toggle-ai-generation"),
|
||||||
|
("POST", "/api/v1/admin/{tryout_id}/reset-normalization"),
|
||||||
|
# Admin CAT endpoints
|
||||||
|
("POST", "/api/v1/admin/cat/test"),
|
||||||
|
("GET", "/api/v1/admin/session/{session_id}/status"),
|
||||||
|
# Admin web routes (HTML pages)
|
||||||
|
("GET", "/admin"),
|
||||||
|
("GET", "/admin/login"),
|
||||||
|
("POST", "/admin/login"),
|
||||||
|
("POST", "/admin/logout"),
|
||||||
|
("GET", "/admin/password"),
|
||||||
|
("POST", "/admin/password"),
|
||||||
|
("GET", "/admin/dashboard"),
|
||||||
|
("GET", "/admin/questions"),
|
||||||
|
("GET", "/admin/questions/{item_id}"),
|
||||||
|
("GET", "/admin/questions/{item_id}/quality"),
|
||||||
|
("GET", "/admin/exams"),
|
||||||
|
("GET", "/admin/exams/{tryout_id}"),
|
||||||
|
("GET", "/admin/reports"),
|
||||||
|
("GET", "/admin/settings"),
|
||||||
|
("GET", "/admin/hierarchy"),
|
||||||
|
("GET", "/admin/websites"),
|
||||||
|
("POST", "/admin/websites"),
|
||||||
|
("GET", "/admin/websites/new"),
|
||||||
|
("GET", "/admin/websites/{website_id}"),
|
||||||
|
("POST", "/admin/websites/{website_id}"),
|
||||||
|
("POST", "/admin/websites/{website_id}/delete"),
|
||||||
|
("GET", "/admin/tryout-import"),
|
||||||
|
("GET", "/admin/tryout-import/preview"),
|
||||||
|
("POST", "/admin/tryout-import"),
|
||||||
|
("GET", "/admin/snapshot-questions"),
|
||||||
|
("POST", "/admin/snapshot-questions/promote-bulk"),
|
||||||
|
("GET", "/admin/calibration-status"),
|
||||||
|
("GET", "/admin/item-statistics"),
|
||||||
|
("GET", "/admin/sessions"),
|
||||||
|
("GET", "/admin/basis-items"),
|
||||||
|
("GET", "/admin/basis-items/{item_id}"),
|
||||||
|
("POST", "/admin/basis-items/{item_id}/generate"),
|
||||||
|
("POST", "/admin/basis-items/{item_id}/generate/review-bulk"),
|
||||||
|
("GET", "/admin/basis-items/{item_id}/generate/variants/{variant_id}"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Placeholder values for path parameters
|
||||||
|
PLACEHOLDERS = {
|
||||||
|
"{session_id}": "test-session-123",
|
||||||
|
"{tryout_id}": "test-tryout-123",
|
||||||
|
"{item_id}": "1",
|
||||||
|
"{website_id}": "1",
|
||||||
|
"{wp_user_id}": "123",
|
||||||
|
"{schedule_id}": "test-schedule-123",
|
||||||
|
"{format}": "xlsx",
|
||||||
|
"{variant_id}": "test-variant-123",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Minimal request bodies for POST endpoints
|
||||||
|
REQUEST_BODIES = {
|
||||||
|
"/api/v1/session/": {
|
||||||
|
"session_id": "test",
|
||||||
|
"tryout_id": "test",
|
||||||
|
"wp_user_id": "123",
|
||||||
|
"website_id": 1,
|
||||||
|
"scoring_mode": "ctt",
|
||||||
|
},
|
||||||
|
"/api/v1/session/{session_id}/complete": {
|
||||||
|
"end_time": "2024-01-01T00:00:00Z",
|
||||||
|
"user_answers": [],
|
||||||
|
},
|
||||||
|
"/api/v1/session/{session_id}/submit_answer": {
|
||||||
|
"item_id": 1,
|
||||||
|
"response": "A",
|
||||||
|
"time_spent": 10,
|
||||||
|
},
|
||||||
|
"/api/v1/tryout/{tryout_id}/normalization": {
|
||||||
|
"normalization_mode": "static",
|
||||||
|
"static_rataan": 500,
|
||||||
|
"static_sb": 100,
|
||||||
|
},
|
||||||
|
"/api/v1/wordpress/sync_users": {}, # Requires proper auth header
|
||||||
|
"/api/v1/wordpress/verify_session": {
|
||||||
|
"website_id": 1,
|
||||||
|
"wp_user_id": "123",
|
||||||
|
"token": "test",
|
||||||
|
},
|
||||||
|
"/api/v1/reports/schedule": {
|
||||||
|
"tryout_id": "test",
|
||||||
|
"report_type": "student_performance",
|
||||||
|
},
|
||||||
|
"/api/v1/admin/ai/generate-preview": {
|
||||||
|
"basis_item_id": 1,
|
||||||
|
"target_level": "sulit",
|
||||||
|
"ai_model": "qwen/qwen2.5-32b-instruct",
|
||||||
|
},
|
||||||
|
"/api/v1/admin/ai/generate-save": {
|
||||||
|
"stem": "Test?",
|
||||||
|
"options": {"A": "a", "B": "b", "C": "c", "D": "d"},
|
||||||
|
"correct": "A",
|
||||||
|
"tryout_id": "test",
|
||||||
|
"website_id": 1,
|
||||||
|
"basis_item_id": 1,
|
||||||
|
"slot": 1,
|
||||||
|
"level": "sulit",
|
||||||
|
"ai_model": "qwen/qwen2.5-32b-instruct",
|
||||||
|
},
|
||||||
|
"/api/v1/admin/cat/test": {"tryout_id": "test", "website_id": 1},
|
||||||
|
"/api/v1/admin/{tryout_id}/calibrate": {},
|
||||||
|
"/api/v1/admin/{tryout_id}/toggle-ai-generation": {},
|
||||||
|
"/api/v1/admin/{tryout_id}/reset-normalization": {},
|
||||||
|
"/api/v1/import-export/preview": None, # Requires file upload
|
||||||
|
"/api/v1/import-export/questions": None, # Requires file upload
|
||||||
|
"/api/v1/import-export/tryout-json/preview": None, # Requires file upload
|
||||||
|
"/api/v1/import-export/tryout-json": None, # Requires file upload
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def expand_route(method: str, route: str) -> list:
|
||||||
|
"""Expand route with placeholders."""
|
||||||
|
expanded = []
|
||||||
|
test_route = route
|
||||||
|
for placeholder, value in PLACEHOLDERS.items():
|
||||||
|
if placeholder in test_route:
|
||||||
|
test_route = test_route.replace(placeholder, value)
|
||||||
|
expanded.append((method, test_route))
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
|
def test_route(client: httpx.Client, method: str, route: str) -> dict:
|
||||||
|
"""Test a single route."""
|
||||||
|
# Expand placeholders
|
||||||
|
expanded = expand_route(method, route)
|
||||||
|
if not expanded:
|
||||||
|
return {
|
||||||
|
"route": route,
|
||||||
|
"method": method,
|
||||||
|
"error": "Could not expand route",
|
||||||
|
"status_code": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
method, test_route = expanded[0]
|
||||||
|
|
||||||
|
# Determine request body
|
||||||
|
body = None
|
||||||
|
request_body = REQUEST_BODIES.get(route, REQUEST_BODIES.get(test_route, {}))
|
||||||
|
if request_body is not None:
|
||||||
|
body = request_body
|
||||||
|
|
||||||
|
# Determine query params
|
||||||
|
params = {}
|
||||||
|
if "export/questions" in route:
|
||||||
|
params = {"tryout_id": "test"}
|
||||||
|
|
||||||
|
headers = {"X-Website-ID": "1"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.request(
|
||||||
|
method=method,
|
||||||
|
url=BASE_URL + test_route,
|
||||||
|
json=body if body and method in ["POST", "PUT", "PATCH"] else None,
|
||||||
|
params=params,
|
||||||
|
headers=headers,
|
||||||
|
timeout=10.0,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
is_500 = response.status_code == 500
|
||||||
|
is_ise = "Internal Server Error" in response.text
|
||||||
|
|
||||||
|
return {
|
||||||
|
"route": route,
|
||||||
|
"method": method,
|
||||||
|
"expanded_route": test_route,
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"has_500": is_500,
|
||||||
|
"has_ise": is_ise,
|
||||||
|
"response_preview": response.text[:200] if response.text else "",
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return {
|
||||||
|
"route": route,
|
||||||
|
"method": method,
|
||||||
|
"expanded_route": test_route,
|
||||||
|
"status_code": None,
|
||||||
|
"has_500": False,
|
||||||
|
"has_ise": False,
|
||||||
|
"response_preview": "",
|
||||||
|
"error": "Timeout",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"route": route,
|
||||||
|
"method": method,
|
||||||
|
"expanded_route": test_route,
|
||||||
|
"status_code": None,
|
||||||
|
"has_500": False,
|
||||||
|
"has_ise": False,
|
||||||
|
"response_preview": "",
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 80)
|
||||||
|
print("Testing all IRT Bank Soal routes for Internal Server Errors")
|
||||||
|
print("=" * 80)
|
||||||
|
print()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
has_errors = False
|
||||||
|
|
||||||
|
with httpx.Client(timeout=30.0) as client:
|
||||||
|
for method, route in API_ROUTES:
|
||||||
|
result = test_route(client, method, route)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
status = result["status_code"]
|
||||||
|
error_marker = ""
|
||||||
|
|
||||||
|
if result["error"]:
|
||||||
|
error_marker = f" [ERROR: {result['error']}]"
|
||||||
|
has_errors = True
|
||||||
|
elif status and status >= 500:
|
||||||
|
error_marker = f" [INTERNAL SERVER ERROR!]"
|
||||||
|
has_errors = True
|
||||||
|
elif status and status == 500:
|
||||||
|
error_marker = f" [500 - INTERNAL SERVER ERROR!]"
|
||||||
|
has_errors = True
|
||||||
|
elif "Internal Server Error" in str(result.get("response_preview", "")):
|
||||||
|
error_marker = " [500 - INTERNAL SERVER ERROR!]"
|
||||||
|
has_errors = True
|
||||||
|
|
||||||
|
status_str = str(status) if status else "N/A"
|
||||||
|
print(f"{method:6} {route:<60} -> {status_str}{error_marker}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 80)
|
||||||
|
print("SUMMARY")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
total = len(results)
|
||||||
|
successful = sum(1 for r in results if r["status_code"] and r["status_code"] < 500)
|
||||||
|
client_errors = sum(
|
||||||
|
1 for r in results if r["status_code"] and 400 <= r["status_code"] < 500
|
||||||
|
)
|
||||||
|
server_errors = sum(
|
||||||
|
1 for r in results if r["status_code"] and r["status_code"] >= 500
|
||||||
|
)
|
||||||
|
timeouts = sum(1 for r in results if r["error"] == "Timeout")
|
||||||
|
exceptions = sum(1 for r in results if r["error"] and r["error"] != "Timeout")
|
||||||
|
ise_errors = sum(1 for r in results if r.get("has_ise") or r.get("has_500"))
|
||||||
|
|
||||||
|
print(f"Total routes tested: {total}")
|
||||||
|
print(f"Successful (2xx): {successful}")
|
||||||
|
print(f"Client errors (4xx): {client_errors}")
|
||||||
|
print(f"Server errors (5xx): {server_errors}")
|
||||||
|
print(f"Timeouts: {timeouts}")
|
||||||
|
print(f"Exceptions: {exceptions}")
|
||||||
|
print(f"Internal Server Errors: {ise_errors}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if has_errors:
|
||||||
|
print("Routes with issues:")
|
||||||
|
for r in results:
|
||||||
|
if r["status_code"] and r["status_code"] >= 500:
|
||||||
|
print(f" - {r['method']} {r['route']} -> {r['status_code']}")
|
||||||
|
elif r["error"]:
|
||||||
|
print(f" - {r['method']} {r['route']} -> ERROR: {r['error']}")
|
||||||
|
elif r.get("has_ise"):
|
||||||
|
print(f" - {r['method']} {r['route']} -> Internal Server Error")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if ise_errors == 0 and exceptions == 0:
|
||||||
|
print("✅ All routes passed! No Internal Server Errors detected.")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print("❌ Some routes have issues. Please review the output above.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
50
backend/test_debug_login.py
Normal file
50
backend/test_debug_login.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Debug login issue.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
BASE_URL = "http://localhost:8000"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Debugging login issue...")
|
||||||
|
|
||||||
|
with httpx.Client(base_url=BASE_URL, timeout=30.0) as client:
|
||||||
|
# Get login page
|
||||||
|
response = client.get("/admin/login")
|
||||||
|
print(f"Login page status: {response.status_code}")
|
||||||
|
|
||||||
|
# Extract CSRF token
|
||||||
|
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||||
|
csrf_token = match.group(1) if match else ""
|
||||||
|
print(f"CSRF token: {csrf_token[:30]}...")
|
||||||
|
|
||||||
|
# Look for any error messages in the page
|
||||||
|
if "error" in response.text.lower():
|
||||||
|
print("\n=== Error messages in login page ===")
|
||||||
|
# Extract error div content
|
||||||
|
error_match = re.search(
|
||||||
|
r'<div class="error">(.*?)</div>', response.text, re.DOTALL
|
||||||
|
)
|
||||||
|
if error_match:
|
||||||
|
print(error_match.group(1))
|
||||||
|
else:
|
||||||
|
# Print a portion of the page around "error"
|
||||||
|
idx = response.text.lower().find("error")
|
||||||
|
print(response.text[max(0, idx - 50) : idx + 200])
|
||||||
|
|
||||||
|
# Try to check if Redis is accessible via the health endpoint
|
||||||
|
health = client.get("/health")
|
||||||
|
print(f"\nHealth check: {health.text}")
|
||||||
|
|
||||||
|
# Print login page content for inspection
|
||||||
|
print("\n=== Login page content (first 2000 chars) ===")
|
||||||
|
print(response.text[:2000])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
73
backend/test_debug_login2.py
Normal file
73
backend/test_debug_login2.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Debug login issue - check Redis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
BASE_URL = "http://localhost:8000"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Debugging login issue - detailed...")
|
||||||
|
|
||||||
|
with httpx.Client(base_url=BASE_URL, timeout=30.0) as client:
|
||||||
|
# Get login page
|
||||||
|
response = client.get("/admin/login")
|
||||||
|
print(f"Login page status: {response.status_code}")
|
||||||
|
|
||||||
|
# Extract CSRF token
|
||||||
|
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||||
|
csrf_token = match.group(1) if match else ""
|
||||||
|
print(f"CSRF token: {csrf_token}")
|
||||||
|
|
||||||
|
# Print ALL cookies
|
||||||
|
print(f"\nCookies before login: {dict(client.cookies)}")
|
||||||
|
|
||||||
|
# Submit login
|
||||||
|
response = client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin123",
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
},
|
||||||
|
follow_redirects=False, # Don't follow redirect to see the response
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\nLogin response status: {response.status_code}")
|
||||||
|
print(f"Login response headers: {dict(response.headers)}")
|
||||||
|
print(f"Cookies after login: {dict(client.cookies)}")
|
||||||
|
|
||||||
|
# Check if response has any content
|
||||||
|
print(f"\nLogin response content (first 1000 chars):")
|
||||||
|
print(response.text[:1000])
|
||||||
|
|
||||||
|
# Now try with a redirect follow
|
||||||
|
print("\n\n=== Trying with redirect follow ===")
|
||||||
|
client2 = httpx.Client(base_url=BASE_URL, timeout=30.0)
|
||||||
|
|
||||||
|
response = client2.get("/admin/login")
|
||||||
|
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||||
|
csrf_token = match.group(1) if match else ""
|
||||||
|
|
||||||
|
response = client2.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin123",
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Final status after redirect: {response.status_code}")
|
||||||
|
print(f"Final URL: {response.url}")
|
||||||
|
print(f"Final cookies: {dict(client2.cookies)}")
|
||||||
|
print(f"Final content (first 500 chars): {response.text[:500]}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
142
backend/test_debug_traceback.py
Normal file
142
backend/test_debug_traceback.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Debug the 500 Internal Server Error on variant approval - fixed CSRF.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
BASE_URL = "http://localhost:8000"
|
||||||
|
|
||||||
|
|
||||||
|
def login(client: httpx.Client) -> bool:
|
||||||
|
"""Login and maintain session."""
|
||||||
|
response = client.get("/admin/login")
|
||||||
|
if response.status_code != 200:
|
||||||
|
return False
|
||||||
|
|
||||||
|
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||||
|
csrf_token = match.group(1) if match else ""
|
||||||
|
|
||||||
|
if not csrf_token:
|
||||||
|
return False
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin123",
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.status_code == 200 and "/admin/dashboard" in str(response.url)
|
||||||
|
|
||||||
|
|
||||||
|
def get_csrf_from_page(client: httpx.Client, page_url: str) -> tuple:
|
||||||
|
"""Get CSRF token from a specific page and return both token and response."""
|
||||||
|
response = client.get(page_url, follow_redirects=True)
|
||||||
|
if response.status_code == 200:
|
||||||
|
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||||
|
if match:
|
||||||
|
return match.group(1), response
|
||||||
|
return "", response
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 80)
|
||||||
|
print("Debugging 500 Internal Server Error on Variant Approval")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
with httpx.Client(base_url=BASE_URL, timeout=60.0) as client:
|
||||||
|
print("\n1. Logging in...")
|
||||||
|
if not login(client):
|
||||||
|
print(" ❌ Login failed")
|
||||||
|
return
|
||||||
|
print(" ✅ Login successful")
|
||||||
|
|
||||||
|
# Test 1: Variant approval - get CSRF from the actual review page
|
||||||
|
print("\n2. Testing variant approval...")
|
||||||
|
|
||||||
|
# First access the review page to get the CSRF token
|
||||||
|
csrf_token, page_response = get_csrf_from_page(
|
||||||
|
client, "/admin/questions/4/generate?tab=review"
|
||||||
|
)
|
||||||
|
print(f" Page URL: {page_response.url}")
|
||||||
|
print(f" Page status: {page_response.status_code}")
|
||||||
|
print(f" CSRF token: {csrf_token[:30] if csrf_token else 'None'}...")
|
||||||
|
|
||||||
|
# If we got redirected, we can't test this endpoint
|
||||||
|
if "/generate" not in str(page_response.url):
|
||||||
|
print(
|
||||||
|
" ⚠️ Redirected away from AI playground - item may not exist or not be AI-generated"
|
||||||
|
)
|
||||||
|
print(" Skipping this test...")
|
||||||
|
else:
|
||||||
|
# Submit the form
|
||||||
|
response = client.post(
|
||||||
|
"/admin/questions/4/generate/review-bulk",
|
||||||
|
data={
|
||||||
|
"item_ids": "4",
|
||||||
|
"action": "approved",
|
||||||
|
"tab": "review",
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" Response status: {response.status_code}")
|
||||||
|
|
||||||
|
# Extract and print the full traceback
|
||||||
|
if "Traceback" in response.text:
|
||||||
|
idx = response.text.find("Traceback")
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("FULL TRACEBACK:")
|
||||||
|
print("=" * 80)
|
||||||
|
print(response.text[idx:])
|
||||||
|
print("=" * 80)
|
||||||
|
elif response.status_code == 500:
|
||||||
|
print("\n ⚠️ Got 500 error but no traceback in response")
|
||||||
|
print(f" Response preview: {response.text[:500]}")
|
||||||
|
else:
|
||||||
|
print(f" Response preview: {response.text[:500]}")
|
||||||
|
|
||||||
|
# Test 2: Generate variants
|
||||||
|
print("\n3. Testing generate variants...")
|
||||||
|
|
||||||
|
csrf_token, page_response = get_csrf_from_page(
|
||||||
|
client, "/admin/questions/4/generate?tab=generate"
|
||||||
|
)
|
||||||
|
print(f" Page URL: {page_response.url}")
|
||||||
|
print(f" Page status: {page_response.status_code}")
|
||||||
|
|
||||||
|
if "/generate" not in str(page_response.url):
|
||||||
|
print(" ⚠️ Redirected away from AI playground")
|
||||||
|
else:
|
||||||
|
response = client.post(
|
||||||
|
"/admin/questions/4/generate",
|
||||||
|
data={
|
||||||
|
"target_level": "mudah",
|
||||||
|
"ai_model": "meta-llama/llama-4-maverick:free",
|
||||||
|
"generation_count": "1",
|
||||||
|
"operator_notes": "",
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" Response status: {response.status_code}")
|
||||||
|
|
||||||
|
if "Traceback" in response.text:
|
||||||
|
idx = response.text.find("Traceback")
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("FULL TRACEBACK:")
|
||||||
|
print("=" * 80)
|
||||||
|
print(response.text[idx:])
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
404
backend/test_form_posts.py
Normal file
404
backend/test_form_posts.py
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test all form POST endpoints for Internal Server Errors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
BASE_URL = "http://localhost:8000"
|
||||||
|
|
||||||
|
# All form POST endpoints from admin_web.py
|
||||||
|
FORM_POST_ENDPOINTS = [
|
||||||
|
# (endpoint, method, form_data, description)
|
||||||
|
(
|
||||||
|
"/admin/login",
|
||||||
|
"POST",
|
||||||
|
{"username": "admin", "password": "admin123"},
|
||||||
|
"Admin login",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/admin/password",
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
"old_password": "admin123",
|
||||||
|
"new_password": "admin123",
|
||||||
|
"re_new_password": "admin123",
|
||||||
|
},
|
||||||
|
"Change password",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/admin/websites",
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
"site_name": "Test Site",
|
||||||
|
"site_url": "https://test.example.com",
|
||||||
|
},
|
||||||
|
"Create website",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/admin/websites/1/edit",
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
"site_name": "Updated Test Site",
|
||||||
|
"site_url": "https://updated.example.com",
|
||||||
|
},
|
||||||
|
"Edit website",
|
||||||
|
),
|
||||||
|
("/admin/websites/1/delete", "POST", {}, "Delete website"),
|
||||||
|
(
|
||||||
|
"/admin/tryout-import/preview",
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
"website_id": "1",
|
||||||
|
},
|
||||||
|
"Tryout import preview (no file)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/admin/tryout-import",
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
"website_id": "1",
|
||||||
|
"preview_token": "invalid-token",
|
||||||
|
},
|
||||||
|
"Tryout import submit",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/admin/snapshot-questions/promote-bulk",
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
"snapshot_id": "1",
|
||||||
|
"snapshot_question_ids": [],
|
||||||
|
},
|
||||||
|
"Promote snapshot questions bulk",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/admin/basis-items/1/generate",
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
"target_level": "mudah",
|
||||||
|
"ai_model": "",
|
||||||
|
"generation_count": "1",
|
||||||
|
"operator_notes": "",
|
||||||
|
},
|
||||||
|
"Generate variants for basis item",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/admin/basis-items/1/review-bulk",
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
"item_ids": ["1"],
|
||||||
|
"action": "approved",
|
||||||
|
},
|
||||||
|
"Review bulk variants",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/admin/questions/1/generate",
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
"target_level": "mudah",
|
||||||
|
"ai_model": "meta-llama/llama-4-maverick:free",
|
||||||
|
"generation_count": "1",
|
||||||
|
"operator_notes": "",
|
||||||
|
"include_note_for_admin": True,
|
||||||
|
"include_note_in_prompt": False,
|
||||||
|
},
|
||||||
|
"Generate question variants",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/admin/questions/1/generate/review-bulk",
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
"item_ids": ["1"],
|
||||||
|
"action": "approved",
|
||||||
|
"tab": "review",
|
||||||
|
},
|
||||||
|
"Review question variants bulk",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# API POST endpoints
|
||||||
|
API_POST_ENDPOINTS = [
|
||||||
|
(
|
||||||
|
"/api/v1/session/",
|
||||||
|
{
|
||||||
|
"session_id": "test-session-123",
|
||||||
|
"tryout_id": "test",
|
||||||
|
"wp_user_id": "123",
|
||||||
|
"website_id": 1,
|
||||||
|
"scoring_mode": "ctt",
|
||||||
|
},
|
||||||
|
"Create session",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/api/v1/session/test-session-123/complete",
|
||||||
|
{
|
||||||
|
"end_time": "2024-01-01T00:00:00Z",
|
||||||
|
"user_answers": [],
|
||||||
|
},
|
||||||
|
"Complete session",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/api/v1/session/test-session-123/submit_answer",
|
||||||
|
{
|
||||||
|
"item_id": 1,
|
||||||
|
"response": "A",
|
||||||
|
"time_spent": 10,
|
||||||
|
},
|
||||||
|
"Submit answer",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/api/v1/wordpress/verify_session",
|
||||||
|
{
|
||||||
|
"website_id": 1,
|
||||||
|
"wp_user_id": "123",
|
||||||
|
"token": "test",
|
||||||
|
},
|
||||||
|
"Verify WordPress session",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/api/v1/reports/schedule",
|
||||||
|
{
|
||||||
|
"tryout_id": "test",
|
||||||
|
"report_type": "student_performance",
|
||||||
|
},
|
||||||
|
"Schedule report",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/api/v1/admin/cat/test",
|
||||||
|
{
|
||||||
|
"tryout_id": "test",
|
||||||
|
"website_id": 1,
|
||||||
|
},
|
||||||
|
"Test CAT algorithm",
|
||||||
|
),
|
||||||
|
("/api/v1/admin/1/calibrate", {}, "Calibrate tryout"),
|
||||||
|
("/api/v1/admin/1/toggle-ai-generation", {}, "Toggle AI generation"),
|
||||||
|
("/api/v1/admin/1/reset-normalization", {}, "Reset normalization"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_session():
|
||||||
|
"""Login and get session cookies for admin access."""
|
||||||
|
with httpx.Client(base_url=BASE_URL, follow_redirects=True, timeout=30.0) as client:
|
||||||
|
# Try to login
|
||||||
|
response = client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin123",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(f"Login response: {response.status_code}")
|
||||||
|
|
||||||
|
# Check if we have admin access
|
||||||
|
response = client.get("/admin")
|
||||||
|
print(f"Admin page response: {response.status_code}")
|
||||||
|
|
||||||
|
# Return cookies
|
||||||
|
return client.cookies
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint(
|
||||||
|
client: httpx.Client, endpoint: str, method: str, data: dict, cookies: dict = None
|
||||||
|
) -> dict:
|
||||||
|
"""Test a single endpoint."""
|
||||||
|
headers = {"X-Website-ID": "1"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if method == "POST":
|
||||||
|
# Check if this looks like form data or JSON
|
||||||
|
if isinstance(data, dict) and all(
|
||||||
|
isinstance(v, str) or v is None for v in data.values()
|
||||||
|
):
|
||||||
|
# Form data
|
||||||
|
response = client.post(
|
||||||
|
endpoint,
|
||||||
|
data=data,
|
||||||
|
headers=headers,
|
||||||
|
cookies=cookies,
|
||||||
|
timeout=30.0,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# JSON data
|
||||||
|
response = client.post(
|
||||||
|
endpoint,
|
||||||
|
json=data,
|
||||||
|
headers=headers,
|
||||||
|
cookies=cookies,
|
||||||
|
timeout=30.0,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = client.request(
|
||||||
|
method,
|
||||||
|
endpoint,
|
||||||
|
headers=headers,
|
||||||
|
cookies=cookies,
|
||||||
|
timeout=30.0,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for internal server error
|
||||||
|
has_ise = (
|
||||||
|
response.status_code == 500
|
||||||
|
or "Internal Server Error" in response.text
|
||||||
|
or "500 Internal Server Error" in response.text
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for traceback
|
||||||
|
has_traceback = "Traceback" in response.text
|
||||||
|
|
||||||
|
return {
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"method": method,
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"has_ise": has_ise,
|
||||||
|
"has_traceback": has_traceback,
|
||||||
|
"response_preview": response.text[:500] if response.text else "",
|
||||||
|
"redirect_location": response.headers.get("location", ""),
|
||||||
|
}
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return {
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"method": method,
|
||||||
|
"status_code": None,
|
||||||
|
"has_ise": False,
|
||||||
|
"has_traceback": False,
|
||||||
|
"response_preview": "",
|
||||||
|
"error": "Timeout",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"method": method,
|
||||||
|
"status_code": None,
|
||||||
|
"has_ise": False,
|
||||||
|
"has_traceback": False,
|
||||||
|
"response_preview": "",
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 80)
|
||||||
|
print("Testing all Form POST endpoints for Internal Server Errors")
|
||||||
|
print("=" * 80)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Get admin session
|
||||||
|
print("Getting admin session...")
|
||||||
|
cookies = get_admin_session()
|
||||||
|
print()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
has_errors = False
|
||||||
|
|
||||||
|
with httpx.Client(base_url=BASE_URL, follow_redirects=True, timeout=30.0) as client:
|
||||||
|
# Test admin form POST endpoints
|
||||||
|
print("-" * 80)
|
||||||
|
print("ADMIN FORM POST ENDPOINTS")
|
||||||
|
print("-" * 80)
|
||||||
|
|
||||||
|
for endpoint, method, data, description in FORM_POST_ENDPOINTS:
|
||||||
|
print(f"\nTesting: {description}")
|
||||||
|
print(f" Endpoint: {endpoint}")
|
||||||
|
|
||||||
|
result = test_endpoint(client, endpoint, method, data, cookies)
|
||||||
|
results.append((description, result))
|
||||||
|
|
||||||
|
status = result["status_code"]
|
||||||
|
error_details = ""
|
||||||
|
|
||||||
|
if result.get("error"):
|
||||||
|
error_details = f" [ERROR: {result['error']}]"
|
||||||
|
has_errors = True
|
||||||
|
elif result.get("has_traceback"):
|
||||||
|
error_details = f" [TRACEBACK!]"
|
||||||
|
has_errors = True
|
||||||
|
print(f" Response: {result['response_preview'][:1000]}")
|
||||||
|
elif result.get("has_ise"):
|
||||||
|
error_details = f" [INTERNAL SERVER ERROR!]"
|
||||||
|
has_errors = True
|
||||||
|
print(f" Response: {result['response_preview'][:1000]}")
|
||||||
|
|
||||||
|
status_str = str(status) if status else "N/A"
|
||||||
|
print(f" Status: {status_str}{error_details}")
|
||||||
|
|
||||||
|
if result.get("redirect_location"):
|
||||||
|
print(f" Redirect: {result['redirect_location']}")
|
||||||
|
|
||||||
|
# Test API POST endpoints
|
||||||
|
print()
|
||||||
|
print("-" * 80)
|
||||||
|
print("API POST ENDPOINTS")
|
||||||
|
print("-" * 80)
|
||||||
|
|
||||||
|
for endpoint, data, description in API_POST_ENDPOINTS:
|
||||||
|
print(f"\nTesting: {description}")
|
||||||
|
print(f" Endpoint: {endpoint}")
|
||||||
|
|
||||||
|
result = test_endpoint(client, endpoint, "POST", data, cookies)
|
||||||
|
results.append((description, result))
|
||||||
|
|
||||||
|
status = result["status_code"]
|
||||||
|
error_details = ""
|
||||||
|
|
||||||
|
if result.get("error"):
|
||||||
|
error_details = f" [ERROR: {result['error']}]"
|
||||||
|
has_errors = True
|
||||||
|
elif result.get("has_traceback"):
|
||||||
|
error_details = f" [TRACEBACK!]"
|
||||||
|
has_errors = True
|
||||||
|
print(f" Response: {result['response_preview'][:1000]}")
|
||||||
|
elif result.get("has_ise"):
|
||||||
|
error_details = f" [INTERNAL SERVER ERROR!]"
|
||||||
|
has_errors = True
|
||||||
|
print(f" Response: {result['response_preview'][:1000]}")
|
||||||
|
|
||||||
|
status_str = str(status) if status else "N/A"
|
||||||
|
print(f" Status: {status_str}{error_details}")
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print()
|
||||||
|
print("=" * 80)
|
||||||
|
print("SUMMARY")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
total = len(results)
|
||||||
|
ise_errors = sum(1 for _, r in results if r.get("has_ise"))
|
||||||
|
tracebacks = sum(1 for _, r in results if r.get("has_traceback"))
|
||||||
|
timeouts = sum(1 for _, r in results if r.get("error") == "Timeout")
|
||||||
|
exceptions = sum(
|
||||||
|
1 for _, r in results if r.get("error") and r.get("error") != "Timeout"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Total endpoints tested: {total}")
|
||||||
|
print(f"Internal Server Errors: {ise_errors}")
|
||||||
|
print(f"Tracebacks: {tracebacks}")
|
||||||
|
print(f"Timeouts: {timeouts}")
|
||||||
|
print(f"Exceptions: {exceptions}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if ise_errors > 0 or tracebacks > 0:
|
||||||
|
print("Endpoints with issues:")
|
||||||
|
for desc, r in results:
|
||||||
|
if r.get("has_ise") or r.get("has_traceback"):
|
||||||
|
print(f" - {desc}: {r['endpoint']} -> {r['status_code']}")
|
||||||
|
if r.get("has_traceback"):
|
||||||
|
print(f" Traceback detected in response")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if has_errors:
|
||||||
|
print("❌ Some endpoints have issues. Please review the output above.")
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
print("✅ All endpoints passed! No Internal Server Errors detected.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
68
backend/test_session_debug.py
Normal file
68
backend/test_session_debug.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Debug redirect on AI playground page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
BASE_URL = "http://localhost:8000"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Debugging redirect on AI playground page...")
|
||||||
|
|
||||||
|
with httpx.Client(base_url=BASE_URL, timeout=30.0) as client:
|
||||||
|
# Login first
|
||||||
|
response = client.get("/admin/login")
|
||||||
|
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||||
|
csrf_token = match.group(1) if match else ""
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin123",
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
print(f"Logged in, URL: {response.url}")
|
||||||
|
|
||||||
|
# Get AI playground page without following redirects
|
||||||
|
print("\nGetting AI playground page without following redirects...")
|
||||||
|
response = client.get(
|
||||||
|
"/admin/questions/1/generate?tab=review", follow_redirects=False
|
||||||
|
)
|
||||||
|
print(f"Status: {response.status_code}")
|
||||||
|
print(f"Location header: {response.headers.get('location', 'None')}")
|
||||||
|
|
||||||
|
# Follow the redirect
|
||||||
|
if response.headers.get("location"):
|
||||||
|
redirect_url = response.headers["location"]
|
||||||
|
print(f"\nFollowing redirect to: {redirect_url}")
|
||||||
|
response = client.get(redirect_url, follow_redirects=True)
|
||||||
|
print(f"Final status: {response.status_code}")
|
||||||
|
print(f"Final URL: {response.url}")
|
||||||
|
|
||||||
|
# Check for forms
|
||||||
|
post_forms = re.findall(
|
||||||
|
r'<form[^>]*method="post"[^>]*>', response.text, re.IGNORECASE
|
||||||
|
)
|
||||||
|
print(f"\nFound {len(post_forms)} POST forms")
|
||||||
|
|
||||||
|
# Look for CSRF token
|
||||||
|
csrf_inputs = re.findall(
|
||||||
|
r'<input[^>]*name="csrf_token"[^>]*>', response.text, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if csrf_inputs:
|
||||||
|
print(f"Found {len(csrf_inputs)} CSRF token inputs:")
|
||||||
|
for inp in csrf_inputs[:3]:
|
||||||
|
print(f" {inp}")
|
||||||
|
else:
|
||||||
|
print("No CSRF token inputs found")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
374
backend/test_variant_approval.py
Normal file
374
backend/test_variant_approval.py
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test variant approval endpoints with proper session handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
BASE_URL = "http://localhost:8000"
|
||||||
|
|
||||||
|
|
||||||
|
def get_csrf_token(client: httpx.Client, page_url: str) -> str:
|
||||||
|
"""Extract CSRF token from a page."""
|
||||||
|
try:
|
||||||
|
response = client.get(page_url)
|
||||||
|
if response.status_code == 200:
|
||||||
|
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error getting CSRF token from {page_url}: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def login(client: httpx.Client) -> bool:
|
||||||
|
"""Login and maintain session."""
|
||||||
|
# Get login page
|
||||||
|
response = client.get("/admin/login")
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f" Failed to get login page: {response.status_code}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
match = re.search(r'name="csrf_token" value="([^"]+)"', response.text)
|
||||||
|
csrf_token = match.group(1) if match else ""
|
||||||
|
|
||||||
|
if not csrf_token:
|
||||||
|
print(" Failed to get CSRF token")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Submit login - follow redirects to complete login
|
||||||
|
response = client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin123",
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200 and "/admin/dashboard" in str(response.url):
|
||||||
|
print(" ✅ Successfully logged in!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
print(f" Login failed: {response.status_code}, URL: {response.url}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_variant_approval(client: httpx.Client) -> dict:
|
||||||
|
"""Test the variant approval endpoint."""
|
||||||
|
|
||||||
|
# Get CSRF token from the review page
|
||||||
|
csrf_token = get_csrf_token(client, "/admin/questions/1/generate?tab=review")
|
||||||
|
|
||||||
|
if not csrf_token:
|
||||||
|
return {
|
||||||
|
"status_code": None,
|
||||||
|
"has_ise": False,
|
||||||
|
"has_traceback": False,
|
||||||
|
"error": "Could not get CSRF token - likely not authenticated",
|
||||||
|
"response_preview": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Submit variant approval
|
||||||
|
response = client.post(
|
||||||
|
"/admin/questions/1/generate/review-bulk",
|
||||||
|
data={
|
||||||
|
"item_ids": "1",
|
||||||
|
"action": "approved",
|
||||||
|
"tab": "review",
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" Response status: {response.status_code}")
|
||||||
|
print(f" Final URL: {response.url}")
|
||||||
|
|
||||||
|
# Check for errors
|
||||||
|
has_ise = response.status_code == 500 or "Internal Server Error" in response.text
|
||||||
|
has_traceback = "Traceback" in response.text
|
||||||
|
|
||||||
|
if has_traceback:
|
||||||
|
print("\n === TRACEBACK DETECTED ===")
|
||||||
|
# Extract just the traceback part
|
||||||
|
if "Traceback" in response.text:
|
||||||
|
idx = response.text.find("Traceback")
|
||||||
|
print(response.text[idx : idx + 3000])
|
||||||
|
print(" ==========================\n")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"has_ise": has_ise,
|
||||||
|
"has_traceback": has_traceback,
|
||||||
|
"response_preview": response.text[:1000],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_basis_item_review(client: httpx.Client) -> dict:
|
||||||
|
"""Test the basis item review bulk endpoint."""
|
||||||
|
|
||||||
|
# Get CSRF token from the basis item page
|
||||||
|
csrf_token = get_csrf_token(client, "/admin/basis-items/1")
|
||||||
|
|
||||||
|
if not csrf_token:
|
||||||
|
return {
|
||||||
|
"status_code": None,
|
||||||
|
"has_ise": False,
|
||||||
|
"has_traceback": False,
|
||||||
|
"error": "Could not get CSRF token - likely not authenticated",
|
||||||
|
"response_preview": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Submit basis item review
|
||||||
|
response = client.post(
|
||||||
|
"/admin/basis-items/1/review-bulk",
|
||||||
|
data={
|
||||||
|
"item_ids": "1",
|
||||||
|
"action": "approved",
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" Response status: {response.status_code}")
|
||||||
|
print(f" Final URL: {response.url}")
|
||||||
|
|
||||||
|
# Check for errors
|
||||||
|
has_ise = response.status_code == 500 or "Internal Server Error" in response.text
|
||||||
|
has_traceback = "Traceback" in response.text
|
||||||
|
|
||||||
|
if has_traceback:
|
||||||
|
print("\n === TRACEBACK DETECTED ===")
|
||||||
|
if "Traceback" in response.text:
|
||||||
|
idx = response.text.find("Traceback")
|
||||||
|
print(response.text[idx : idx + 3000])
|
||||||
|
print(" ==========================\n")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"has_ise": has_ise,
|
||||||
|
"has_traceback": has_traceback,
|
||||||
|
"response_preview": response.text[:1000],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_promote(client: httpx.Client) -> dict:
|
||||||
|
"""Test the snapshot questions promote bulk endpoint."""
|
||||||
|
|
||||||
|
# Get CSRF token from the hierarchy page
|
||||||
|
csrf_token = get_csrf_token(client, "/admin/hierarchy")
|
||||||
|
|
||||||
|
if not csrf_token:
|
||||||
|
return {
|
||||||
|
"status_code": None,
|
||||||
|
"has_ise": False,
|
||||||
|
"has_traceback": False,
|
||||||
|
"error": "Could not get CSRF token - likely not authenticated",
|
||||||
|
"response_preview": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Submit snapshot promote (with empty list)
|
||||||
|
response = client.post(
|
||||||
|
"/admin/snapshot-questions/promote-bulk",
|
||||||
|
data={
|
||||||
|
"snapshot_id": "1",
|
||||||
|
"snapshot_question_ids": "",
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" Response status: {response.status_code}")
|
||||||
|
|
||||||
|
# Check for errors
|
||||||
|
has_ise = response.status_code == 500 or "Internal Server Error" in response.text
|
||||||
|
has_traceback = "Traceback" in response.text
|
||||||
|
|
||||||
|
if has_traceback:
|
||||||
|
print("\n === TRACEBACK DETECTED ===")
|
||||||
|
if "Traceback" in response.text:
|
||||||
|
idx = response.text.find("Traceback")
|
||||||
|
print(response.text[idx : idx + 3000])
|
||||||
|
print(" ==========================\n")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"has_ise": has_ise,
|
||||||
|
"has_traceback": has_traceback,
|
||||||
|
"response_preview": response.text[:1000],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tryout_import_preview(client: httpx.Client) -> dict:
|
||||||
|
"""Test the tryout import preview endpoint."""
|
||||||
|
|
||||||
|
csrf_token = get_csrf_token(client, "/admin/tryout-import")
|
||||||
|
|
||||||
|
if not csrf_token:
|
||||||
|
return {
|
||||||
|
"status_code": None,
|
||||||
|
"has_ise": False,
|
||||||
|
"has_traceback": False,
|
||||||
|
"error": "Could not get CSRF token",
|
||||||
|
"response_preview": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Submit tryout import preview (without file)
|
||||||
|
response = client.post(
|
||||||
|
"/admin/tryout-import/preview",
|
||||||
|
data={
|
||||||
|
"website_id": "1",
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" Response status: {response.status_code}")
|
||||||
|
|
||||||
|
has_ise = response.status_code == 500 or "Internal Server Error" in response.text
|
||||||
|
has_traceback = "Traceback" in response.text
|
||||||
|
|
||||||
|
if has_traceback:
|
||||||
|
print("\n === TRACEBACK DETECTED ===")
|
||||||
|
if "Traceback" in response.text:
|
||||||
|
idx = response.text.find("Traceback")
|
||||||
|
print(response.text[idx : idx + 3000])
|
||||||
|
print(" ==========================\n")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"has_ise": has_ise,
|
||||||
|
"has_traceback": has_traceback,
|
||||||
|
"response_preview": response.text[:1000],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_website_crud(client: httpx.Client) -> dict:
|
||||||
|
"""Test website creation endpoint."""
|
||||||
|
|
||||||
|
csrf_token = get_csrf_token(client, "/admin/websites")
|
||||||
|
|
||||||
|
if not csrf_token:
|
||||||
|
return {
|
||||||
|
"status_code": None,
|
||||||
|
"has_ise": False,
|
||||||
|
"has_traceback": False,
|
||||||
|
"error": "Could not get CSRF token",
|
||||||
|
"response_preview": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Submit website creation
|
||||||
|
response = client.post(
|
||||||
|
"/admin/websites",
|
||||||
|
data={
|
||||||
|
"site_name": "Test Site",
|
||||||
|
"site_url": "https://test.example.com",
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" Response status: {response.status_code}")
|
||||||
|
|
||||||
|
has_ise = response.status_code == 500 or "Internal Server Error" in response.text
|
||||||
|
has_traceback = "Traceback" in response.text
|
||||||
|
|
||||||
|
if has_traceback:
|
||||||
|
print("\n === TRACEBACK DETECTED ===")
|
||||||
|
if "Traceback" in response.text:
|
||||||
|
idx = response.text.find("Traceback")
|
||||||
|
print(response.text[idx : idx + 3000])
|
||||||
|
print(" ==========================\n")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"has_ise": has_ise,
|
||||||
|
"has_traceback": has_traceback,
|
||||||
|
"response_preview": response.text[:1000],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 80)
|
||||||
|
print("Testing Form POST Endpoints for Internal Server Errors")
|
||||||
|
print("=" * 80)
|
||||||
|
print()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
with httpx.Client(base_url=BASE_URL, timeout=30.0) as client:
|
||||||
|
# Login
|
||||||
|
print("Step 1: Logging in...")
|
||||||
|
if not login(client):
|
||||||
|
print("❌ Login failed")
|
||||||
|
return 1
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test 1: Variant approval
|
||||||
|
print(
|
||||||
|
"Step 2: Testing variant approval (/admin/questions/1/generate/review-bulk)..."
|
||||||
|
)
|
||||||
|
result1 = test_variant_approval(client)
|
||||||
|
results.append(("Variant approval", result1))
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test 2: Basis item review
|
||||||
|
print("Step 3: Testing basis item review (/admin/basis-items/1/review-bulk)...")
|
||||||
|
result2 = test_basis_item_review(client)
|
||||||
|
results.append(("Basis item review", result2))
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test 3: Snapshot promote
|
||||||
|
print(
|
||||||
|
"Step 4: Testing snapshot promote (/admin/snapshot-questions/promote-bulk)..."
|
||||||
|
)
|
||||||
|
result3 = test_snapshot_promote(client)
|
||||||
|
results.append(("Snapshot promote", result3))
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test 4: Tryout import preview
|
||||||
|
print("Step 5: Testing tryout import preview (/admin/tryout-import/preview)...")
|
||||||
|
result4 = test_tryout_import_preview(client)
|
||||||
|
results.append(("Tryout import preview", result4))
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test 5: Website creation
|
||||||
|
print("Step 6: Testing website creation (/admin/websites)...")
|
||||||
|
result5 = test_website_crud(client)
|
||||||
|
results.append(("Website creation", result5))
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("=" * 80)
|
||||||
|
print("RESULTS SUMMARY")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
all_good = True
|
||||||
|
for name, result in results:
|
||||||
|
if result.get("has_ise") or result.get("has_traceback"):
|
||||||
|
print(f"❌ {name}: INTERNAL SERVER ERROR!")
|
||||||
|
print(f" Status: {result['status_code']}")
|
||||||
|
print(f" Preview: {result['response_preview'][:200]}...")
|
||||||
|
all_good = False
|
||||||
|
elif result.get("error"):
|
||||||
|
print(f"⚠️ {name}: {result['error']}")
|
||||||
|
elif result["status_code"] in [200, 303]:
|
||||||
|
print(f"✅ {name}: OK ({result['status_code']})")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ {name}: Unexpected status {result['status_code']}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if all_good:
|
||||||
|
print("✅ All form POST endpoints passed! No Internal Server Errors detected.")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print("❌ Some endpoints have issues. Please review the output above.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -19,6 +19,12 @@ def test_require_website_auth_returns_scoped_website_for_allowed_role():
|
|||||||
assert website_id == 5
|
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():
|
def test_require_website_auth_rejects_disallowed_role():
|
||||||
auth = AuthContext(website_id=5, role="student", wp_user_id="u1")
|
auth = AuthContext(website_id=5, role="student", wp_user_id="u1")
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
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:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
ensure_website_scope_matches(auth_website_id=10, payload_website_id=11)
|
ensure_website_scope_matches(auth_website_id=10, payload_website_id=11)
|
||||||
assert exc_info.value.status_code == 403
|
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)
|
||||||
@@ -23,6 +23,30 @@ def test_issue_and_decode_access_token_round_trip():
|
|||||||
assert auth.wp_user_id == "wp-1001"
|
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():
|
def test_decode_access_token_rejects_tampered_signature():
|
||||||
token = issue_access_token(
|
token = issue_access_token(
|
||||||
website_id=7,
|
website_id=7,
|
||||||
@@ -8,12 +8,14 @@ from app.core import rate_limit
|
|||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
from app.models.report_schedule import ReportScheduleModel
|
from app.models.report_schedule import ReportScheduleModel
|
||||||
from app.services import ai_generation
|
from app.services import ai_generation
|
||||||
|
from app.services import cat_selection
|
||||||
from app.services.reporting import (
|
from app.services.reporting import (
|
||||||
cancel_scheduled_report,
|
cancel_scheduled_report,
|
||||||
get_scheduled_report,
|
get_scheduled_report,
|
||||||
list_scheduled_reports,
|
list_scheduled_reports,
|
||||||
schedule_report,
|
schedule_report,
|
||||||
)
|
)
|
||||||
|
from app.schemas.ai import GeneratedQuestion
|
||||||
|
|
||||||
|
|
||||||
class DummyRequest:
|
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)
|
assert all("items.website_id" in query for query in captured_queries)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_prompt_preserves_basis_option_labels():
|
||||||
|
prompt = ai_generation.get_prompt_template(
|
||||||
|
basis_stem="<p>Basis question?</p>",
|
||||||
|
basis_options={
|
||||||
|
"A": "Option A",
|
||||||
|
"B": "Option B",
|
||||||
|
"C": "Option C",
|
||||||
|
"D": "Option D",
|
||||||
|
"E": "Option E",
|
||||||
|
},
|
||||||
|
basis_correct="A",
|
||||||
|
basis_explanation="<p>Because A.</p>",
|
||||||
|
target_level="mudah",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Create exactly 5 answer options with labels exactly: A, B, C, D, E" in prompt
|
||||||
|
assert '"E": "Option E text"' in prompt
|
||||||
|
assert "The correct field must be exactly one of: A, B, C, D, E" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_generated_question_must_match_basis_option_labels():
|
||||||
|
basis_item = SimpleNamespace(
|
||||||
|
options={
|
||||||
|
"A": "Option A",
|
||||||
|
"B": "Option B",
|
||||||
|
"C": "Option C",
|
||||||
|
"D": "Option D",
|
||||||
|
"E": "Option E",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
generated = GeneratedQuestion(
|
||||||
|
stem="Generated",
|
||||||
|
options={
|
||||||
|
"A": "Option A",
|
||||||
|
"B": "Option B",
|
||||||
|
"C": "Option C",
|
||||||
|
"D": "Option D",
|
||||||
|
},
|
||||||
|
correct="A",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not ai_generation.generated_matches_basis_options(generated, basis_item)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cat_selection_only_serves_active_or_approved_variants():
|
||||||
|
compiled = str(
|
||||||
|
cat_selection._servable_item_filter().compile(
|
||||||
|
compile_kwargs={"literal_binds": True}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "active" in compiled
|
||||||
|
assert "approved" in compiled
|
||||||
|
assert "draft" not in compiled
|
||||||
|
assert "rejected" not in compiled
|
||||||
|
|
||||||
|
|
||||||
def test_production_init_db_skips_create_all(monkeypatch):
|
def test_production_init_db_skips_create_all(monkeypatch):
|
||||||
import app.database as database
|
import app.database as database
|
||||||
|
|
||||||
@@ -7,5 +7,5 @@ from app.main import app
|
|||||||
|
|
||||||
|
|
||||||
def test_next_item_route_is_registered():
|
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
|
assert "/api/v1/session/{session_id}/next_item" in paths
|
||||||
71
docker-compose.yml
Normal file
71
docker-compose.yml
Normal file
@@ -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:
|
||||||
7198
form_posts_check_and_fixes.md
Normal file
7198
form_posts_check_and_fixes.md
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=http://localhost:8000/api/v1
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user