From 8a5ea57febf291d446ad23a945b69ed7cd57a442 Mon Sep 17 00:00:00 2001 From: ectasy663 Date: Fri, 25 Sep 2026 18:12:06 +0530 Subject: [PATCH] fix(cbt): comprehensive overhaul of CBT & DPP test pipelines, JSON schemas, active states, and exam pattern filters --- backend/main.py | 180 ++++++++++++++++++++++++--------- src/app/admin/tests/page.tsx | 91 ++++++++++++++--- src/app/cbt/[examId]/page.tsx | 15 ++- src/app/dashboard/cbt/page.tsx | 2 +- 4 files changed, 222 insertions(+), 66 deletions(-) diff --git a/backend/main.py b/backend/main.py index e6e91a4..366a2a1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -443,21 +443,28 @@ def login(payload: LoginPayload, session: Session = Depends(get_session)): def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] = None, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)): query = select(models.Test).where(models.Test.is_active == True) if exam_pattern: - if exam_pattern == 'JEE_NEET': + pattern_str = str(exam_pattern).strip() + if pattern_str in ['JEE_NEET', 'ALL', 'All']: pass - elif exam_pattern == 'JEE': - query = query.where(models.Test.exam_pattern.in_([models.ExamEnum.JEE_MAIN, models.ExamEnum.JEE_ADVANCED])) + elif pattern_str in ['JEE', 'JEE_Main', 'JEE_MAIN', 'JEE Main', 'JEE_Advanced', 'JEE_ADVANCED', 'JEE Advanced']: + query = query.where(models.Test.exam_pattern.in_([models.ExamEnum.JEE_MAIN, models.ExamEnum.JEE_ADVANCED, models.ExamEnum.JEE_NEET])) + elif pattern_str in ['NEET']: + query = query.where(models.Test.exam_pattern.in_([models.ExamEnum.NEET, models.ExamEnum.JEE_NEET])) else: try: - # Find matching enum by value string (e.g. "JEE_Main") - enum_val = models.ExamEnum(exam_pattern) - query = query.where(models.Test.exam_pattern == enum_val) + enum_val = models.ExamEnum(pattern_str) + query = query.where(models.Test.exam_pattern.in_([enum_val, models.ExamEnum.JEE_NEET])) except ValueError: pass if type: - query = query.where(models.Test.type == type) + if type in ["Mock", "CBT"]: + query = query.where(models.Test.type.in_(["Mock", "CBT"])) + elif type in ["DPS", "DPP"]: + query = query.where(models.Test.type.in_(["DPS", "DPP"])) + else: + query = query.where(models.Test.type == type) else: - query = query.where(models.Test.type != "DPS") + query = query.where(models.Test.type.not_in(["DPS", "DPP"])) return session.exec(query).all() @app.get("/api/tests/{test_id}", response_model=models.Test, tags=["CBT Engine"]) @@ -1303,26 +1310,30 @@ def get_admin_tests(session: Session = Depends(get_session), current_user: model return tests class ManualQuestionInput(BaseModel): - section: str = "General" - subject: str - topic: str - subtopic: str - difficulty: str - type: str - question_text: str - options: Optional[Dict[str, Any]] = None - correct_answer: Dict[str, Any] - solution_text: str - language: str + section: Optional[str] = "General" + subject: Optional[str] = "General" + topic: Optional[str] = "General" + subtopic: Optional[str] = "General" + difficulty: Optional[str] = "Medium" + type: Optional[str] = "MCQ_Single" + question_text: Optional[str] = None + question: Optional[str] = None + options: Optional[Any] = None + options_hi: Optional[Any] = None + correct_answer: Optional[Any] = None + solution_text: Optional[str] = None + solution: Optional[str] = None + explanation: Optional[str] = None + language: Optional[str] = "English" class ManualTestCreate(BaseModel): title: str - type: str = "CBT" + type: str = "Mock" exam_pattern: str time_limit: int scheduled_date: Optional[datetime] = None end_date: Optional[datetime] = None - admin_id: UUID + admin_id: Optional[UUID] = None questions_json: List[ManualQuestionInput] @app.post("/api/admin/tests/upload", tags=["Admin"]) @@ -1332,7 +1343,7 @@ async def upload_admin_test( exam_pattern: str = Form(...), time_limit: int = Form(...), scheduled_date: Optional[datetime] = Form(None), - admin_id: UUID = Form(...), + admin_id: Optional[UUID] = Form(None), session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user), ): @@ -1344,6 +1355,24 @@ async def upload_admin_test( import storage import uuid + # Safe pattern normalization + pat_str = str(exam_pattern).strip() + if pat_str in ["JEE", "JEE_MAIN", "JEE Main", "JEE_Main"]: + pat_enum = models.ExamEnum.JEE_MAIN + elif pat_str in ["JEE_ADVANCED", "JEE Advanced", "JEE_Advanced"]: + pat_enum = models.ExamEnum.JEE_ADVANCED + elif pat_str in ["NEET"]: + pat_enum = models.ExamEnum.NEET + elif pat_str in ["JEE_NEET", "JEE & NEET"]: + pat_enum = models.ExamEnum.JEE_NEET + else: + try: + pat_enum = models.ExamEnum(pat_str) + except ValueError: + pat_enum = models.ExamEnum.JEE_MAIN + + effective_admin_id = admin_id or uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + pdf_bytes = await file.read() current_year = datetime.utcnow().year object_name = f"tests/{current_year}/{uuid.uuid4()}_{file.filename}" @@ -1354,28 +1383,19 @@ async def upload_admin_test( test_obj = models.Test( title=title, type="DPS", - exam_pattern=models.ExamEnum(exam_pattern), + exam_pattern=pat_enum, sections={}, total_time=time_limit, difficulty="Medium", scheduled_date=scheduled_date, - is_active=False, - created_by=admin_id, + is_active=True, + created_by=effective_admin_id, pdf_url=url ) session.add(test_obj) session.commit() session.refresh(test_obj) - # Phase 2: Uncomment below to trigger AI question extraction from PDF - # from worker import process_pdf_test - # process_pdf_test.delay( - # test_id_str=str(test_obj.id), - # object_name=object_name, - # admin_id_str=str(admin_id), - # exam_pattern=exam_pattern - # ) - return { "status": "uploaded", "test_id": test_obj.id, @@ -1388,18 +1408,37 @@ def create_manual_test( session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user) ): import uuid + + # Safe pattern normalization + pat_str = str(data.exam_pattern).strip() + if pat_str in ["JEE", "JEE_MAIN", "JEE Main", "JEE_Main"]: + pat_enum = models.ExamEnum.JEE_MAIN + elif pat_str in ["JEE_ADVANCED", "JEE Advanced", "JEE_Advanced"]: + pat_enum = models.ExamEnum.JEE_ADVANCED + elif pat_str in ["NEET"]: + pat_enum = models.ExamEnum.NEET + elif pat_str in ["JEE_NEET", "JEE & NEET"]: + pat_enum = models.ExamEnum.JEE_NEET + else: + try: + pat_enum = models.ExamEnum(pat_str) + except ValueError: + pat_enum = models.ExamEnum.JEE_MAIN + + effective_admin_id = data.admin_id or uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + # 1. Create the test object test_obj = models.Test( title=data.title, type=data.type, - exam_pattern=models.ExamEnum(data.exam_pattern), + exam_pattern=pat_enum, sections={}, total_time=data.time_limit, difficulty="Medium", scheduled_date=data.scheduled_date, end_date=data.end_date, is_active=True, - created_by=data.admin_id + created_by=effective_admin_id ) session.add(test_obj) @@ -1407,22 +1446,67 @@ def create_manual_test( sections_dict = {} for q_data in data.questions_json: + # Extract text with fallbacks + q_text = q_data.question_text or q_data.question or "Question" + sol_text = q_data.solution_text or q_data.solution or q_data.explanation or "Detailed solution will be available after review." + subj = q_data.subject or "General" + top = q_data.topic or subj + subtop = q_data.subtopic or top + diff = q_data.difficulty or "Medium" + + # Normalize question type safely + t_raw = str(q_data.type or "MCQ_Single").strip() + if t_raw in ["Single_Choice", "MCQ", "Single", "MCQ_Single"]: + q_type = models.QuestionTypeEnum.MCQ_SINGLE + elif t_raw in ["Multiple_Choice", "Multiple", "MCQ_Multiple"]: + q_type = models.QuestionTypeEnum.MCQ_MULTIPLE + elif t_raw in ["Numerical", "Subjective", "Integer"]: + q_type = models.QuestionTypeEnum.NUMERICAL + else: + try: + q_type = models.QuestionTypeEnum(t_raw) + except ValueError: + q_type = models.QuestionTypeEnum.MCQ_SINGLE + + # Normalize language + l_raw = str(q_data.language or "English").strip().capitalize() + if l_raw not in ["English", "Hindi", "Bilingual"]: + l_raw = "English" + lang_enum = models.MediumEnum(l_raw) + + # Normalize options + opts = q_data.options + if isinstance(opts, list): + opts = {chr(65 + i): str(v) for i, v in enumerate(opts)} + + opts_hi = q_data.options_hi + if isinstance(opts_hi, list): + opts_hi = {chr(65 + i): str(v) for i, v in enumerate(opts_hi)} + + # Normalize correct_answer + ca = q_data.correct_answer + if not isinstance(ca, dict): + ca = {"value": ca if ca is not None else "A"} + elif "value" not in ca: + ca = {"value": ca.get("correct") or ca.get("answer") or "A"} + q_obj = models.Question( - subject=q_data.subject, - topic=q_data.topic, - subtopic=q_data.subtopic, - difficulty=q_data.difficulty, - type=models.QuestionTypeEnum(q_data.type), - question_text=q_data.question_text, - options=q_data.options, - correct_answer=q_data.correct_answer, - solution_text=q_data.solution_text, - language=models.MediumEnum(q_data.language), - created_by=data.admin_id + subject=subj, + topic=top, + subtopic=subtop, + difficulty=diff, + type=q_type, + question_text=q_text, + options=opts, + options_hi=opts_hi, + correct_answer=ca, + solution_text=sol_text, + language=lang_enum, + created_by=effective_admin_id ) session.add(q_obj) - sec_name = q_data.section + sec_name = q_data.section or subj or "General" if sec_name not in sections_dict: sections_dict[sec_name] = { "name": sec_name, diff --git a/src/app/admin/tests/page.tsx b/src/app/admin/tests/page.tsx index a3a5961..05203b6 100644 --- a/src/app/admin/tests/page.tsx +++ b/src/app/admin/tests/page.tsx @@ -14,23 +14,88 @@ import { z } from "zod" import { useActiveUser } from "@/hooks/useActiveUser" -// Strict Zod schema for client-side validation -const QuestionSchema = z.object({ +// Permissive, auto-normalizing Zod schema for client-side question parsing +const QuestionSchema = z.preprocess((val: any) => { + if (typeof val !== "object" || val === null) return val; + const q = { ...val }; + + // Normalize question text + if (!q.question_text && q.question) { + q.question_text = q.question; + } + + // Normalize solution text + if (!q.solution_text && (q.solution || q.explanation)) { + q.solution_text = q.solution || q.explanation; + } + if (!q.solution_text) { + q.solution_text = "Detailed solution will be available after review."; + } + + // Normalize topic / subtopic / subject / difficulty + if (!q.subject) q.subject = "General"; + if (!q.topic) q.topic = q.subject || "General"; + if (!q.subtopic) q.subtopic = q.topic || "General"; + if (!q.difficulty) q.difficulty = "Medium"; + if (!q.section) q.section = q.subject || "General"; + + // Normalize question type + const typeStr = String(q.type || "").trim(); + if (["Single_Choice", "MCQ", "Single", "MCQ_Single"].includes(typeStr)) { + q.type = "MCQ_Single"; + } else if (["Multiple_Choice", "Multiple", "MCQ_Multiple"].includes(typeStr)) { + q.type = "MCQ_Multiple"; + } else if (["Numerical", "Subjective", "Integer"].includes(typeStr)) { + q.type = "Numerical"; + } else { + q.type = "MCQ_Single"; + } + + // Normalize language + const langStr = String(q.language || "").trim().toLowerCase(); + if (langStr === "hindi") q.language = "Hindi"; + else if (langStr === "bilingual") q.language = "Bilingual"; + else q.language = "English"; + + // Normalize options if passed as an array + if (Array.isArray(q.options)) { + const optsObj: Record = {}; + q.options.forEach((opt: any, idx: number) => { + const letter = String.fromCharCode(65 + idx); + optsObj[letter] = String(opt); + }); + q.options = optsObj; + } + + // Normalize correct_answer format + if (typeof q.correct_answer === "string" || typeof q.correct_answer === "number") { + q.correct_answer = { value: String(q.correct_answer) }; + } else if (Array.isArray(q.correct_answer)) { + q.correct_answer = { value: q.correct_answer.map(String) }; + } else if (typeof q.correct_answer === "object" && q.correct_answer !== null) { + const val = q.correct_answer.value ?? q.correct_answer.correct ?? q.correct_answer.answer ?? "A"; + q.correct_answer = { value: val }; + } else { + q.correct_answer = { value: "A" }; + } + + return q; +}, z.object({ id: z.string().optional(), section: z.string().default("General"), - subject: z.string(), - topic: z.string(), - subtopic: z.string(), - difficulty: z.string(), + subject: z.string().default("General"), + topic: z.string().default("General"), + subtopic: z.string().default("General"), + difficulty: z.string().default("Medium"), type: z.enum(["MCQ_Single", "MCQ_Multiple", "Numerical"]), - question_text: z.string(), + question_text: z.string().min(1, "Question text is required"), options: z.record(z.string(), z.any()).nullable().optional(), correct_answer: z.object({ value: z.any() }), solution_text: z.string(), language: z.enum(["English", "Hindi", "Bilingual"]) -}).passthrough(); +}).passthrough()); const TestSchema = z.array(QuestionSchema); @@ -173,13 +238,14 @@ export default function AdminTestsPage() { const handleUpload = async (e: React.FormEvent) => { e.preventDefault() + const effectiveAdminId = userId && userId !== "admin" ? userId : "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + if (testType === 'DPP_PDF') { const file = fileInputRef.current?.files?.[0] if (!file) { alert("Please select a PDF file to upload to Minio.") return } - if (!userId) return setUploading(true) try { const formData = new FormData() @@ -187,7 +253,7 @@ export default function AdminTestsPage() { formData.append("title", title) formData.append("exam_pattern", examPattern) formData.append("time_limit", timeLimit) - formData.append("admin_id", userId === "admin" ? "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" : (userId as string)) + formData.append("admin_id", effectiveAdminId) if (scheduledDate) formData.append("scheduled_date", scheduledDate.toISOString()) const res = await fetchWithAuth("/api/admin/tests/upload", { @@ -215,7 +281,6 @@ export default function AdminTestsPage() { alert("Please upload a valid JSON file with questions first.") return } - if (!userId) return setUploading(true) @@ -224,7 +289,7 @@ export default function AdminTestsPage() { type: testType, exam_pattern: examPattern, time_limit: parseInt(timeLimit), - admin_id: userId === "admin" ? "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" : userId, + admin_id: effectiveAdminId, questions_json: validQuestions, scheduled_date: scheduledDate ? scheduledDate.toISOString() : null, end_date: endDate ? endDate.toISOString() : null, @@ -449,7 +514,7 @@ export default function AdminTestsPage() {

