banu4prasad commited on
Commit
0592ab6
·
1 Parent(s): 41a5d1a

code split: api/routes/tests

Browse files
backend/app/api/routes/tests/__init__.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+ from .attempts import (
4
+ save_answers,
5
+ start_test,
6
+ submit_test,
7
+ )
8
+ from .history import my_history
9
+ from .results import get_result
10
+ from .schemas import AnswerSubmit, BulkAnswerSubmit
11
+
12
+ from .catalog import router as catalog_router
13
+ from .attempts import router as attempts_router
14
+ from .results import router as results_router
15
+ from .leaderboard import router as leaderboard_router
16
+ from .history import router as history_router
17
+
18
+ router = APIRouter(prefix="/tests", tags=["Tests"])
19
+
20
+ router.include_router(catalog_router)
21
+ router.include_router(attempts_router)
22
+ router.include_router(results_router)
23
+ router.include_router(leaderboard_router)
24
+ router.include_router(history_router)
25
+
26
+ # Re-exports for tests/test_attempt_persistence.py
27
+ __all__ = [
28
+ "AnswerSubmit",
29
+ "BulkAnswerSubmit",
30
+ "get_result",
31
+ "my_history",
32
+ "save_answers",
33
+ "start_test",
34
+ "submit_test",
35
+ ]
backend/app/api/routes/{tests.py → tests/attempts.py} RENAMED
@@ -1,183 +1,27 @@
1
  from datetime import datetime, timezone
2
- from typing import List, Optional
3
 
4
  from fastapi import APIRouter, Depends, HTTPException
5
- from fastapi_cache.decorator import cache
6
- from pydantic import BaseModel
7
- from sqlalchemy import func, select
8
- from sqlalchemy.orm import Session, aliased, joinedload
9
 
10
- from app.api.deps import get_current_user, require_aspirant
11
  from app.core.database import get_db
12
- from app.models.models import (
13
- PracticeAttemptCounter,
14
- Question,
15
- Test,
16
- TestAttempt,
17
- TestStatus,
18
- UserAnswer,
19
- )
20
  from app.services.cloudinary_service import (
21
  optimize_delivery_image_url,
22
  optimize_delivery_image_urls,
23
  )
24
- from app.services.scoring import evaluate_answer
25
-
26
- router = APIRouter(prefix="/tests", tags=["Tests"])
27
-
28
- MAX_REATTEMPTS = 5 # max reattempts per test (6 total including first)
29
-
30
-
31
- # ── Schemas ───────────────────────────────────────────────────────
32
-
33
-
34
- class AnswerSubmit(BaseModel):
35
- question_id: int
36
- selected_answer: Optional[str] = None
37
- time_spent_seconds: int = 0
38
-
39
-
40
- class BulkAnswerSubmit(BaseModel):
41
- answers: List[AnswerSubmit]
42
-
43
-
44
- class ViolationUpdate(BaseModel):
45
- tab_violations: Optional[int] = None
46
- fullscreen_violations: Optional[int] = None
47
-
48
-
49
- def _submitted_attempts_query(db: Session, user_id: int, test_id: int):
50
- return db.query(TestAttempt).filter(
51
- TestAttempt.user_id == user_id,
52
- TestAttempt.test_id == test_id,
53
- TestAttempt.status == TestStatus.submitted,
54
- )
55
-
56
-
57
- def _first_submitted_attempt(
58
- db: Session, user_id: int, test_id: int
59
- ) -> Optional[TestAttempt]:
60
- return (
61
- _submitted_attempts_query(db, user_id, test_id)
62
- .order_by(TestAttempt.id.asc())
63
- .first()
64
- )
65
-
66
-
67
- def _practice_counter(
68
- db: Session, user_id: int, test_id: int
69
- ) -> Optional[PracticeAttemptCounter]:
70
- return (
71
- db.query(PracticeAttemptCounter)
72
- .filter(
73
- PracticeAttemptCounter.user_id == user_id,
74
- PracticeAttemptCounter.test_id == test_id,
75
- )
76
- .first()
77
- )
78
-
79
-
80
- def _attempt_progress(
81
- db: Session,
82
- user_id: int,
83
- test_id: int,
84
- submitted_count: Optional[int] = None,
85
- ) -> dict:
86
- if submitted_count is None:
87
- submitted_count = _submitted_attempts_query(db, user_id, test_id).count()
88
-
89
- counter = _practice_counter(db, user_id, test_id)
90
- stored_practice_count = counter.count if counter else 0
91
- legacy_practice_count = max(submitted_count - 1, 0)
92
- practice_count = max(stored_practice_count, legacy_practice_count)
93
-
94
- return {
95
- "submitted_count": submitted_count,
96
- "practice_count": practice_count,
97
- "total_used": (1 if submitted_count > 0 else 0) + practice_count,
98
- }
99
-
100
-
101
- def _set_practice_count(
102
- db: Session, user_id: int, test_id: int, count: int
103
- ) -> PracticeAttemptCounter:
104
- counter = _practice_counter(db, user_id, test_id)
105
- if not counter:
106
- counter = PracticeAttemptCounter(user_id=user_id, test_id=test_id, count=0)
107
- db.add(counter)
108
-
109
- counter.count = max(counter.count or 0, count)
110
- counter.updated_at = datetime.now(timezone.utc)
111
- return counter
112
-
113
-
114
- def _has_previous_submission(
115
- db: Session, user_id: int, test_id: int, attempt_id: int
116
- ) -> bool:
117
- return (
118
- db.query(TestAttempt.id)
119
- .filter(
120
- TestAttempt.user_id == user_id,
121
- TestAttempt.test_id == test_id,
122
- TestAttempt.status == TestStatus.submitted,
123
- TestAttempt.id != attempt_id,
124
- )
125
- .first()
126
- is not None
127
- )
128
-
129
-
130
- # ── Tests ─────────────────────────────────────────────────────────
131
-
132
-
133
- @router.get("")
134
- def list_tests(db: Session = Depends(get_db), _=Depends(require_aspirant)):
135
- results = (
136
- db.query(Test, func.count(Question.id).label("question_count"))
137
- .outerjoin(Question, Test.id == Question.test_id)
138
- .filter(Test.is_published.is_(True))
139
- .group_by(Test.id)
140
- .order_by(Test.created_at.desc())
141
- .all()
142
- )
143
- return [
144
- {
145
- "id": test.id,
146
- "title": test.title,
147
- "description": test.description,
148
- "duration_minutes": test.duration_minutes,
149
- "total_marks": test.total_marks,
150
- "question_count": question_count,
151
- "series_id": test.series_id,
152
- "created_at": test.created_at,
153
- "category": test.category,
154
- "series_name": test.series_name,
155
- "test_type": test.test_type,
156
- "subject": test.subject,
157
- }
158
- for test, question_count in results
159
- ]
160
-
161
-
162
- @router.get("/{test_id}")
163
- def get_test(test_id: int, db: Session = Depends(get_db), _=Depends(require_aspirant)):
164
- test = db.query(Test).filter(Test.id == test_id).first()
165
- if not test:
166
- raise HTTPException(status_code=404, detail="Test not found")
167
- question_count = db.query(Question).filter(Question.test_id == test_id).count()
168
- return {
169
- "id": test.id,
170
- "title": test.title,
171
- "description": test.description,
172
- "duration_minutes": test.duration_minutes,
173
- "total_marks": test.total_marks,
174
- "question_count": question_count,
175
- "series_id": test.series_id,
176
- "created_at": test.created_at,
177
- }
178
-
179
 
