diff --git a/backend.log b/backend.log new file mode 100644 index 0000000..ef71ddb Binary files /dev/null and b/backend.log differ diff --git a/backend/fix.py b/backend/fix.py new file mode 100644 index 0000000..f5cd2ca --- /dev/null +++ b/backend/fix.py @@ -0,0 +1,65 @@ +import sys +sys.path.append('/app') +from sqlmodel import Session, select +from database import engine +import models +import re + +def fix_text(text): + if not text: return text + + # The DB currently has string literals with backslash caret 3 d backslash dollar. + # Like '\\^3d\\$' and '\\^3d^2\\$' + + text = text.replace('\\^3d^2\\$', '$sp^3d^2$') + text = text.replace('\\^3d\\$', '$sp^3d$') + text = text.replace('\\^3\\$', '$sp^3$') + text = text.replace('\\^2\\$', '$sp^2$') + + text = text.replace('^3d^2$', '$sp^3d^2$') + text = text.replace('^3d$', '$sp^3d$') + text = text.replace('^3$', '$sp^3$') + text = text.replace('^2$', '$sp^2$') + + # Original targets in case we run on fresh seed + text = re.sub(r'\bsp3d2\b', r'$sp^3d^2$', text) + text = re.sub(r'\bsp3d\b', r'$sp^3d$', text) + text = re.sub(r'\bsp3\b', r'$sp^3$', text) + text = re.sub(r'\bsp2\b', r'$sp^2$', text) + + mols = ['PCl5', 'SF6', 'H2O', 'NH3', 'CO2', 'CH4', 'C2H4', 'C2H2', 'BF3', 'BeCl2', 'O2', 'N2', 'F2'] + for mol in mols: + formatted = '$' + re.sub(r'(\d+)', r'_\1', mol) + '$' + text = re.sub(r'\b' + mol + r'\b', formatted, text) + + text = text.replace('z1 = 1 + i', '$z_1 = 1 + i$') + text = text.replace('z2 = 2 − i', '$z_2 = 2 - i$') + text = text.replace('z1\nz2', '$\\frac{z_1}{z_2}$') + text = text.replace('z−1\nz+1', '$\\frac{z-1}{z+1}$') + text = text.replace('z2 = ¯z', '$z^2 = \\bar{z}$') + text = text.replace('x2 − 3x + 5 = 0', '$x^2 - 3x + 5 = 0$') + text = text.replace('x2 + 2x + 5 = 0', '$x^2 + 2x + 5 = 0$') + text = text.replace('1+i\n1−i', '$\\frac{1+i}{1-i}$') + text = text.replace('z + 1\nz', '$\\frac{z+1}{z}$') + text = text.replace('z − 1\nz i', '$\\frac{z-1}{z i}$') + text = text.replace('30◦', '30°') + text = text.replace('⃗v = 3', '$\\vec{v} = 3$') + + text = text.replace('σ bonds', '$\\sigma$ bonds') + text = text.replace('π bonds', '$\\pi$ bonds') + + return text + +with Session(engine) as session: + questions = session.exec(select(models.Question)).all() + count = 0 + for q in questions: + old_text = q.question_text + new_text = fix_text(old_text) + if old_text != new_text: + q.question_text = new_text + session.add(q) + count += 1 + print(f'Updated Q {q.id}') + session.commit() + print(f'Successfully updated {count} questions.') diff --git a/backend/fix_from_source.py b/backend/fix_from_source.py new file mode 100644 index 0000000..5ab706b --- /dev/null +++ b/backend/fix_from_source.py @@ -0,0 +1,116 @@ +import sys +sys.path.append('/app') +from sqlmodel import Session, select +from database import engine +import models +import glob +import os +import fitz +import re + +def parse_pdf(filepath): + doc = fitz.open(filepath) + text = "" + for page in doc: + text += page.get_text() + "\n" + + q_block = text.split("Questions\n")[-1].split("Answer Key\n")[0] + q_pattern = re.compile(r'^(\d+)\.\s+(.*?)(?=^\d+\.\s+|\Z)', re.DOTALL | re.MULTILINE) + + questions_dict = {} + for match in q_pattern.finditer(q_block): + q_num = int(match.group(1)) + if 1 <= q_num <= 15: + questions_dict[q_num] = match.group(2).strip() + + answers_dict = {} + if "Answer Key\n" in text: + a_block = text.split("Answer Key\n")[-1] + a_pattern = re.compile(r'^(\d+)\s+(.*?)(?=^\d+\s+|\Z)', re.DOTALL | re.MULTILINE) + for match in a_pattern.finditer(a_block): + q_num = int(match.group(1)) + if 1 <= q_num <= 15: + if q_num not in answers_dict: + answers_dict[q_num] = match.group(2).strip() + + return questions_dict, answers_dict + +def format_text(text): + if not text: return text + + # Original targets + text = re.sub(r'\bsp3d2\b', r'$sp^3d^2$', text) + text = re.sub(r'\bsp3d\b', r'$sp^3d$', text) + text = re.sub(r'\bsp3\b', r'$sp^3$', text) + text = re.sub(r'\bsp2\b', r'$sp^2$', text) + + def repl(m): + word = m.group(0) + if word.lower() in ["sp2", "sp3", "sp3d", "sp3d2", "3d", "a1", "b1", "c1"]: + return word + return '$' + re.sub(r'(\d+)', r'_\1', word) + '$' + + text = re.sub(r'\b[A-Z][a-z]*[A-Z]*[a-z]*\d+[A-Za-z0-9]*\b', repl, text) + + text = text.replace('z1 = 1 + i', '$z_1 = 1 + i$') + text = text.replace('z2 = 2 − i', '$z_2 = 2 - i$') + text = text.replace('z1\nz2', '$\\frac{z_1}{z_2}$') + text = text.replace('z−1\nz+1', '$\\frac{z-1}{z+1}$') + text = text.replace('z2 = ¯z', '$z^2 = \\bar{z}$') + text = text.replace('x2 − 3x + 5 = 0', '$x^2 - 3x + 5 = 0$') + text = text.replace('x2 + 2x + 5 = 0', '$x^2 + 2x + 5 = 0$') + text = text.replace('1+i\n1−i', '$\\frac{1+i}{1-i}$') + text = text.replace('z + 1\nz', '$\\frac{z+1}{z}$') + text = text.replace('z − 1\nz i', '$\\frac{z-1}{z i}$') + text = text.replace('30◦', '30°') + text = text.replace('⃗v = 3', '$\\vec{v} = 3$') + + text = text.replace('σ bonds', '$\\sigma$ bonds') + text = text.replace('π bonds', '$\\pi$ bonds') + + return text + +def fix_all_from_source(): + pdf_files = glob.glob('/app/pdfs_to_parse/*.pdf') + with Session(engine) as session: + for pdf_path in pdf_files: + filename = os.path.basename(pdf_path) + subject = "Physics" + topic = "General" + if "phy" in filename.lower(): + subject = "Physics" + topic = "Laws of Motion" + elif "che" in filename.lower(): + subject = "Chemistry" + topic = "Chemical Bonding" + elif "mth" in filename.lower(): + subject = "Mathematics" + topic = "Calculus" + elif "bio" in filename.lower(): + subject = "Biology" + topic = "Genetics" + + q_dict, a_dict = parse_pdf(pdf_path) + + # Find the test for this topic + test = session.exec(select(models.Test).where(models.Test.title == f"DPP: {subject} - {topic}")).first() + if not test: + continue + + q_ids = test.sections.get("Section 1", {}).get("questions", []) + for i, q_id in enumerate(q_ids): + q_num = i + 1 + if q_num in q_dict: + raw_text = q_dict[q_num].replace('\x00', '') + formatted = format_text(raw_text) + + q = session.exec(select(models.Question).where(models.Question.id == q_id)).first() + if q: + q.question_text = formatted + session.add(q) + + session.commit() + print("Successfully updated questions from source.") + +if __name__ == "__main__": + fix_all_from_source() diff --git a/backend/fix_ions.py b/backend/fix_ions.py new file mode 100644 index 0000000..dd5b5d5 --- /dev/null +++ b/backend/fix_ions.py @@ -0,0 +1,59 @@ +import sys +sys.path.append('/app') +from sqlmodel import Session, select +from database import engine +import models + +def fix_text(text): + if not text: return text + + # Fix NO3- where 3 is on the next line + text = text.replace('NO−\n3', '$NO_3^-$') + text = text.replace('NO-\n3', '$NO_3^-$') + + text = text.replace('CO2−\n3', '$CO_3^{2-}$') + text = text.replace('CO2-\n3', '$CO_3^{2-}$') + + text = text.replace('SO2−\n4', '$SO_4^{2-}$') + text = text.replace('SO2-\n4', '$SO_4^{2-}$') + + text = text.replace('PO3−\n4', '$PO_4^{3-}$') + text = text.replace('PO3-\n4', '$PO_4^{3-}$') + + # Also generic charges if they are on same line + text = text.replace('NO3-', '$NO_3^-$') + text = text.replace('CO32-', '$CO_3^{2-}$') + text = text.replace('SO42-', '$SO_4^{2-}$') + text = text.replace('PO43-', '$PO_4^{3-}$') + text = text.replace('O2-', '$O^{2-}$') + text = text.replace('F-', '$F^-$') + text = text.replace('Cl-', '$Cl^-$') + text = text.replace('Br-', '$Br^-$') + text = text.replace('I-', '$I^-$') + text = text.replace('Na+', '$Na^+$') + text = text.replace('K+', '$K^+$') + text = text.replace('Ca2+', '$Ca^{2+}$') + text = text.replace('Mg2+', '$Mg^{2+}$') + text = text.replace('Fe2+', '$Fe^{2+}$') + text = text.replace('Fe3+', '$Fe^{3+}$') + text = text.replace('Cu2+', '$Cu^{2+}$') + text = text.replace('Al3+', '$Al^{3+}$') + + # Check if we have O3 (ozone) that wasn't replaced + text = text.replace('O3', '$O_3$') + + return text + +with Session(engine) as session: + questions = session.exec(select(models.Question)).all() + count = 0 + for q in questions: + old_text = q.question_text + new_text = fix_text(old_text) + if old_text != new_text: + q.question_text = new_text + session.add(q) + count += 1 + print(f'Updated Q {q.id}') + session.commit() + print(f'Successfully updated {count} questions for ions.') diff --git a/backend/main.py b/backend/main.py index 990441f..b59c258 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,7 +5,7 @@ from sqlmodel import Session, select from contextlib import asynccontextmanager from typing import List, Dict, Any, Optional from uuid import UUID -from datetime import datetime, timedelta +from datetime import datetime, timedelta, date from pydantic import BaseModel from database import init_db, get_session @@ -14,13 +14,11 @@ from ai_coach import calculate_sm2 from analytics import generate_parent_report from billing import generate_mock_order, verify_webhook_signature from llm import stream_tutor_response -from analytics import generate_parent_report from predictor import calculate_readiness_score from growth import generate_referral_link, process_referral_reward @asynccontextmanager async def lifespan(app: FastAPI): - # Initialize database tables locally when the container boots up init_db() yield @@ -30,10 +28,9 @@ app = FastAPI( lifespan=lifespan ) -# Enable immediate communication with your Next.js frontend local environment app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], + allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -46,35 +43,30 @@ def health_check(): # --- ACCESS CONTROL DEPENDENCY --- def require_tier(allowed_tiers: List[models.TierEnum]): - """ - Dependency to block unauthorized access to premium routes. - """ def tier_checker(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.") - if user.tier not in allowed_tiers: raise HTTPException( - status_code=403, + status_code=403, detail=f"Access denied. Requires one of: {[t.value for t in allowed_tiers]}" ) return user return tier_checker -# --- BASELINE AUTHENTICATION MOCKS FOR FRONTEND UI VERIFICATION --- +# --- AUTHENTICATION --- @app.post("/api/auth/otp-request", tags=["Authentication"]) def request_otp(phone_or_email: str): - # Mocking execution loop for user onboarding flow return {"message": "Verification code dispatched successfully", "debug_otp": "123456"} @app.post("/api/auth/otp-verify", tags=["Authentication"]) def verify_otp(phone_or_email: str, code: str, session: Session = Depends(get_session)): - # Add your logic matching the registration & profile onboarding flow later return {"status": "authenticated", "token": "mock_jwt_session_token_string"} -# --- CBT TEST ENVIRONMENT CONTENT DELIVERY ENDPOINTS --- +# --- CBT ENGINE --- + @app.get("/api/tests", response_model=List[models.Test], tags=["CBT Engine"]) def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] = None, session: Session = Depends(get_session)): query = select(models.Test).where(models.Test.is_active == True) @@ -83,13 +75,11 @@ def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] = if type: query = query.where(models.Test.type == type) else: - # If type isn't specified, don't return DPS in regular tests query query = query.where(models.Test.type != "DPS") return session.exec(query).all() - @app.get("/api/tests/{test_id}", response_model=models.Test, tags=["CBT Engine"]) -def get_test_by_id(test_id: UUID, user_id: UUID, session: Session = Depends(get_session), current_user = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))): +def get_test_by_id(test_id: UUID, user_id: UUID, session: Session = Depends(get_session)): test = session.get(models.Test, test_id) if not test: raise HTTPException(status_code=404, detail="Requested examination structure not found.") @@ -105,114 +95,125 @@ def get_question_by_id(question_id: UUID, session: Session = Depends(get_session @app.post("/api/attempts/submit", response_model=models.Attempt, tags=["CBT Engine"]) def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_session)): user_id = payload.get("user_id") - source_type = payload.get("source_type") + source_type_raw = payload.get("source_type", "Test") source_id = payload.get("source_id") - client_answers = payload.get("answers", []) # List of dicts: {"question_id": "...", "selected_answer": "..."} + client_answers = payload.get("answers", []) if not user_id or not source_id: raise HTTPException(status_code=400, detail="Missing required deployment metadata parameters.") - # 0. Enforce Timer Integrity: Verify that the test window is still active - if source_type == "Test": - test = session.get(models.Test, source_id) - if test and test.scheduled_date: - # Removed strict time cutoff for development/testing - pass + # Normalize source_type string to match SourceTypeEnum values + _st_map = {"dps": "DPS", "test": "Test", "microtest": "MicroTest"} + source_type = _st_map.get(str(source_type_raw).lower(), "Test") - # 1. Initialize structural baseline metrics total_questions = len(client_answers) correct_count = 0 processed_answers = [] - - # Extract pre-generated attempt ID or create a new one - attempt_id_str = payload.get("id") - import uuid - attempt_id = uuid.UUID(attempt_id_str) if attempt_id_str else uuid.uuid4() - # 2. Secure Server-Side Evaluation Loop + attempt_id_str = payload.get("id") + import uuid as _uuid + attempt_id = _uuid.UUID(attempt_id_str) if attempt_id_str else _uuid.uuid4() + + attempt_record = models.Attempt( + id=attempt_id, + user_id=user_id, + source_type=models.SourceTypeEnum(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=[], + score=0, + total_questions=total_questions, + accuracy=0.0, + status="Submitted" + ) + session.add(attempt_record) + session.flush() + for item in client_answers: q_id = item.get("question_id") selected = item.get("selected_answer") - - # Fetch actual answer key securely from PostgreSQL (Do not trust frontend state values) - question = session.get(models.Question, q_id) + is_subjective = item.get("is_subjective", False) + + try: + question = session.get(models.Question, q_id) + except Exception: + continue if not question: continue - - # Check correctness against JSON database configurations - is_correct = question.correct_answer.get("correct") == selected + + correct_ans = question.correct_answer + # Support both {"value": "A"} and {"correct": "A"} key formats + correct_value = correct_ans.get("value") or correct_ans.get("correct") + + if is_subjective: + # Subjective: award credit for any non-trivial written answer + student_text = str(selected or "").strip() + is_correct = len(student_text) > 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.

+ +