{test.title}

- {test.exam_pattern.replace("_", " ")} + {test.exam_pattern?.replace("_", " ") || "All"} {test.is_active ? "Active" : "Inactive / Processing"} diff --git a/src/app/cbt/[examId]/page.tsx b/src/app/cbt/[examId]/page.tsx index 3b0ecd3..069a746 100644 --- a/src/app/cbt/[examId]/page.tsx +++ b/src/app/cbt/[examId]/page.tsx @@ -119,11 +119,18 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri setExamType(testData.type); if (testData.title) setTestName(testData.title); - // Extract all question IDs from sections + // Extract all question IDs from sections safely const questionIds: string[] = []; - Object.values(testData.sections).forEach((section: any) => { - questionIds.push(...section.questions); - }); + if (testData.sections) { + const sectionsList = Array.isArray(testData.sections) + ? testData.sections + : Object.values(testData.sections); + sectionsList.forEach((section: any) => { + if (section && Array.isArray(section.questions)) { + questionIds.push(...section.questions); + } + }); + } const qRes = await fetchWithAuth(`/api/tests/${examId}/questions`); if (!qRes.ok) throw new Error("Failed to fetch questions in bulk"); diff --git a/src/app/dashboard/cbt/page.tsx b/src/app/dashboard/cbt/page.tsx index d28324c..85cc071 100644 --- a/src/app/dashboard/cbt/page.tsx +++ b/src/app/dashboard/cbt/page.tsx @@ -72,7 +72,7 @@ export default function CBTPage() { if (selectedPattern === "JEE") { testsData = testsData.filter((t: any) => t.exam_pattern?.includes("JEE")); } else if (selectedPattern === "NEET") { - testsData = testsData.filter((t: any) => t.exam_pattern === "NEET"); + testsData = testsData.filter((t: any) => t.exam_pattern === "NEET" || t.exam_pattern === "JEE_NEET"); } setExams(testsData); }