From 1a6168f724e15953378d13d65ea8eeb913250a9a Mon Sep 17 00:00:00 2001 From: ectasy663 Date: Wed, 8 Jul 2026 12:46:44 +0530 Subject: [PATCH] fix(dashboard): DPP tags properly dynamic based on user target exam --- backend.log | Bin 0 -> 153086 bytes backend/fix.py | 65 +++ backend/fix_from_source.py | 116 ++++++ backend/fix_ions.py | 59 +++ backend/main.py | 386 ++++++++++-------- backend/models.py | 1 + backend/pragativeer.db | 0 backend/seed_from_dpps.py | 6 +- package-lock.json | 129 ++++++ package.json | 3 + src/app/admin/layout.tsx | 6 +- src/app/admin/students/page.tsx | 2 + src/app/admin/tests/page.tsx | 101 +++-- src/app/cbt/[examId]/page.tsx | 38 +- src/app/dashboard/analytics/page.tsx | 2 +- .../cbt/history/[attemptId]/page.tsx | 12 +- src/app/dashboard/cbt/page.tsx | 2 +- src/app/dashboard/community/page.tsx | 17 +- src/app/dashboard/dpp/page.tsx | 18 +- src/app/dashboard/layout.tsx | 10 +- src/app/dashboard/page.tsx | 9 +- src/app/layout.tsx | 10 +- src/app/page.tsx | 122 +++--- src/components/CBT/ExamResult.tsx | 6 +- src/components/CBT/PeriodicTable.tsx | 8 +- .../Dashboard/StreakOnboardingModal.tsx | 4 +- src/components/StreakCounter.tsx | 11 +- src/store/cbtStore.ts | 14 +- 28 files changed, 853 insertions(+), 304 deletions(-) create mode 100644 backend.log create mode 100644 backend/fix.py create mode 100644 backend/fix_from_source.py create mode 100644 backend/fix_ions.py create mode 100644 backend/pragativeer.db diff --git a/backend.log b/backend.log new file mode 100644 index 0000000000000000000000000000000000000000..ef71ddb89b992bcf7fe882e83096f8eca5d9c765 GIT binary patch literal 153086 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 5 + else: + is_correct = bool(correct_value) and correct_value == selected + if is_correct: correct_count += 1 - + processed_answers.append({ "question_id": str(q_id), "selected_answer": selected, "is_correct": is_correct, + "is_subjective": is_subjective, "time_spent": item.get("time_spent", 0) }) - # 3. AI Coach Processing: If the answer is incorrect, extract it to the Mistake queue - if not is_correct: - # Check if this user has failed this specific question before to track execution loops - existing_mistake = session.exec( - select(models.Mistake).where( - models.Mistake.user_id == user_id, - models.Mistake.question_id == q_id - ) - ).first() + # Track mistakes only for objective wrong answers + if not is_correct and not is_subjective: + try: + existing_mistake = session.exec( + select(models.Mistake).where( + models.Mistake.user_id == user_id, + models.Mistake.question_id == q_id + ) + ).first() + except Exception: + existing_mistake = None if existing_mistake: - # User failed it again: reset repetitions, assign low quality score (q=1) interval, ef = calculate_sm2(q=1, repetitions=existing_mistake.review_count, ease_factor=2.5, previous_interval=1) existing_mistake.review_count += 1 existing_mistake.next_review = datetime.utcnow() + timedelta(days=interval) existing_mistake.consecutive_correct = 0 session.add(existing_mistake) else: - # Brand new mistake: enter into the spaced repetition queue for the first time new_mistake = models.Mistake( user_id=user_id, question_id=q_id, - attempt_id=attempt_id, # Linking back to original source file attempt mapping - error_type=models.ErrorTypeEnum.CONCEPTUAL, # Defaults to conceptual until AI tags it + attempt_id=attempt_id, + error_type=models.ErrorTypeEnum.CONCEPTUAL, topic=question.topic, subtopic=question.subtopic, - next_review=datetime.utcnow() + timedelta(days=1), # Schedule for tomorrow review loop + next_review=datetime.utcnow() + timedelta(days=1), review_count=1, consecutive_correct=0, is_mastered=False ) session.add(new_mistake) - # 4. Save Core Attempt Records accuracy = (correct_count / total_questions) * 100 if total_questions > 0 else 0.0 - - attempt_record = models.Attempt( - id=attempt_id, - user_id=user_id, - source_type=source_type, - source_id=source_id, - started_at=datetime.utcnow() - timedelta(seconds=payload.get("time_taken", 0)), - submitted_at=datetime.utcnow(), - time_taken=payload.get("time_taken", 0), - answers=processed_answers, - score=correct_count, - total_questions=total_questions, - accuracy=accuracy, - status="Submitted" - ) - + + attempt_record.answers = processed_answers + attempt_record.score = correct_count + attempt_record.accuracy = accuracy + session.add(attempt_record) session.commit() session.refresh(attempt_record) - + return attempt_record -# --- AI COACH SPАСED REPETITION ENDPOINTS --- +# --- AI COACH SPACED REPETITION ENDPOINTS --- @app.get("/api/mistakes/today", tags=["AI Coach"]) def get_todays_mistakes(user_id: UUID, session: Session = Depends(get_session)): - # Retrieve all un-mastered mistakes for the user scheduled on or before today now = datetime.utcnow() statement = select(models.Mistake, models.Question).join( models.Question, models.Mistake.question_id == models.Question.id @@ -222,61 +223,52 @@ def get_todays_mistakes(user_id: UUID, session: Session = Depends(get_session)): models.Mistake.next_review <= now ) results = session.exec(statement).all() - response_data = [] for mistake, question in results: - response_data.append({ - "mistake": mistake, - "question": question - }) + response_data.append({"mistake": mistake, "question": question}) return response_data @app.post("/api/mistakes/review", tags=["AI Coach"]) def submit_mistake_review(payload: Dict[str, Any], session: Session = Depends(get_session)): mistake_id = payload.get("mistake_id") selected_answer = payload.get("selected_answer") - + if not mistake_id or not selected_answer: raise HTTPException(status_code=400, detail="Missing mistake_id or selected_answer") - + mistake = session.get(models.Mistake, mistake_id) if not mistake: raise HTTPException(status_code=404, detail="Mistake not found.") - + question = session.get(models.Question, mistake.question_id) - - is_correct = question.correct_answer.get("correct") == selected_answer - - # Calculate intervals - q_value = 4 if is_correct else 1 - - # Very basic interval calculation for demo + correct_value = question.correct_answer.get("value") or question.correct_answer.get("correct") + is_correct = correct_value == selected_answer + + q_value = 4 if is_correct else 1 days_since_creation = (datetime.utcnow() - mistake.created_at).days prev_interval = max(1, days_since_creation) - interval, ef = calculate_sm2(q=q_value, repetitions=mistake.review_count, ease_factor=2.5, previous_interval=prev_interval) - + if is_correct: mistake.consecutive_correct += 1 if mistake.consecutive_correct >= 3: mistake.is_mastered = True else: mistake.consecutive_correct = 0 - + mistake.review_count += 1 mistake.next_review = datetime.utcnow() + timedelta(days=interval) - + session.add(mistake) session.commit() session.refresh(mistake) - - return {"mistake": mistake, "is_correct": is_correct, "correct_answer": question.correct_answer.get("correct")} -# --- PARENT SUBSYSTEM ENDPOINTS --- + return {"mistake": mistake, "is_correct": is_correct, "correct_answer": correct_value} + +# --- PARENT SUBSYSTEM --- @app.post("/api/parent/register", response_model=models.Parent, tags=["Parent Subsystem"]) def register_parent(parent_data: models.Parent, session: Session = Depends(get_session)): - # Persist parent baseline identity profile session.add(parent_data) session.commit() session.refresh(parent_data) @@ -287,26 +279,24 @@ def link_student_to_parent(parent_id: UUID, student_phone_or_email: str, session parent = session.get(models.Parent, parent_id) if not parent: raise HTTPException(status_code=404, detail="Parent account context not found.") - - # Locate the target student account by phone or email criteria + student = session.exec( select(models.User).where( - (models.User.phone == student_phone_or_email) | + (models.User.phone == student_phone_or_email) | (models.User.email == student_phone_or_email) ) ).first() - + if not student: raise HTTPException(status_code=404, detail="Target student profile could not be verified.") - - # Append the student's UUID string to the parent's linked array + current_links = list(parent.linked_students or []) if str(student.id) not in current_links: current_links.append(str(student.id)) parent.linked_students = current_links session.add(parent) session.commit() - + return {"message": "Student successfully linked to parent context.", "linked_student_id": student.id} @app.get("/api/parent/{parent_id}/dashboard/{student_id}", tags=["Parent Subsystem"]) @@ -314,17 +304,14 @@ def get_parent_dashboard_metrics(parent_id: UUID, student_id: UUID, session: Ses parent = session.get(models.Parent, parent_id) if not parent: raise HTTPException(status_code=404, detail="Unauthorized parent profile access attempt.") - - # Security check: Ensure this parent is explicitly authorized to view this student's data + if str(student_id) not in (parent.linked_students or []): raise HTTPException(status_code=403, detail="Access denied to requested student dashboard data.") - + report = generate_parent_report(student_id, session) return report - - -# --- BILLING & SUBSCRIPTION ENDPOINTS --- +# --- BILLING & SUBSCRIPTION --- @app.post("/api/billing/create-order", tags=["Subscription Engine"]) def create_subscription_order(user_id: UUID, plan_tier: str, session: Session = Depends(get_session)): @@ -332,12 +319,7 @@ def create_subscription_order(user_id: UUID, plan_tier: str, session: Session = if not user: raise HTTPException(status_code=404, detail="User not found.") - # Match PRD pricing parameters (amounts in paise) - pricing_map = { - "Pro": 29900, # ₹299/mo - "Al_Coach": 59900 # ₹599/mo - } - + pricing_map = {"Pro": 29900, "Al_Coach": 59900} amount = pricing_map.get(plan_tier) if not amount: raise HTTPException(status_code=400, detail="Invalid subscription tier requested.") @@ -347,34 +329,25 @@ def create_subscription_order(user_id: UUID, plan_tier: str, session: Session = @app.post("/api/billing/webhook/razorpay", tags=["Subscription Engine"]) async def razorpay_webhook_handler(request: Request, session: Session = Depends(get_session)): - """ - Receives server-to-server notifications when a payment succeeds. - """ payload_bytes = await request.body() - payload_str = payload_bytes.decode('utf-8') + payload_str = payload_bytes.decode("utf-8") signature = request.headers.get("X-Razorpay-Signature", "") - # Security Check: Verify signature if not verify_webhook_signature(payload_str, signature): raise HTTPException(status_code=400, detail="Invalid webhook signature.") import json data = json.loads(payload_str) - - # Process the successful payment event + if data.get("event") == "payment.captured": notes = data["payload"]["payment"]["entity"]["notes"] user_id = notes.get("user_id") tier_requested = notes.get("tier_requested") - + user = session.get(models.User, user_id) if user and tier_requested: - # Upgrade the user's tier in the database user.tier = models.TierEnum(tier_requested) session.add(user) - - # Log the subscription record - from datetime import timedelta new_sub = models.Subscription( user_id=user.id, tier=models.TierEnum(tier_requested), @@ -387,24 +360,23 @@ async def razorpay_webhook_handler(request: Request, session: Session = Depends( ) session.add(new_sub) session.commit() - + return {"status": "Webhook processed successfully"} -# --- STUDY PLAN ENDPOINTS --- -from datetime import date +# --- STUDY PLAN --- @app.get("/api/ai-coach/study-plan/today", tags=["AI Coach"]) -def get_todays_study_plan(user_id: UUID, session: Session = Depends(get_session), current_user = Depends(require_tier([models.TierEnum.AI_COACH]))): +def get_todays_study_plan(user_id: UUID, session: Session = Depends(get_session), current_user=Depends(require_tier([models.TierEnum.AI_COACH]))): today = datetime.utcnow().date() plan = session.exec( select(models.StudyPlan) .where(models.StudyPlan.user_id == user_id) .where(models.StudyPlan.date == today) ).first() - + if not plan: raise HTTPException(status_code=404, detail="No study plan generated for today yet.") - + return plan class ChatRequest(BaseModel): @@ -413,32 +385,22 @@ class ChatRequest(BaseModel): @app.post("/api/ai-coach/chat/stream", tags=["AI Coach"]) async def ai_tutor_chat_stream(request: ChatRequest, session: Session = Depends(get_session)): - """ - Streams the context-aware LLM response back to the client. - """ user = session.get(models.User, request.user_id) if not user: raise HTTPException(status_code=404, detail="Student profile not found.") if user.tier != models.TierEnum.AI_COACH: raise HTTPException(status_code=403, detail="Access denied. Gated to AI_COACH tier.") - - # Enforce Daily Limit (50 messages) as per PRD A-04 - # (In a full production build, you'd increment a Redis counter here) return StreamingResponse( - stream_tutor_response(request.user_id, request.message, session), + stream_tutor_response(request.user_id, request.message, session), media_type="text/event-stream" ) @app.get("/api/ai-coach/readiness/{user_id}", tags=["AI Coach"]) -def get_exam_readiness(user_id: UUID, session: Session = Depends(get_session), current_user = Depends(require_tier([models.TierEnum.AI_COACH]))): - """ - Returns the dynamic 0-100 Exam Readiness Score and College Prediction. - """ +def get_exam_readiness(user_id: UUID, session: Session = Depends(get_session), current_user=Depends(require_tier([models.TierEnum.AI_COACH]))): readiness_data = calculate_readiness_score(user_id, session) if not readiness_data: raise HTTPException(status_code=404, detail="Could not calculate readiness score.") - return readiness_data @app.get("/api/growth/referral/{user_id}", tags=["Viral Growth"]) @@ -446,17 +408,17 @@ def get_referral_dashboard(user_id: UUID, session: Session = Depends(get_session user = session.get(models.User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found.") - + link = generate_referral_link(user_id) - return { "referral_link": link, - "total_referrals": 0, # In production, query a referral tracking table + "total_referrals": 0, "rewards_earned_days": 0, "share_message": f"I'm on a {user.streak_count}-day study streak on PragatiVeer! Use my link to get 7 days of the AI Coach for free: {link}" } -# --- USER CRUD & TEST LISTING --- +# --- USERS --- + @app.get("/api/users/{user_id}", response_model=models.User, tags=["Users"]) def get_user_profile(user_id: UUID, session: Session = Depends(get_session)): user = session.get(models.User, user_id) @@ -476,24 +438,18 @@ def update_user_profile(user_id: UUID, payload: UserUpdate, session: Session = D user = session.get(models.User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") - - if payload.name is not None: - user.name = payload.name - if payload.target_exam is not None: - user.target_exam = payload.target_exam - if payload.student_class is not None: - user.student_class = payload.student_class - if payload.school is not None: - user.school = payload.school - if payload.state is not None: - user.state = payload.state - + + if payload.name is not None: user.name = payload.name + if payload.target_exam is not None: user.target_exam = payload.target_exam + if payload.student_class is not None: user.student_class = payload.student_class + if payload.school is not None: user.school = payload.school + if payload.state is not None: user.state = payload.state + session.add(user) session.commit() session.refresh(user) return user -from fastapi import UploadFile, File from storage import upload_file @app.post("/api/users/{user_id}/upload-avatar", tags=["Users"]) @@ -501,31 +457,22 @@ async def upload_user_avatar(user_id: UUID, file: UploadFile = File(...), sessio user = session.get(models.User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") - + contents = await file.read() file_extension = file.filename.split(".")[-1] if "." in file.filename else "jpg" object_name = f"avatar_{user_id}.{file_extension}" public_url = upload_file(contents, object_name, content_type=file.content_type) - + if not public_url: raise HTTPException(status_code=500, detail="Failed to upload image to Minio.") - - return {"url": public_url} -@app.get("/api/tests", response_model=List[models.Test], tags=["CBT Engine"]) -def list_tests(exam_pattern: Optional[models.ExamEnum] = None, session: Session = Depends(get_session)): - query = select(models.Test) - if exam_pattern is not None: - query = query.where(models.Test.exam_pattern == exam_pattern) - return session.exec(query).all() + return {"url": public_url} @app.get("/api/users/{user_id}/attempts", tags=["Users"]) def get_user_attempts(user_id: UUID, session: Session = Depends(get_session)): query = select(models.Attempt).where(models.Attempt.user_id == user_id) return session.exec(query).all() - - # --- ADMIN ROUTES --- @app.get("/api/admin/tests", tags=["Admin"]) @@ -533,6 +480,28 @@ def get_admin_tests(session: Session = Depends(get_session)): tests = session.exec(select(models.Test)).all() return tests +class ManualQuestionInput(BaseModel): + section: str + text_en: str + text_hi: Optional[str] = None + options_en: List[str] + options_hi: Optional[List[str]] = None + correct_answer: str + type: str = "SingleChoice" + topic: str = "General" + difficulty: str = "Medium" + marks: int = 4 + negative_marks: int = 1 + +class ManualTestCreate(BaseModel): + title: str + exam_pattern: str + time_limit: int + scheduled_date: Optional[datetime] = None + end_date: Optional[datetime] = None + admin_id: UUID + questions_json: List[ManualQuestionInput] + @app.post("/api/admin/tests/upload", tags=["Admin"]) async def upload_admin_test( file: UploadFile = File(...), @@ -546,14 +515,13 @@ async def upload_admin_test( import storage import uuid from worker import process_pdf_test - + pdf_bytes = await file.read() object_name = f"tests/{uuid.uuid4()}_{file.filename}" url = storage.upload_file(pdf_bytes, object_name, content_type="application/pdf") if not url: raise HTTPException(status_code=500, detail="Failed to upload PDF to MinIO") - # Create inactive test test_obj = models.Test( title=title, type="Mock", @@ -562,14 +530,13 @@ async def upload_admin_test( total_time=time_limit, difficulty="Medium", scheduled_date=scheduled_date, - is_active=False, # Inactive until processing finishes + is_active=False, created_by=admin_id ) session.add(test_obj) session.commit() session.refresh(test_obj) - # Trigger async background worker process_pdf_test.delay( test_id_str=str(test_obj.id), object_name=object_name, @@ -579,14 +546,73 @@ async def upload_admin_test( return {"status": "processing", "test_id": test_obj.id, "message": "Test is being processed by AI"} +@app.post("/api/admin/tests/create_manual", tags=["Admin"]) +def create_manual_test( + data: ManualTestCreate, + session: Session = Depends(get_session) +): + import uuid + # 1. Create the test object + test_obj = models.Test( + title=data.title, + type="Mock", + exam_pattern=models.ExamEnum(data.exam_pattern), + 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 + ) + session.add(test_obj) + session.commit() + session.refresh(test_obj) + + # 2. Parse and create questions + sections_dict = {} + + for q_data in data.questions_json: + q_obj = models.Question( + text_en=q_data.text_en, + text_hi=q_data.text_hi, + options_en=q_data.options_en, + options_hi=q_data.options_hi, + correct_answer=q_data.correct_answer, + type=q_data.type, + topic=q_data.topic, + difficulty=q_data.difficulty, + marks=q_data.marks, + negative_marks=q_data.negative_marks + ) + session.add(q_obj) + session.commit() + session.refresh(q_obj) + + sec_name = q_data.section + if sec_name not in sections_dict: + sections_dict[sec_name] = { + "name": sec_name, + "questions": [], + "time_limit": None + } + sections_dict[sec_name]["questions"].append(str(q_obj.id)) + + # 3. Update the test with the sections + test_obj.sections = sections_dict + session.add(test_obj) + session.commit() + + return {"message": "Test created successfully", "test_id": test_obj.id} + @app.put("/api/admin/tests/{test_id}/toggle", tags=["Admin"]) def toggle_test_status(test_id: UUID, session: Session = Depends(get_session)): test_obj = session.get(models.Test, test_id) if not test_obj: raise HTTPException(status_code=404, detail="Test not found") - + test_obj.is_active = not test_obj.is_active session.add(test_obj) session.commit() - + return {"status": "success", "is_active": test_obj.is_active} \ No newline at end of file diff --git a/backend/models.py b/backend/models.py index 9a5a98d..7885acb 100644 --- a/backend/models.py +++ b/backend/models.py @@ -114,6 +114,7 @@ class Test(SQLModel, table=True): total_time: int difficulty: str scheduled_date: Optional[datetime] = None + end_date: Optional[datetime] = None is_active: bool = Field(default=True) created_by: UUID diff --git a/backend/pragativeer.db b/backend/pragativeer.db new file mode 100644 index 0000000..e69de29 diff --git a/backend/seed_from_dpps.py b/backend/seed_from_dpps.py index 43bd3e6..e6f73d5 100644 --- a/backend/seed_from_dpps.py +++ b/backend/seed_from_dpps.py @@ -100,10 +100,10 @@ async def seed_pdfs(): topic=topic, subtopic="General", difficulty="Medium", - type=models.QuestionTypeEnum.MCQ_SINGLE, + type=models.QuestionTypeEnum.NUMERICAL, question_text=q_text, - options=options, - correct_answer={"value": "A"}, + options=None, + correct_answer={"value": "Subjective input"}, solution_text=ans_text, language=models.MediumEnum.ENGLISH, exam=models.ExamEnum.JEE_MAIN, diff --git a/package-lock.json b/package-lock.json index 3bb36ed..55343bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,9 @@ "name": "pragativeer-v2", "version": "0.1.0", "dependencies": { + "@types/react-datepicker": "^6.2.0", "clsx": "^2.1.1", + "date-fns": "^4.4.0", "framer-motion": "^12.41.0", "html-to-image": "^1.11.13", "idb-keyval": "^6.2.6", @@ -16,6 +18,7 @@ "lucide-react": "^1.21.0", "next": "16.2.9", "react": "19.2.4", + "react-datepicker": "^9.1.0", "react-dom": "19.2.4", "react-katex": "^3.1.0", "react-markdown": "^10.1.0", @@ -467,6 +470,59 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1746,6 +1802,27 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/react-datepicker": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@types/react-datepicker/-/react-datepicker-6.2.0.tgz", + "integrity": "sha512-+JtO4Fm97WLkJTH8j8/v3Ldh7JCNRwjMYjRaKh4KHH0M3jJoXtwiD3JBCsdlg3tsFIw9eQSqyAPeVDN2H2oM9Q==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.2", + "@types/react": "*", + "date-fns": "^3.3.1" + } + }, + "node_modules/@types/react-datepicker/node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/@types/react-dom": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", @@ -3194,6 +3271,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -7065,6 +7152,42 @@ "node": ">=0.10.0" } }, + "node_modules/react-datepicker": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-9.1.0.tgz", + "integrity": "sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.27.15", + "clsx": "^2.1.1", + "date-fns": "^4.1.0" + }, + "peerDependencies": { + "date-fns-tz": "^3.0.0", + "react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "date-fns-tz": { + "optional": true + } + } + }, + "node_modules/react-datepicker/node_modules/@floating-ui/react": { + "version": "0.27.19", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", + "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", @@ -7955,6 +8078,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", diff --git a/package.json b/package.json index 2a82037..87ede3f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "lint": "eslint" }, "dependencies": { + "@types/react-datepicker": "^6.2.0", "clsx": "^2.1.1", + "date-fns": "^4.4.0", "framer-motion": "^12.41.0", "html-to-image": "^1.11.13", "idb-keyval": "^6.2.6", @@ -17,6 +19,7 @@ "lucide-react": "^1.21.0", "next": "16.2.9", "react": "19.2.4", + "react-datepicker": "^9.1.0", "react-dom": "19.2.4", "react-katex": "^3.1.0", "react-markdown": "^10.1.0", diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 04019b4..2e0616a 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -1,6 +1,6 @@ "use client" -import { ReactNode } from "react" +import { ReactNode, useEffect } from "react" import { LayoutDashboard, Users, Database, Terminal, Settings, FileText } from "lucide-react" import Link from "next/link" import { usePathname } from "next/navigation" @@ -9,6 +9,10 @@ import { Logo } from "@/components/Logo" export default function AdminLayout({ children }: { children: ReactNode }) { const pathname = usePathname() + useEffect(() => { + document.documentElement.classList.add('dark'); + }, []); + return (
{/* Sidebar */} diff --git a/src/app/admin/students/page.tsx b/src/app/admin/students/page.tsx index 6411ff3..2d84141 100644 --- a/src/app/admin/students/page.tsx +++ b/src/app/admin/students/page.tsx @@ -1,3 +1,5 @@ +"use client" + import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Search, Download, MoreVertical } from "lucide-react" diff --git a/src/app/admin/tests/page.tsx b/src/app/admin/tests/page.tsx index d63ec22..fa652a8 100644 --- a/src/app/admin/tests/page.tsx +++ b/src/app/admin/tests/page.tsx @@ -7,6 +7,8 @@ import { Badge } from "@/components/ui/badge" import { Switch } from "@/components/ui/switch" import { Input } from "@/components/ui/input" import { Loader2, Upload, FileText, Clock, CalendarIcon } from "lucide-react" +import DatePicker from "react-datepicker" +import "react-datepicker/dist/react-datepicker.css" import { useActiveUser } from "@/hooks/useActiveUser" @@ -15,13 +17,13 @@ export default function AdminTestsPage() { const [loading, setLoading] = useState(true) const [uploading, setUploading] = useState(false) const { userId } = useActiveUser() - const fileInputRef = useRef(null) - // Upload Form State const [title, setTitle] = useState("") const [examPattern, setExamPattern] = useState("JEE_Main") const [timeLimit, setTimeLimit] = useState("180") - const [scheduledDate, setScheduledDate] = useState("") + const [scheduledDate, setScheduledDate] = useState(null) + const [endDate, setEndDate] = useState(null) + const [questionsJson, setQuestionsJson] = useState("") const fetchTests = async () => { try { @@ -56,37 +58,48 @@ export default function AdminTestsPage() { const handleUpload = async (e: React.FormEvent) => { e.preventDefault() - if (!fileInputRef.current?.files?.[0]) return + if (!questionsJson) return if (!userId) return setUploading(true) - const formData = new FormData() - formData.append("file", fileInputRef.current.files[0]) - formData.append("title", title) - formData.append("exam_pattern", examPattern) - formData.append("time_limit", timeLimit) - formData.append("admin_id", userId) - if (scheduledDate) { - formData.append("scheduled_date", new Date(scheduledDate).toISOString()) + + let parsedQuestions = []; + try { + parsedQuestions = JSON.parse(questionsJson); + } catch(err) { + alert("Invalid JSON format. Please paste a valid JSON array."); + setUploading(false); + return; } + const payload = { + title, + exam_pattern: examPattern, + time_limit: parseInt(timeLimit), + admin_id: userId, + questions_json: parsedQuestions, + scheduled_date: scheduledDate ? scheduledDate.toISOString() : null, + end_date: endDate ? endDate.toISOString() : null, + }; + try { - const res = await fetch("http://localhost:8000/api/admin/tests/upload", { + const res = await fetch("http://localhost:8000/api/admin/tests/create_manual", { method: "POST", - body: formData + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) }) if (res.ok) { - alert("Test uploaded and sent to AI for processing!") + alert("Test created successfully!") setTitle("") - if (fileInputRef.current) fileInputRef.current.value = "" + setQuestionsJson("") fetchTests() } else { - alert("Upload failed") + alert("Creation failed") } } catch (error) { console.error(error) - alert("Upload failed") + alert("Creation failed") } finally { setUploading(false) } @@ -106,9 +119,9 @@ export default function AdminTestsPage() { - Upload New Test + Create Manual Test - Upload a PDF containing questions. AI will automatically extract and format them. + Paste the structured JSON block of questions in LaTeX format to create a deterministic test.
@@ -135,20 +148,52 @@ export default function AdminTestsPage() { setTimeLimit(e.target.value)} />
-
- - setScheduledDate(e.target.value)} /> +
+
+ + setScheduledDate(date)} + showTimeSelect + timeFormat="HH:mm" + timeIntervals={15} + timeCaption="time" + dateFormat="MMMM d, yyyy h:mm aa" + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" + placeholderText="Select start date & time" + /> +
+
+ + setEndDate(date)} + showTimeSelect + timeFormat="HH:mm" + timeIntervals={15} + timeCaption="time" + dateFormat="MMMM d, yyyy h:mm aa" + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" + placeholderText="Select end date & time" + /> +
- - -

Make sure it contains clear questions and options.

+ +