180
- # ── Attempt ───────────────────────────────────────────────────────
181
 
182
 
183
  def _enforce_reattempt_limit(progress: dict) -> None:
@@ -256,20 +100,6 @@ def start_test(
256
  }
257
 
258
 
259
- def _attempt_out(a: TestAttempt) -> dict:
260
- return {
261
- "id": a.id,
262
- "test_id": a.test_id,
263
- "status": a.status,
264
- "started_at": a.started_at,
265
- "submitted_at": a.submitted_at,
266
- "score": a.score,
267
- "total_marks": a.total_marks,
268
- "tab_violations": a.tab_violations,
269
- "fullscreen_violations": a.fullscreen_violations,
270
- }
271
-
272
-
273
  @router.get("/{test_id}/attempt/{attempt_id}/questions")
274
  def get_questions(
275
  test_id: int,
@@ -388,154 +218,6 @@ def save_answers(
388
  return {"message": "Saved"}
389
 
390
 
391
- def _percentage(
392
- score: Optional[float], total_marks: Optional[float], digits: int = 2
393
- ) -> float:
394
- return round((score or 0) / total_marks * 100, digits) if total_marks else 0
395
-
396
-
397
- def _base_answer_detail(
398
- q: Question, selected_answer, is_correct, marks_awarded, time_spent_seconds
399
- ):
400
- return {
401
- "question_id": q.id,
402
- "question_text": q.question_text,
403
- "question_type": q.question_type,
404
- "question_image_url": optimize_delivery_image_url(q.question_image_url),
405
- "options": q.options,
406
- "option_images": optimize_delivery_image_urls(q.option_images),
407
- "correct_answer": q.correct_answer,
408
- "selected_answer": selected_answer,
409
- "is_correct": is_correct,
410
- "marks_awarded": marks_awarded,
411
- "marks": q.marks,
412
- "negative_marks": q.negative_marks,
413
- "time_spent_seconds": time_spent_seconds,
414
- "topper_answer": None,
415
- "topper_time_seconds": 0,
416
- }
417
-
418
-
419
- def _evaluate_submission(test: Test, answers: List[AnswerSubmit]):
420
- submitted_by_question = {ans.question_id: ans for ans in answers}
421
- earned = 0.0
422
- details = []
423
-
424
- # Test.questions relationship declares order_by="Question.order_index",
425
- # so the lazy-load already returns questions sorted by order_index.
426
- for q in test.questions:
427
- submitted = submitted_by_question.get(q.id)
428
- selected = submitted.selected_answer if submitted else None
429
- if selected is not None and selected.strip() == "":
430
- selected = None
431
- is_correct, marks = evaluate_answer(q, selected)
432
- earned += marks
433
- details.append(
434
- _base_answer_detail(
435
- q,
436
- selected,
437
- is_correct,
438
- marks,
439
- submitted.time_spent_seconds if submitted else 0,
440
- )
441
- )
442
-
443
- total_marks = sum(q.marks for q in test.questions)
444
- return max(0.0, round(earned, 2)), total_marks, details
445
-
446
-
447
- def _attempt_answer_details(test: Test, answers: list[UserAnswer]):
448
- answers_map = {a.question_id: a for a in answers}
449
- details = []
450
- for q in sorted(test.questions, key=lambda x: x.order_index):
451
- ua = answers_map.get(q.id)
452
- details.append(
453
- _base_answer_detail(
454
- q,
455
- ua.selected_answer if ua else None,
456
- ua.is_correct if ua else None,
457
- ua.marks_awarded if ua else 0,
458
- ua.time_spent_seconds if ua else 0,
459
- )
460
- )
461
- return details
462
-
463
-
464
- def _result_payload(
465
- *,
466
- attempt_id,
467
- attempt_number: int,
468
- attempts_remaining: int,
469
- counts_for_leaderboard: bool,
470
- persisted: bool,
471
- test: Test,
472
- score: float,
473
- total_marks: float,
474
- submitted_at: Optional[datetime],
475
- tab_violations: int,
476
- answer_details: list[dict],
477
- current_user,
478
- db: Session,
479
- client_result_id: Optional[str] = None,
480
- ) -> dict:
481
- first_attempts = _get_first_attempts(test.id, db)
482
-
483
- topper = first_attempts[0] if first_attempts else None
484
- topper_data = None
485
- topper_answers_map = {}
486
- if topper:
487
- topper_answers_map = {ua.question_id: ua for ua in topper.answers}
488
- topper_data = {
489
- "user_id": topper.user_id,
490
- "full_name": topper.user.full_name,
491
- "score": topper.score,
492
- "total_marks": topper.total_marks,
493
- "percentage": _percentage(topper.score, topper.total_marks),
494
- }
495
-
496
- correct = 0
497
- incorrect = 0
498
- skipped = 0
499
- for detail in answer_details:
500
- topper_ua = topper_answers_map.get(detail["question_id"])
501
- detail["topper_answer"] = topper_ua.selected_answer if topper_ua else None
502
- detail["topper_time_seconds"] = topper_ua.time_spent_seconds if topper_ua else 0
503
- if detail["is_correct"] is True:
504
- correct += 1
505
- elif detail["is_correct"] is False:
506
- incorrect += 1
507
- else:
508
- skipped += 1
509
- rank = next(
510
- (i + 1 for i, a in enumerate(first_attempts) if a.user_id == current_user.id),
511
- None,
512
- )
513
-
514
- return {
515
- "attempt_id": attempt_id,
516
- "client_result_id": client_result_id,
517
- "attempt_number": attempt_number,
518
- "attempts_remaining": attempts_remaining,
519
- "max_attempts": MAX_REATTEMPTS + 1,
520
- "counts_for_leaderboard": counts_for_leaderboard,
521
- "persisted": persisted,
522
- "test_id": test.id,
523
- "test_title": test.title,
524
- "score": score,
525
- "total_marks": total_marks,
526
- "percentage": _percentage(score, total_marks),
527
- "correct": correct,
528
- "incorrect": incorrect,
529
- "skipped": skipped,
530
- "submitted_at": submitted_at,
531
- "tab_violations": tab_violations,
532
- "rank": rank,
533
- "total_participants": len(first_attempts),
534
- "topper": topper_data,
535
- "answers": answer_details,
536
- }
537
-
538
-
539
  @router.post("/{test_id}/attempt/{attempt_id}/submit")
540
  def submit_test(
541
  test_id: int,
@@ -623,256 +305,3 @@ def submit_test(
623
  db.commit()
624
 
625
  return {"id": client_result_id, "persisted": False, "result": result}
626
-
627
-
628
- # ── Result ────────────────────────────────────────────────────────
629
-
630
-
631
- @router.get("/attempt/{attempt_id}/result")
632
- def get_result(
633
- attempt_id: int,
634
- db: Session = Depends(get_db),
635
- current_user=Depends(get_current_user),
636
- ):
637
- attempt = (
638
- db.query(TestAttempt)
639
- .filter(TestAttempt.id == attempt_id, TestAttempt.user_id == current_user.id)
640
- .first()
641
- )
642
- if not attempt or attempt.status != TestStatus.submitted:
643
- raise HTTPException(status_code=404, detail="Result not found")
644
-
645
- test = attempt.test
646
- all_user_attempts = (
647
- _submitted_attempts_query(db, current_user.id, test.id)
648
- .order_by(TestAttempt.id.asc())
649
- .all()
650
- )
651
- attempt_number = next(
652
- (i + 1 for i, a in enumerate(all_user_attempts) if a.id == attempt_id), 1
653
- )
654
- is_first = attempt_number == 1
655
-
656
- progress = _attempt_progress(db, current_user.id, test.id, len(all_user_attempts))
657
- total_marks = attempt.total_marks or sum(q.marks for q in test.questions)
658
-
659
- return _result_payload(
660
- attempt_id=attempt_id,
661
- client_result_id=None,
662
- attempt_number=attempt_number,
663
- attempts_remaining=max(0, MAX_REATTEMPTS + 1 - progress["total_used"]),
664
- counts_for_leaderboard=is_first,
665
- persisted=True,
666
- test=test,
667
- score=attempt.score or 0,
668
- total_marks=total_marks,
669
- submitted_at=attempt.submitted_at,
670
- tab_violations=attempt.tab_violations,
671
- answer_details=_attempt_answer_details(test, attempt.answers),
672
- current_user=current_user,
673
- db=db,
674
- )
675
-
676
-
677
- def _get_first_attempts(test_id: int, db: Session) -> list[TestAttempt]:
678
- if db.get_bind().dialect.name == "postgresql":
679
- return _first_attempts_pg(test_id, db)
680
- return _first_attempts_generic(test_id, db)
681
-
682
-
683
- def _first_attempts_pg(test_id: int, db: Session) -> list[TestAttempt]:
684
- """Fetch first attempts using PostgreSQL DISTINCT ON."""
685
- first_attempts_subquery = (
686
- db.query(TestAttempt)
687
- .filter(
688
- TestAttempt.test_id == test_id,
689
- TestAttempt.status == TestStatus.submitted,
690
- )
691
- .distinct(TestAttempt.user_id)
692
- .order_by(TestAttempt.user_id, TestAttempt.id.asc())
693
- .subquery()
694
- )
695
-
696
- FirstAttempt = aliased(TestAttempt, first_attempts_subquery)
697
-
698
- return (
699
- db.query(FirstAttempt)
700
- .options(joinedload(FirstAttempt.user))
701
- .order_by(
702
- FirstAttempt.score.desc(),
703
- FirstAttempt.id.asc(),
704
- )
705
- .all()
706
- )
707
-
708
-
709
- def _first_attempts_generic(test_id: int, db: Session) -> list[TestAttempt]:
710
- """Fetch first attempts using ROW_NUMBER() for non-PostgreSQL dialects."""
711
- first_attempt_ids_subquery = (
712
- db.query(
713
- TestAttempt.id.label("attempt_id"),
714
- func.row_number()
715
- .over(
716
- partition_by=TestAttempt.user_id,
717
- order_by=TestAttempt.id.asc(),
718
- )
719
- .label("attempt_rank"),
720
- )
721
- .filter(
722
- TestAttempt.test_id == test_id,
723
- TestAttempt.status == TestStatus.submitted,
724
- )
725
- .subquery()
726
- )
727
-
728
- return (
729
- db.query(TestAttempt)
730
- .options(joinedload(TestAttempt.user))
731
- .join(
732
- first_attempt_ids_subquery,
733
- TestAttempt.id == first_attempt_ids_subquery.c.attempt_id,
734
- )
735
- .filter(first_attempt_ids_subquery.c.attempt_rank == 1)
736
- .order_by(
737
- TestAttempt.score.desc(),
738
- TestAttempt.id.asc(),
739
- )
740
- .all()
741
- )
742
-
743
-
744
- # ── Leaderboard ───────────────────────────────────────────────────
745
-
746
-
747
- def _leaderboard_cache_key_builder(
748
- func, namespace="", *, request=None, response=None, args=None, kwargs=None
749
- ):
750
- args = args or ()
751
- kwargs = kwargs or {}
752
-
753
- test_id = kwargs.get("test_id")
754
- current_user = kwargs.get("current_user")
755
-
756
- if test_id is None and request is not None:
757
- test_id = request.path_params.get("test_id")
758
- if test_id is None and args:
759
- test_id = args[0]
760
- if current_user is None and len(args) >= 3:
761
- current_user = args[2]
762
-
763
- user_id = getattr(current_user, "id", "unknown")
764
- return (
765
- f"{namespace}:{func.__module__}:{func.__name__}:test:{test_id}:user:{user_id}"
766
- )
767
-
768
-
769
- @router.get("/{test_id}/leaderboard")
770
- @cache(expire=60, key_builder=_leaderboard_cache_key_builder)
771
- def get_leaderboard(
772
- test_id: int, db: Session = Depends(get_db), current_user=Depends(require_aspirant)
773
- ):
774
- test = db.query(Test).filter(Test.id == test_id).first()
775
- if not test:
776
- raise HTTPException(status_code=404, detail="Test not found")
777
-
778
- first_attempts = _get_first_attempts(test_id, db)
779
-
780
- leaderboard = []
781
- current_user_rank = None
782
- for rank, attempt in enumerate(first_attempts, 1):
783
- score = attempt.score or 0.0
784
- pct = round(score / attempt.total_marks * 100, 1) if attempt.total_marks else 0
785
- if attempt.user_id == current_user.id:
786
- current_user_rank = rank
787
- leaderboard.append(
788
- {
789
- "rank": rank,
790
- "user_id": attempt.user_id,
791
- "full_name": attempt.user.full_name,
792
- "score": attempt.score,
793
- "total_marks": attempt.total_marks,
794
- "percentage": pct,
795
- "submitted_at": attempt.submitted_at,
796
- "tab_violations": attempt.tab_violations,
797
- "is_current_user": attempt.user_id == current_user.id,
798
- }
799
- )
800
-
801
- return {
802
- "test_id": test_id,
803
- "test_title": test.title,
804
- "total_participants": len(leaderboard),
805
- "current_user_rank": current_user_rank,
806
- "leaderboard": leaderboard,
807
- }
808
-
809
-
810
- # ── My attempts ───────────────────────────────────────────────────
811
-
812
-
813
- @router.get("/{test_id}/my-attempts")
814
- def my_attempts(
815
- test_id: int, db: Session = Depends(get_db), current_user=Depends(get_current_user)
816
- ):
817
- first_attempt = _first_submitted_attempt(db, current_user.id, test_id)
818
- if not first_attempt:
819
- return []
820
-
821
- submitted_count = _submitted_attempts_query(db, current_user.id, test_id).count()
822
- progress = _attempt_progress(db, current_user.id, test_id, submitted_count)
823
-
824
- return [
825
- {
826
- "attempt_id": first_attempt.id,
827
- "attempt_number": 1,
828
- "counts_for_leaderboard": True,
829
- "is_first": True,
830
- "status": first_attempt.status,
831
- "score": first_attempt.score,
832
- "total_marks": first_attempt.total_marks,
833
- "percentage": _percentage(
834
- first_attempt.score, first_attempt.total_marks, 1
835
- ),
836
- "started_at": first_attempt.started_at,
837
- "submitted_at": first_attempt.submitted_at,
838
- "attempts_remaining": max(0, MAX_REATTEMPTS + 1 - progress["total_used"]),
839
- }
840
- ]
841
-
842
-
843
- @router.get("/my/history")
844
- def my_history(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
845
- attempts = (
846
- db.query(TestAttempt)
847
- .filter(
848
- TestAttempt.user_id == current_user.id,
849
- TestAttempt.status == TestStatus.submitted,
850
- )
851
- .order_by(TestAttempt.id.asc())
852
- .all()
853
- )
854
-
855
- first_by_test: dict[int, TestAttempt] = {}
856
- for attempt in attempts:
857
- first_by_test.setdefault(attempt.test_id, attempt)
858
-
859
- first_attempts = sorted(
860
- first_by_test.values(),
861
- key=lambda a: a.started_at or a.submitted_at or datetime.min,
862
- reverse=True,
863
- )
864
-
865
- return [
866
- {
867
- "id": a.id,
868
- "test_id": a.test_id,
869
- "status": a.status,
870
- "started_at": a.started_at,
871
- "submitted_at": a.submitted_at,
872
- "score": a.score,
873
- "total_marks": a.total_marks,
874
- "tab_violations": a.tab_violations,
875
- "fullscreen_violations": a.fullscreen_violations,
876
- }
877
- for a in first_attempts
878
- ]
 
1
  from datetime import datetime, timezone
 
2
 
3
  from fastapi import APIRouter, Depends, HTTPException
4
+ from sqlalchemy.orm import Session
 
 
 
5
 
6
+ from app.api.deps import require_aspirant
7
  from app.core.database import get_db
8
+ from app.models.models import Question, Test, TestAttempt, TestStatus, UserAnswer
 
 
 
 
 
 
 
9
  from app.services.cloudinary_service import (
10
  optimize_delivery_image_url,
11
  optimize_delivery_image_urls,
12
  )
13
+ from app.api.routes.tests.helpers import (
14
+ MAX_REATTEMPTS,
15
+ _attempt_out,
16
+ _attempt_progress,
17
+ _has_previous_submission,
18
+ _set_practice_count,
19
+ _submitted_attempts_query,
20
+ )
21
+ from app.api.routes.tests.schemas import BulkAnswerSubmit, ViolationUpdate
22
+ from app.api.routes.tests.result_builder import _evaluate_submission, _result_payload
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
+ router = APIRouter()
25
 
26
 
27
  def _enforce_reattempt_limit(progress: dict) -> None:
 
100
  }
101
 
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  @router.get("/{test_id}/attempt/{attempt_id}/questions")
104
  def get_questions(
105
  test_id: int,
 
218
  return {"message": "Saved"}
219
 
220
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  @router.post("/{test_id}/attempt/{attempt_id}/submit")
222
  def submit_test(
223
  test_id: int,
 
305
  db.commit()
306
 
307
  return {"id": client_result_id, "persisted": False, "result": result}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/api/routes/tests/catalog.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from sqlalchemy import func
3
+ from sqlalchemy.orm import Session
4
+
5
+ from app.api.deps import require_aspirant
6
+ from app.core.database import get_db
7
+ from app.models.models import Question, Test
8
+
9
+ router = APIRouter()
10
+
11
+
12
+ @router.get("/")
13
+ def list_tests(db: Session = Depends(get_db), _=Depends(require_aspirant)):
14
+ results = (
15
+ db.query(Test, func.count(Question.id).label("question_count"))
16
+ .outerjoin(Question, Test.id == Question.test_id)
17
+ .filter(Test.is_published.is_(True))
18
+ .group_by(Test.id)
19
+ .order_by(Test.created_at.desc())
20
+ .all()
21
+ )
22
+ return [
23
+ {
24
+ "id": test.id,
25
+ "title": test.title,
26
+ "description": test.description,
27
+ "duration_minutes": test.duration_minutes,
28
+ "total_marks": test.total_marks,
29
+ "question_count": question_count,
30
+ "series_id": test.series_id,
31
+ "created_at": test.created_at,
32
+ "category": test.category,
33
+ "series_name": test.series_name,
34
+ "test_type": test.test_type,
35
+ "subject": test.subject,
36
+ }
37
+ for test, question_count in results
38
+ ]
39
+
40
+
41
+ @router.get("/{test_id}")
42
+ def get_test(test_id: int, db: Session = Depends(get_db), _=Depends(require_aspirant)):
43
+ test = db.query(Test).filter(Test.id == test_id).first()
44
+ if not test:
45
+ raise HTTPException(status_code=404, detail="Test not found")
46
+ question_count = db.query(Question).filter(Question.test_id == test_id).count()
47
+ return {
48
+ "id": test.id,
49
+ "title": test.title,
50
+ "description": test.description,
51
+ "duration_minutes": test.duration_minutes,
52
+ "total_marks": test.total_marks,
53
+ "question_count": question_count,
54
+ "series_id": test.series_id,
55
+ "created_at": test.created_at,
56
+ }
backend/app/api/routes/tests/helpers.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+ from typing import Optional
3
+
4
+ from sqlalchemy import func
5
+ from sqlalchemy.orm import Session, aliased, joinedload
6
+
7
+ from app.models.models import (
8
+ PracticeAttemptCounter,
9
+ TestAttempt,
10
+ TestStatus,
11
+ )
12
+
13
+ MAX_REATTEMPTS = 5 # max reattempts per test (6 total including first)
14
+
15
+
16
+ def _submitted_attempts_query(db: Session, user_id: int, test_id: int):
17
+ return db.query(TestAttempt).filter(
18
+ TestAttempt.user_id == user_id,
19
+ TestAttempt.test_id == test_id,
20
+ TestAttempt.status == TestStatus.submitted,
21
+ )
22
+
23
+
24
+ def _first_submitted_attempt(
25
+ db: Session, user_id: int, test_id: int
26
+ ) -> Optional[TestAttempt]:
27
+ return (
28
+ _submitted_attempts_query(db, user_id, test_id)
29
+ .order_by(TestAttempt.id.asc())
30
+ .first()
31
+ )
32
+
33
+
34
+ def _practice_counter(
35
+ db: Session, user_id: int, test_id: int
36
+ ) -> Optional[PracticeAttemptCounter]:
37
+ return (
38
+ db.query(PracticeAttemptCounter)
39
+ .filter(
40
+ PracticeAttemptCounter.user_id == user_id,
41
+ PracticeAttemptCounter.test_id == test_id,
42
+ )
43
+ .first()
44
+ )
45
+
46
+
47
+ def _attempt_progress(
48
+ db: Session,
49
+ user_id: int,
50
+ test_id: int,
51
+ submitted_count: Optional[int] = None,
52
+ ) -> dict:
53
+ if submitted_count is None:
54
+ submitted_count = _submitted_attempts_query(db, user_id, test_id).count()
55
+
56
+ counter = _practice_counter(db, user_id, test_id)
57
+ stored_practice_count = counter.count if counter else 0
58
+ legacy_practice_count = max(submitted_count - 1, 0)
59
+ practice_count = max(stored_practice_count, legacy_practice_count)
60
+
61
+ return {
62
+ "submitted_count": submitted_count,
63
+ "practice_count": practice_count,
64
+ "total_used": (1 if submitted_count > 0 else 0) + practice_count,
65
+ }
66
+
67
+
68
+ def _set_practice_count(
69
+ db: Session, user_id: int, test_id: int, count: int
70
+ ) -> PracticeAttemptCounter:
71
+ counter = _practice_counter(db, user_id, test_id)
72
+ if not counter:
73
+ counter = PracticeAttemptCounter(user_id=user_id, test_id=test_id, count=0)
74
+ db.add(counter)
75
+
76
+ counter.count = max(counter.count or 0, count)
77
+ counter.updated_at = datetime.now(timezone.utc)
78
+ return counter
79
+
80
+
81
+ def _has_previous_submission(
82
+ db: Session, user_id: int, test_id: int, attempt_id: int
83
+ ) -> bool:
84
+ return (
85
+ db.query(TestAttempt.id)
86
+ .filter(
87
+ TestAttempt.user_id == user_id,
88
+ TestAttempt.test_id == test_id,
89
+ TestAttempt.status == TestStatus.submitted,
90
+ TestAttempt.id != attempt_id,
91
+ )
92
+ .first()
93
+ is not None
94
+ )
95
+
96
+
97
+ def _attempt_out(a: TestAttempt) -> dict:
98
+ return {
99
+ "id": a.id,
100
+ "test_id": a.test_id,
101
+ "status": a.status,
102
+ "started_at": a.started_at,
103
+ "submitted_at": a.submitted_at,
104
+ "score": a.score,
105
+ "total_marks": a.total_marks,
106
+ "tab_violations": a.tab_violations,
107
+ "fullscreen_violations": a.fullscreen_violations,
108
+ }
109
+
110
+
111
+ def _percentage(
112
+ score: Optional[float], total_marks: Optional[float], digits: int = 2
113
+ ) -> float:
114
+ return round((score or 0) / total_marks * 100, digits) if total_marks else 0
115
+
116
+
117
+ def _get_first_attempts(test_id: int, db: Session) -> list[TestAttempt]:
118
+ if db.get_bind().dialect.name == "postgresql":
119
+ return _first_attempts_pg(test_id, db)
120
+ return _first_attempts_generic(test_id, db)
121
+
122
+
123
+ def _first_attempts_pg(test_id: int, db: Session) -> list[TestAttempt]:
124
+ """Fetch first attempts using PostgreSQL DISTINCT ON."""
125
+ first_attempts_subquery = (
126
+ db.query(TestAttempt)
127
+ .filter(
128
+ TestAttempt.test_id == test_id,
129
+ TestAttempt.status == TestStatus.submitted,
130
+ )
131
+ .distinct(TestAttempt.user_id)
132
+ .order_by(TestAttempt.user_id, TestAttempt.id.asc())
133
+ .subquery()
134
+ )
135
+
136
+ FirstAttempt = aliased(TestAttempt, first_attempts_subquery)
137
+
138
+ return (
139
+ db.query(FirstAttempt)
140
+ .options(joinedload(FirstAttempt.user))
141
+ .order_by(
142
+ FirstAttempt.score.desc(),
143
+ FirstAttempt.id.asc(),
144
+ )
145
+ .all()
146
+ )
147
+
148
+
149
+ def _first_attempts_generic(test_id: int, db: Session) -> list[TestAttempt]:
150
+ """Fetch first attempts using ROW_NUMBER() for non-PostgreSQL dialects."""
151
+ first_attempt_ids_subquery = (
152
+ db.query(
153
+ TestAttempt.id.label("attempt_id"),
154
+ func.row_number()
155
+ .over(
156
+ partition_by=TestAttempt.user_id,
157
+ order_by=TestAttempt.id.asc(),
158
+ )
159
+ .label("attempt_rank"),
160
+ )
161
+ .filter(
162
+ TestAttempt.test_id == test_id,
163
+ TestAttempt.status == TestStatus.submitted,
164
+ )
165
+ .subquery()
166
+ )
167
+
168
+ return (
169
+ db.query(TestAttempt)
170
+ .options(joinedload(TestAttempt.user))
171
+ .join(
172
+ first_attempt_ids_subquery,
173
+ TestAttempt.id == first_attempt_ids_subquery.c.attempt_id,
174
+ )
175
+ .filter(first_attempt_ids_subquery.c.attempt_rank == 1)
176
+ .order_by(
177
+ TestAttempt.score.desc(),
178
+ TestAttempt.id.asc(),
179
+ )
180
+ .all()
181
+ )
backend/app/api/routes/tests/history.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from fastapi import APIRouter, Depends
3
+ from sqlalchemy.orm import Session
4
+
5
+ from app.api.deps import get_current_user
6
+ from app.core.database import get_db
7
+ from app.models.models import TestAttempt, TestStatus
8
+ from app.api.routes.tests.helpers import (
9
+ MAX_REATTEMPTS,
10
+ _attempt_progress,
11
+ _first_submitted_attempt,
12
+ _percentage,
13
+ _submitted_attempts_query,
14
+ )
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ @router.get("/{test_id}/my-attempts")
20
+ def my_attempts(
21
+ test_id: int, db: Session = Depends(get_db), current_user=Depends(get_current_user)
22
+ ):
23
+ first_attempt = _first_submitted_attempt(db, current_user.id, test_id)
24
+ if not first_attempt:
25
+ return []
26
+
27
+ submitted_count = _submitted_attempts_query(db, current_user.id, test_id).count()
28
+ progress = _attempt_progress(db, current_user.id, test_id, submitted_count)
29
+
30
+ return [
31
+ {
32
+ "attempt_id": first_attempt.id,
33
+ "attempt_number": 1,
34
+ "counts_for_leaderboard": True,
35
+ "is_first": True,
36
+ "status": first_attempt.status,
37
+ "score": first_attempt.score,
38
+ "total_marks": first_attempt.total_marks,
39
+ "percentage": _percentage(
40
+ first_attempt.score, first_attempt.total_marks, 1
41
+ ),
42
+ "started_at": first_attempt.started_at,
43
+ "submitted_at": first_attempt.submitted_at,
44
+ "attempts_remaining": max(0, MAX_REATTEMPTS + 1 - progress["total_used"]),
45
+ }
46
+ ]
47
+
48
+
49
+ @router.get("/my/history")
50
+ def my_history(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
51
+ attempts = (
52
+ db.query(TestAttempt)
53
+ .filter(
54
+ TestAttempt.user_id == current_user.id,
55
+ TestAttempt.status == TestStatus.submitted,
56
+ )
57
+ .order_by(TestAttempt.id.asc())
58
+ .all()
59
+ )
60
+
61
+ first_by_test: dict[int, TestAttempt] = {}
62
+ for attempt in attempts:
63
+ first_by_test.setdefault(attempt.test_id, attempt)
64
+
65
+ first_attempts = sorted(
66
+ first_by_test.values(),
67
+ key=lambda a: a.started_at or a.submitted_at or datetime.min,
68
+ reverse=True,
69
+ )
70
+
71
+ return [
72
+ {
73
+ "id": a.id,
74
+ "test_id": a.test_id,
75
+ "status": a.status,
76
+ "started_at": a.started_at,
77
+ "submitted_at": a.submitted_at,
78
+ "score": a.score,
79
+ "total_marks": a.total_marks,
80
+ "tab_violations": a.tab_violations,
81
+ "fullscreen_violations": a.fullscreen_violations,
82
+ }
83
+ for a in first_attempts
84
+ ]
backend/app/api/routes/tests/leaderboard.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from fastapi_cache.decorator import cache
3
+ from sqlalchemy.orm import Session
4
+
5
+ from app.api.deps import require_aspirant
6
+ from app.core.database import get_db
7
+ from app.models.models import Test
8
+ from app.api.routes.tests.helpers import _get_first_attempts
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ def _leaderboard_cache_key_builder(
14
+ func, namespace="", *, request=None, response=None, args=None, kwargs=None
15
+ ):
16
+ args = args or ()
17
+ kwargs = kwargs or {}
18
+
19
+ test_id = kwargs.get("test_id")
20
+ current_user = kwargs.get("current_user")
21
+
22
+ if test_id is None and request is not None:
23
+ test_id = request.path_params.get("test_id")
24
+ if test_id is None and args:
25
+ test_id = args[0]
26
+ if current_user is None and len(args) >= 3:
27
+ current_user = args[2]
28
+
29
+ user_id = getattr(current_user, "id", "unknown")
30
+ return (
31
+ f"{namespace}:{func.__module__}:{func.__name__}:test:{test_id}:user:{user_id}"
32
+ )
33
+
34
+
35
+ @router.get("/{test_id}/leaderboard")
36
+ @cache(expire=60, key_builder=_leaderboard_cache_key_builder)
37
+ def get_leaderboard(
38
+ test_id: int, db: Session = Depends(get_db), current_user=Depends(require_aspirant)
39
+ ):
40
+ test = db.query(Test).filter(Test.id == test_id).first()
41
+ if not test:
42
+ raise HTTPException(status_code=404, detail="Test not found")
43
+
44
+ first_attempts = _get_first_attempts(test_id, db)
45
+
46
+ leaderboard = []
47
+ current_user_rank = None
48
+ for rank, attempt in enumerate(first_attempts, 1):
49
+ score = attempt.score or 0.0
50
+ pct = round(score / attempt.total_marks * 100, 1) if attempt.total_marks else 0
51
+ if attempt.user_id == current_user.id:
52
+ current_user_rank = rank
53
+ leaderboard.append(
54
+ {
55
+ "rank": rank,
56
+ "user_id": attempt.user_id,
57
+ "full_name": attempt.user.full_name,
58
+ "score": attempt.score,
59
+ "total_marks": attempt.total_marks,
60
+ "percentage": pct,
61
+ "submitted_at": attempt.submitted_at,
62
+ "tab_violations": attempt.tab_violations,
63
+ "is_current_user": attempt.user_id == current_user.id,
64
+ }
65
+ )
66
+
67
+ return {
68
+ "test_id": test_id,
69
+ "test_title": test.title,
70
+ "total_participants": len(leaderboard),
71
+ "current_user_rank": current_user_rank,
72
+ "leaderboard": leaderboard,
73
+ }
backend/app/api/routes/tests/result_builder.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import List, Optional
3
+
4
+ from sqlalchemy.orm import Session
5
+
6
+ from app.models.models import Question, Test, UserAnswer
7
+ from app.services.cloudinary_service import (
8
+ optimize_delivery_image_url,
9
+ optimize_delivery_image_urls,
10
+ )
11
+ from app.services.scoring import evaluate_answer
12
+ from app.api.routes.tests.helpers import (
13
+ MAX_REATTEMPTS,
14
+ _get_first_attempts,
15
+ _percentage,
16
+ )
17
+ from app.api.routes.tests.schemas import AnswerSubmit
18
+
19
+
20
+ def _base_answer_detail(
21
+ q: Question, selected_answer, is_correct, marks_awarded, time_spent_seconds
22
+ ):
23
+ return {
24
+ "question_id": q.id,
25
+ "question_text": q.question_text,
26
+ "question_type": q.question_type,
27
+ "question_image_url": optimize_delivery_image_url(q.question_image_url),
28
+ "options": q.options,
29
+ "option_images": optimize_delivery_image_urls(q.option_images),
30
+ "correct_answer": q.correct_answer,
31
+ "selected_answer": selected_answer,
32
+ "is_correct": is_correct,
33
+ "marks_awarded": marks_awarded,
34
+ "marks": q.marks,
35
+ "negative_marks": q.negative_marks,
36
+ "time_spent_seconds": time_spent_seconds,
37
+ "topper_answer": None,
38
+ "topper_time_seconds": 0,
39
+ }
40
+
41
+
42
+ def _evaluate_submission(test: Test, answers: List[AnswerSubmit]):
43
+ submitted_by_question = {ans.question_id: ans for ans in answers}
44
+ earned = 0.0
45
+ details = []
46
+
47
+ # Test.questions relationship declares order_by="Question.order_index",
48
+ # so the lazy-load already returns questions sorted by order_index.
49
+ for q in test.questions:
50
+ submitted = submitted_by_question.get(q.id)
51
+ selected = submitted.selected_answer if submitted else None
52
+ if selected is not None and selected.strip() == "":
53
+ selected = None
54
+ is_correct, marks = evaluate_answer(q, selected)
55
+ earned += marks
56
+ details.append(
57
+ _base_answer_detail(
58
+ q,
59
+ selected,
60
+ is_correct,
61
+ marks,
62
+ submitted.time_spent_seconds if submitted else 0,
63
+ )
64
+ )
65
+
66
+ total_marks = sum(q.marks for q in test.questions)
67
+ return max(0.0, round(earned, 2)), total_marks, details
68
+
69
+
70
+ def _attempt_answer_details(test: Test, answers: list[UserAnswer]):
71
+ answers_map = {a.question_id: a for a in answers}
72
+ details = []
73
+ for q in sorted(test.questions, key=lambda x: x.order_index):
74
+ ua = answers_map.get(q.id)
75
+ details.append(
76
+ _base_answer_detail(
77
+ q,
78
+ ua.selected_answer if ua else None,
79
+ ua.is_correct if ua else None,
80
+ ua.marks_awarded if ua else 0,
81
+ ua.time_spent_seconds if ua else 0,
82
+ )
83
+ )
84
+ return details
85
+
86
+
87
+ def _result_payload(
88
+ *,
89
+ attempt_id,
90
+ attempt_number: int,
91
+ attempts_remaining: int,
92
+ counts_for_leaderboard: bool,
93
+ persisted: bool,
94
+ test: Test,
95
+ score: float,
96
+ total_marks: float,
97
+ submitted_at: Optional[datetime],
98
+ tab_violations: int,
99
+ answer_details: list[dict],
100
+ current_user,
101
+ db: Session,
102
+ client_result_id: Optional[str] = None,
103
+ ) -> dict:
104
+ first_attempts = _get_first_attempts(test.id, db)
105
+
106
+ topper = first_attempts[0] if first_attempts else None
107
+ topper_data = None
108
+ topper_answers_map = {}
109
+ if topper:
110
+ topper_answers_map = {ua.question_id: ua for ua in topper.answers}
111
+ topper_data = {
112
+ "user_id": topper.user_id,
113
+ "full_name": topper.user.full_name,
114
+ "score": topper.score,
115
+ "total_marks": topper.total_marks,
116
+ "percentage": _percentage(topper.score, topper.total_marks),
117
+ }
118
+
119
+ correct = 0
120
+ incorrect = 0
121
+ skipped = 0
122
+ for detail in answer_details:
123
+ topper_ua = topper_answers_map.get(detail["question_id"])
124
+ detail["topper_answer"] = topper_ua.selected_answer if topper_ua else None
125
+ detail["topper_time_seconds"] = topper_ua.time_spent_seconds if topper_ua else 0
126
+ if detail["is_correct"] is True:
127
+ correct += 1
128
+ elif detail["is_correct"] is False:
129
+ incorrect += 1
130
+ else:
131
+ skipped += 1
132
+ rank = next(
133
+ (i + 1 for i, a in enumerate(first_attempts) if a.user_id == current_user.id),
134
+ None,
135
+ )
136
+
137
+ return {
138
+ "attempt_id": attempt_id,
139
+ "client_result_id": client_result_id,
140
+ "attempt_number": attempt_number,
141
+ "attempts_remaining": attempts_remaining,
142
+ "max_attempts": MAX_REATTEMPTS + 1,
143
+ "counts_for_leaderboard": counts_for_leaderboard,
144
+ "persisted": persisted,
145
+ "test_id": test.id,
146
+ "test_title": test.title,
147
+ "score": score,
148
+ "total_marks": total_marks,
149
+ "percentage": _percentage(score, total_marks),
150
+ "correct": correct,
151
+ "incorrect": incorrect,
152
+ "skipped": skipped,
153
+ "submitted_at": submitted_at,
154
+ "tab_violations": tab_violations,
155
+ "rank": rank,
156
+ "total_participants": len(first_attempts),
157
+ "topper": topper_data,
158
+ "answers": answer_details,
159
+ }
backend/app/api/routes/tests/results.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from sqlalchemy.orm import Session
3
+
4
+ from app.api.deps import get_current_user
5
+ from app.core.database import get_db
6
+ from app.models.models import TestAttempt, TestStatus
7
+ from app.api.routes.tests.helpers import (
8
+ MAX_REATTEMPTS,
9
+ _attempt_progress,
10
+ _submitted_attempts_query,
11
+ )
12
+ from app.api.routes.tests.result_builder import _attempt_answer_details, _result_payload
13
+
14
+ router = APIRouter()
15
+
16
+
17
+ @router.get("/attempt/{attempt_id}/result")
18
+ def get_result(
19
+ attempt_id: int,
20
+ db: Session = Depends(get_db),
21
+ current_user=Depends(get_current_user),
22
+ ):
23
+ attempt = (
24
+ db.query(TestAttempt)
25
+ .filter(TestAttempt.id == attempt_id, TestAttempt.user_id == current_user.id)
26
+ .first()
27
+ )
28
+ if not attempt or attempt.status != TestStatus.submitted:
29
+ raise HTTPException(status_code=404, detail="Result not found")
30
+
31
+ test = attempt.test
32
+ all_user_attempts = (
33
+ _submitted_attempts_query(db, current_user.id, test.id)
34
+ .order_by(TestAttempt.id.asc())
35
+ .all()
36
+ )
37
+ attempt_number = next(
38
+ (i + 1 for i, a in enumerate(all_user_attempts) if a.id == attempt_id), 1
39
+ )
40
+ is_first = attempt_number == 1
41
+
42
+ progress = _attempt_progress(db, current_user.id, test.id, len(all_user_attempts))
43
+ total_marks = attempt.total_marks or sum(q.marks for q in test.questions)
44
+
45
+ return _result_payload(
46
+ attempt_id=attempt_id,
47
+ client_result_id=None,
48
+ attempt_number=attempt_number,
49
+ attempts_remaining=max(0, MAX_REATTEMPTS + 1 - progress["total_used"]),
50
+ counts_for_leaderboard=is_first,
51
+ persisted=True,
52
+ test=test,
53
+ score=attempt.score or 0,
54
+ total_marks=total_marks,
55
+ submitted_at=attempt.submitted_at,
56
+ tab_violations=attempt.tab_violations,
57
+ answer_details=_attempt_answer_details(test, attempt.answers),
58
+ current_user=current_user,
59
+ db=db,
60
+ )
backend/app/api/routes/tests/schemas.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class AnswerSubmit(BaseModel):
7
+ question_id: int
8
+ selected_answer: Optional[str] = None
9
+ time_spent_seconds: int = 0
10
+
11
+
12
+ class BulkAnswerSubmit(BaseModel):
13
+ answers: List[AnswerSubmit]
14
+
15
+
16
+ class ViolationUpdate(BaseModel):
17
+ tab_violations: Optional[int] = None
18
+ fullscreen_violations: Optional[int] = None