-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdb.py
More file actions
2360 lines (2074 loc) · 107 KB
/
db.py
File metadata and controls
2360 lines (2074 loc) · 107 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Database management for AI-based Exam System
Handles all database operations and data persistence
"""
import sqlite3
import json
import uuid
import secrets
from datetime import datetime
from typing import Dict, List, Optional
import os
class ExamDatabase:
def __init__(self, db_path: str = "exam_system.db"):
"""Initialize the database connection and create tables if they don't exist"""
self.db_path = db_path
self.init_database()
self._enable_wal_mode()
def _enable_wal_mode(self):
"""Enable WAL (Write-Ahead Logging) mode for better concurrent access.
WAL mode provides:
- Better concurrent read/write performance
- Readers don't block writers and vice versa
- Better crash recovery
- Improved performance for web applications
"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
result = cursor.fetchone()
if result and result[0].lower() == 'wal':
print("✅ SQLite WAL mode enabled for better concurrency")
else:
print(f"⚠️ SQLite journal mode: {result[0] if result else 'unknown'}")
except sqlite3.Error as e:
print(f"⚠️ Could not enable WAL mode: {e}")
def init_database(self):
"""Create database tables if they don't exist"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# Create exams table
cursor.execute('''
CREATE TABLE IF NOT EXISTS exams (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
department TEXT NOT NULL,
position TEXT NOT NULL,
description TEXT,
time_limit INTEGER NOT NULL,
instructions TEXT,
question_structure TEXT,
sections_structure TEXT,
negative_marking_config TEXT,
exam_language TEXT DEFAULT 'english',
exam_link TEXT UNIQUE,
is_finalized BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
show_feedback BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Database migrations - add missing columns
self._add_column_if_not_exists(cursor, 'exams', 'sections_structure', 'TEXT')
self._add_column_if_not_exists(cursor, 'exams', 'is_active', 'BOOLEAN DEFAULT TRUE')
self._add_column_if_not_exists(cursor, 'exams', 'show_feedback', 'BOOLEAN DEFAULT TRUE')
self._add_column_if_not_exists(cursor, 'exams', 'negative_marking_config', 'TEXT')
self._add_column_if_not_exists(cursor, 'exams', 'exam_language', 'TEXT DEFAULT "english"')
self._add_column_if_not_exists(cursor, 'exams', 'evaluation_paused', 'BOOLEAN DEFAULT FALSE')
self._add_column_if_not_exists(cursor, 'exams', 'multi_select_scoring_mode', 'TEXT DEFAULT "partial"')
# Create questions table with image support
cursor.execute('''
CREATE TABLE IF NOT EXISTS questions (
id TEXT PRIMARY KEY,
exam_id TEXT NOT NULL,
section_type TEXT NOT NULL,
question_type TEXT NOT NULL,
question_text TEXT NOT NULL,
options TEXT,
correct_answer INTEGER,
expected_answer TEXT,
evaluation_criteria TEXT,
marks INTEGER NOT NULL,
question_order INTEGER,
explanation TEXT,
image_url TEXT,
image_caption TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (exam_id) REFERENCES exams (id)
)
''')
# Add image columns if they don't exist
self._add_column_if_not_exists(cursor, 'questions', 'section_type', 'TEXT DEFAULT "technical"')
self._add_column_if_not_exists(cursor, 'questions', 'image_url', 'TEXT')
self._add_column_if_not_exists(cursor, 'questions', 'image_caption', 'TEXT')
# Add multi-select MCQ support
# is_multi_select: Boolean flag for multi-select MCQs
# correct_answers: JSON array of correct answer indices for multi-select (e.g., "[0, 2]")
self._add_column_if_not_exists(cursor, 'questions', 'is_multi_select', 'BOOLEAN DEFAULT FALSE')
self._add_column_if_not_exists(cursor, 'questions', 'correct_answers', 'TEXT')
# Create question_images table for multiple images per question
cursor.execute('''
CREATE TABLE IF NOT EXISTS question_images (
id TEXT PRIMARY KEY,
question_id TEXT NOT NULL,
image_url TEXT NOT NULL,
image_caption TEXT,
image_order INTEGER DEFAULT 0,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (question_id) REFERENCES questions (id) ON DELETE CASCADE
)
''')
# Create live_sessions table
cursor.execute('''
CREATE TABLE IF NOT EXISTS live_sessions (
session_id TEXT PRIMARY KEY,
exam_id TEXT NOT NULL,
candidate_name TEXT NOT NULL,
candidate_id TEXT NOT NULL,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_activity TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE,
FOREIGN KEY (exam_id) REFERENCES exams (id)
)
''')
# Create exam_results table
cursor.execute('''
CREATE TABLE IF NOT EXISTS exam_results (
id TEXT PRIMARY KEY,
session_id TEXT UNIQUE NOT NULL,
exam_id TEXT NOT NULL,
candidate_name TEXT NOT NULL,
candidate_id TEXT NOT NULL,
total_marks INTEGER NOT NULL,
obtained_marks REAL NOT NULL,
negative_marks REAL DEFAULT 0,
percentage REAL NOT NULL,
performance_level TEXT NOT NULL,
time_taken TEXT,
has_feedback BOOLEAN DEFAULT TRUE,
evaluation_status TEXT DEFAULT 'pending',
evaluation_error TEXT,
submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
evaluated_at TIMESTAMP,
FOREIGN KEY (exam_id) REFERENCES exams (id)
)
''')
self._add_column_if_not_exists(cursor, 'exam_results', 'has_feedback', 'BOOLEAN DEFAULT TRUE')
self._add_column_if_not_exists(cursor, 'exam_results', 'negative_marks', 'REAL DEFAULT 0')
self._add_column_if_not_exists(cursor, 'exam_results', 'evaluation_status', 'TEXT DEFAULT "pending"')
self._add_column_if_not_exists(cursor, 'exam_results', 'evaluation_error', 'TEXT')
self._add_column_if_not_exists(cursor, 'exam_results', 'evaluated_at', 'TIMESTAMP')
# Create candidate_answers table
cursor.execute('''
CREATE TABLE IF NOT EXISTS candidate_answers (
id TEXT PRIMARY KEY,
result_id TEXT NOT NULL,
question_id TEXT NOT NULL,
candidate_answer TEXT,
marks_obtained REAL NOT NULL,
negative_marks_applied REAL DEFAULT 0,
is_correct BOOLEAN,
feedback TEXT,
evaluation_details TEXT,
FOREIGN KEY (result_id) REFERENCES exam_results (id),
FOREIGN KEY (question_id) REFERENCES questions (id)
)
''')
self._add_column_if_not_exists(cursor, 'candidate_answers', 'negative_marks_applied', 'REAL DEFAULT 0')
# Create detailed_evaluations table
cursor.execute('''
CREATE TABLE IF NOT EXISTS detailed_evaluations (
id TEXT PRIMARY KEY,
answer_id TEXT NOT NULL,
strengths TEXT,
improvements TEXT,
overall_feedback TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (answer_id) REFERENCES candidate_answers (id)
)
''')
# Create exam_sessions table for persistent session storage
cursor.execute('''
CREATE TABLE IF NOT EXISTS exam_sessions (
session_id TEXT PRIMARY KEY,
exam_id TEXT NOT NULL,
candidate_name TEXT NOT NULL,
candidate_id TEXT NOT NULL,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
time_limit INTEGER NOT NULL,
answers_data TEXT,
is_submitted BOOLEAN DEFAULT FALSE,
submitted_at TIMESTAMP,
FOREIGN KEY (exam_id) REFERENCES exams (id)
)
''')
conn.commit()
print("✅ Exam database initialized successfully with image support and persistent sessions")
def _add_column_if_not_exists(self, cursor, table_name: str, column_name: str, column_definition: str):
"""Add a column to table if it doesn't exist"""
try:
cursor.execute(f"SELECT {column_name} FROM {table_name} LIMIT 1")
except sqlite3.OperationalError:
print(f"🔄 Adding {column_name} column to {table_name} table...")
cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_definition}")
print("✅ Database migration completed")
# Image Management Functions
def add_question_image(self, question_id: str, image_url: str, caption: str = None, order: int = 0) -> str:
"""Add an image to a question"""
try:
image_id = str(uuid.uuid4())
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO question_images (id, question_id, image_url, image_caption, image_order)
VALUES (?, ?, ?, ?, ?)
''', (image_id, question_id, image_url, caption, order))
# Also update the main question table for backward compatibility
if order == 0: # Primary image
cursor.execute('''
UPDATE questions SET image_url = ?, image_caption = ?
WHERE id = ?
''', (image_url, caption, question_id))
conn.commit()
print(f"✅ Image added to question {question_id}")
return image_id
except sqlite3.Error as e:
print(f"❌ Error adding question image: {e}")
return None
def get_question_images(self, question_id: str) -> List[Dict]:
"""Get all images for a question"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT id, image_url, image_caption, image_order
FROM question_images
WHERE question_id = ?
ORDER BY image_order
''', (question_id,))
images = []
for row in cursor.fetchall():
images.append({
'id': row[0],
'url': row[1],
'caption': row[2],
'order': row[3]
})
return images
except sqlite3.Error as e:
print(f"❌ Error getting question images: {e}")
return []
def delete_question_image(self, image_id: str) -> bool:
"""Delete a specific image from a question"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# Get image info before deletion
cursor.execute('SELECT question_id, image_order FROM question_images WHERE id = ?', (image_id,))
row = cursor.fetchone()
if row:
question_id, order = row
# Delete the image
cursor.execute('DELETE FROM question_images WHERE id = ?', (image_id,))
# If it was the primary image, clear from questions table
if order == 0:
cursor.execute('UPDATE questions SET image_url = NULL, image_caption = NULL WHERE id = ?', (question_id,))
conn.commit()
return True
return False
except sqlite3.Error as e:
print(f"❌ Error deleting question image: {e}")
return False
def delete_questions_by_section(self, exam_id: str, section_type: str) -> bool:
"""Delete all questions for a specific section of an exam"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# First, get all question IDs for this section
cursor.execute('''
SELECT id FROM questions WHERE exam_id = ? AND section_type = ?
''', (exam_id, section_type))
question_ids = [row[0] for row in cursor.fetchall()]
# Delete images for these questions
for question_id in question_ids:
cursor.execute('DELETE FROM question_images WHERE question_id = ?', (question_id,))
# Delete the questions
cursor.execute('''
DELETE FROM questions WHERE exam_id = ? AND section_type = ?
''', (exam_id, section_type))
deleted_count = cursor.rowcount
conn.commit()
print(f"✅ Deleted {deleted_count} questions from section {section_type}")
return True
except sqlite3.Error as e:
print(f"❌ Error deleting questions by section: {e}")
return False
def update_sections_structure(self, exam_id: str, sections_structure: Dict) -> bool:
"""Update the sections_structure for an exam"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE exams SET sections_structure = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (json.dumps(sections_structure), exam_id))
conn.commit()
print(f"✅ Updated sections_structure for exam {exam_id}")
return True
except sqlite3.Error as e:
print(f"❌ Error updating sections_structure: {e}")
return False
def get_section_question_count(self, exam_id: str, section_type: str) -> int:
"""Get the count of questions for a specific section"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT COUNT(*) FROM questions WHERE exam_id = ? AND section_type = ?
''', (exam_id, section_type))
return cursor.fetchone()[0]
except sqlite3.Error as e:
print(f"❌ Error getting section question count: {e}")
return 0
# Live Session Management
def create_live_session(self, session_id: str, exam_id: str, candidate_name: str, candidate_id: str) -> bool:
"""Create a new live session when candidate starts exam"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO live_sessions (session_id, exam_id, candidate_name, candidate_id)
VALUES (?, ?, ?, ?)
''', (session_id, exam_id, candidate_name, candidate_id))
conn.commit()
print(f"✅ Live session created for {candidate_name} ({candidate_id})")
return True
except sqlite3.Error as e:
print(f"❌ Error creating live session: {e}")
return False
def update_session_activity(self, session_id: str) -> bool:
"""Update last activity time for a live session"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE live_sessions
SET last_activity = CURRENT_TIMESTAMP
WHERE session_id = ? AND is_active = TRUE
''', (session_id,))
conn.commit()
return True
except sqlite3.Error as e:
print(f"❌ Error updating session activity: {e}")
return False
def end_live_session(self, session_id: str) -> bool:
"""End a live session when candidate submits exam"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE live_sessions
SET is_active = FALSE
WHERE session_id = ?
''', (session_id,))
conn.commit()
print(f"✅ Live session ended for session {session_id}")
return True
except sqlite3.Error as e:
print(f"❌ Error ending live session: {e}")
return False
def get_live_candidates(self, exam_id: str) -> List[Dict]:
"""Get all currently live candidates for an exam"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT session_id, candidate_name, candidate_id, started_at, last_activity
FROM live_sessions
WHERE exam_id = ? AND is_active = TRUE
ORDER BY started_at DESC
''', (exam_id,))
live_candidates = []
for row in cursor.fetchall():
live_candidates.append({
'session_id': row[0],
'candidate_name': row[1],
'candidate_id': row[2],
'started_at': row[3],
'last_activity': row[4]
})
return live_candidates
except sqlite3.Error as e:
print(f"❌ Error getting live candidates: {e}")
return []
def cleanup_stale_sessions(self, timeout_minutes: int = 30) -> int:
"""Remove sessions that have been inactive for too long"""
try:
# Validate timeout_minutes is a positive integer to prevent any injection
timeout_minutes = max(1, int(timeout_minutes))
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# Use parameterized query with string concatenation done safely
# SQLite doesn't support parameters in datetime arithmetic, so we build the interval string safely
interval = f'+{timeout_minutes} minutes'
cursor.execute('''
UPDATE live_sessions
SET is_active = FALSE
WHERE is_active = TRUE
AND datetime(last_activity, ?) < datetime('now')
''', (interval,))
cleaned_count = cursor.rowcount
conn.commit()
if cleaned_count > 0:
print(f"🧹 Cleaned up {cleaned_count} stale sessions")
return cleaned_count
except sqlite3.Error as e:
print(f"❌ Error cleaning up stale sessions: {e}")
return 0
# Persistent Exam Session Management
def has_candidate_submitted_exam(self, exam_id: str, candidate_id: str) -> bool:
"""Check if a candidate has already submitted an exam.
Returns True if the candidate has already submitted this exam.
"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# Check both exam_results (completed submissions) and exam_sessions (submitted but maybe pending evaluation)
cursor.execute('''
SELECT 1 FROM exam_results
WHERE exam_id = ? AND candidate_id = ?
LIMIT 1
''', (exam_id, candidate_id))
if cursor.fetchone():
return True
# Also check if there's an active/submitted session
cursor.execute('''
SELECT 1 FROM exam_sessions
WHERE exam_id = ? AND candidate_id = ? AND is_submitted = TRUE
LIMIT 1
''', (exam_id, candidate_id))
if cursor.fetchone():
return True
return False
except sqlite3.Error as e:
print(f"❌ Error checking if candidate submitted exam: {e}")
# Return False on error to allow the candidate to proceed (fail-open for usability)
return False
def has_candidate_active_session(self, exam_id: str, candidate_id: str) -> Optional[str]:
"""Check if a candidate has an active (non-submitted) session for an exam.
Returns the session_id if an active session exists, None otherwise.
"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT session_id FROM exam_sessions
WHERE exam_id = ? AND candidate_id = ? AND is_submitted = FALSE
ORDER BY started_at DESC
LIMIT 1
''', (exam_id, candidate_id))
row = cursor.fetchone()
return row[0] if row else None
except sqlite3.Error as e:
print(f"❌ Error checking active session: {e}")
return None
def create_exam_session(self, session_id: str, exam_id: str, candidate_name: str,
candidate_id: str, time_limit: int) -> bool:
"""Create a persistent exam session in the database"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO exam_sessions (session_id, exam_id, candidate_name, candidate_id, time_limit, answers_data)
VALUES (?, ?, ?, ?, ?, ?)
''', (session_id, exam_id, candidate_name, candidate_id, time_limit, json.dumps({})))
conn.commit()
print(f"✅ Persistent exam session created: {session_id} for {candidate_name}")
return True
except sqlite3.Error as e:
print(f"❌ Error creating exam session: {e}")
return False
def get_exam_session(self, session_id: str) -> Optional[Dict]:
"""Get an exam session by its ID"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT session_id, exam_id, candidate_name, candidate_id, started_at,
time_limit, answers_data, is_submitted, submitted_at
FROM exam_sessions
WHERE session_id = ?
''', (session_id,))
row = cursor.fetchone()
if row:
return {
'session_id': row[0],
'exam_id': row[1],
'candidate_name': row[2],
'candidate_id': row[3],
'started_at': row[4],
'time_limit': row[5],
'answers': json.loads(row[6]) if row[6] else {},
'is_submitted': bool(row[7]),
'submitted_at': row[8]
}
return None
except sqlite3.Error as e:
print(f"❌ Error getting exam session: {e}")
return None
def update_exam_session_answers(self, session_id: str, answers: Dict) -> bool:
"""Update the answers data for an exam session"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE exam_sessions
SET answers_data = ?
WHERE session_id = ? AND is_submitted = FALSE
''', (json.dumps(answers), session_id))
conn.commit()
return cursor.rowcount > 0
except sqlite3.Error as e:
print(f"❌ Error updating exam session answers: {e}")
return False
def mark_exam_session_submitted(self, session_id: str) -> bool:
"""Mark an exam session as submitted"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE exam_sessions
SET is_submitted = TRUE, submitted_at = CURRENT_TIMESTAMP
WHERE session_id = ?
''', (session_id,))
conn.commit()
print(f"✅ Exam session marked as submitted: {session_id}")
return cursor.rowcount > 0
except sqlite3.Error as e:
print(f"❌ Error marking session as submitted: {e}")
return False
def get_all_active_exam_sessions(self) -> List[Dict]:
"""Get all active (non-submitted) exam sessions for recovery"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT session_id, exam_id, candidate_name, candidate_id, started_at,
time_limit, answers_data
FROM exam_sessions
WHERE is_submitted = FALSE
''')
sessions = []
for row in cursor.fetchall():
sessions.append({
'session_id': row[0],
'exam_id': row[1],
'candidate_name': row[2],
'candidate_id': row[3],
'started_at': row[4],
'time_limit': row[5],
'answers': json.loads(row[6]) if row[6] else {}
})
return sessions
except sqlite3.Error as e:
print(f"❌ Error getting active exam sessions: {e}")
return []
def delete_exam_session(self, session_id: str) -> bool:
"""Delete an exam session (after successful submission or cleanup)"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM exam_sessions WHERE session_id = ?', (session_id,))
conn.commit()
return cursor.rowcount > 0
except sqlite3.Error as e:
print(f"❌ Error deleting exam session: {e}")
return False
def cleanup_expired_exam_sessions(self, extra_minutes: int = 60) -> int:
"""Clean up exam sessions that have exceeded their time limit plus buffer"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# Delete sessions where started_at + time_limit + extra_minutes has passed
cursor.execute('''
DELETE FROM exam_sessions
WHERE is_submitted = FALSE
AND datetime(started_at, '+' || (time_limit + ?) || ' minutes') < datetime('now')
''', (extra_minutes,))
cleaned_count = cursor.rowcount
conn.commit()
if cleaned_count > 0:
print(f"🧹 Cleaned up {cleaned_count} expired exam sessions")
return cleaned_count
except sqlite3.Error as e:
print(f"❌ Error cleaning up expired exam sessions: {e}")
return 0
# Exam Management
def create_exam(self, title: str, department: str, position: str, description: str,
time_limit: int, instructions: str, question_structure: Dict,
sections_structure: Dict = None, show_feedback: bool = True,
negative_marking_config: Dict = None, exam_language: str = 'english',
multi_select_scoring_mode: str = 'partial') -> str:
"""Create a new exam
Args:
multi_select_scoring_mode: 'partial' for partial scoring, 'strict' for exact match only
"""
try:
exam_id = str(uuid.uuid4())
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO exams (id, title, department, position, description,
time_limit, instructions, question_structure, sections_structure,
show_feedback, negative_marking_config, exam_language, multi_select_scoring_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (exam_id, title, department, position, description,
time_limit, instructions, json.dumps(question_structure),
json.dumps(sections_structure), show_feedback,
json.dumps(negative_marking_config or {}), exam_language, multi_select_scoring_mode))
conn.commit()
print(f"✅ Exam created with ID: {exam_id}")
return exam_id
except sqlite3.Error as e:
print(f"❌ Error creating exam: {e}")
return None
def get_exam_by_id(self, exam_id: str) -> Optional[Dict]:
"""Get exam by ID"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT id, title, department, position, description, time_limit,
instructions, question_structure, sections_structure, exam_link,
is_finalized, is_active, show_feedback, negative_marking_config,
exam_language, created_at, multi_select_scoring_mode
FROM exams WHERE id = ?
''', (exam_id,))
row = cursor.fetchone()
if row:
return {
'id': row[0], 'title': row[1], 'department': row[2], 'position': row[3],
'description': row[4], 'time_limit': row[5], 'instructions': row[6],
'question_structure': json.loads(row[7]) if row[7] else {},
'sections_structure': json.loads(row[8]) if row[8] else {},
'exam_link': row[9], 'is_finalized': bool(row[10]), 'is_active': bool(row[11]),
'show_feedback': bool(row[12]) if row[12] is not None else True,
'negative_marking_config': json.loads(row[13]) if row[13] else {},
'exam_language': row[14] or 'english', 'created_at': row[15],
'multi_select_scoring_mode': row[16] or 'partial'
}
return None
except sqlite3.Error as e:
print(f"❌ Error getting exam: {e}")
return None
def get_exam_by_link(self, exam_link: str) -> Optional[Dict]:
"""Get exam by link"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT id, title, department, position, description, time_limit,
instructions, question_structure, sections_structure, exam_link,
is_finalized, is_active, show_feedback, negative_marking_config,
exam_language, multi_select_scoring_mode
FROM exams WHERE exam_link = ?
''', (exam_link,))
row = cursor.fetchone()
if not row:
return None
exam_data = {
'id': row[0], 'title': row[1], 'department': row[2], 'position': row[3],
'description': row[4], 'time_limit': row[5], 'instructions': row[6],
'question_structure': json.loads(row[7]) if row[7] else {},
'sections_structure': json.loads(row[8]) if row[8] else {},
'exam_link': row[9], 'is_finalized': row[10], 'is_active': row[11],
'show_feedback': bool(row[12]) if row[12] is not None else True,
'negative_marking_config': json.loads(row[13]) if row[13] else {},
'exam_language': row[14] or 'english',
'multi_select_scoring_mode': row[15] or 'partial'
}
# Check if exam is accessible
if not exam_data['is_finalized'] or not exam_data['is_active']:
return None
return exam_data
except sqlite3.Error as e:
print(f"❌ Error getting exam by link: {e}")
return None
def finalize_exam(self, exam_id: str) -> str:
"""Finalize exam and generate unique link"""
try:
exam_link = secrets.token_urlsafe(16)
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE exams SET exam_link = ?, is_finalized = TRUE, is_active = TRUE, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (exam_link, exam_id))
conn.commit()
print(f"✅ Exam finalized and activated with link: {exam_link}")
return exam_link
except sqlite3.Error as e:
print(f"❌ Error finalizing exam: {e}")
return None
def update_exam_settings(self, exam_id: str, title: str = None, description: str = None,
time_limit: int = None, instructions: str = None,
show_feedback: bool = None, negative_marking_config: Dict = None,
exam_language: str = None, multi_select_scoring_mode: str = None) -> bool:
"""Update exam settings"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
update_fields = []
update_values = []
if title is not None:
update_fields.append("title = ?")
update_values.append(title)
if description is not None:
update_fields.append("description = ?")
update_values.append(description)
if time_limit is not None:
update_fields.append("time_limit = ?")
update_values.append(time_limit)
if instructions is not None:
update_fields.append("instructions = ?")
update_values.append(instructions)
if show_feedback is not None:
update_fields.append("show_feedback = ?")
update_values.append(show_feedback)
if negative_marking_config is not None:
update_fields.append("negative_marking_config = ?")
update_values.append(json.dumps(negative_marking_config))
if exam_language is not None:
update_fields.append("exam_language = ?")
update_values.append(exam_language)
if multi_select_scoring_mode is not None:
update_fields.append("multi_select_scoring_mode = ?")
update_values.append(multi_select_scoring_mode)
update_fields.append("updated_at = CURRENT_TIMESTAMP")
update_values.append(exam_id)
if len(update_fields) > 1: # More than just timestamp
query = f"UPDATE exams SET {', '.join(update_fields)} WHERE id = ?"
cursor.execute(query, update_values)
conn.commit()
return True
except sqlite3.Error as e:
print(f"❌ Error updating exam settings: {e}")
return False
def toggle_exam_status(self, exam_id: str, is_active: bool) -> bool:
"""Toggle exam active/inactive status"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE exams SET is_active = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (is_active, exam_id))
conn.commit()
return True
except sqlite3.Error as e:
print(f"❌ Error toggling exam status: {e}")
return False
def get_all_exams(self) -> List[Dict]:
"""Get all exams"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT id, title, department, position, description, time_limit,
exam_link, is_finalized, is_active, show_feedback,
negative_marking_config, exam_language, created_at
FROM exams ORDER BY created_at DESC
''')
exams = []
for row in cursor.fetchall():
exams.append({
'id': row[0], 'title': row[1], 'department': row[2], 'position': row[3],
'description': row[4], 'time_limit': row[5], 'exam_link': row[6],
'is_finalized': bool(row[7]), 'is_active': bool(row[8]),
'show_feedback': bool(row[9]) if row[9] is not None else True,
'negative_marking_config': json.loads(row[10]) if row[10] else {},
'exam_language': row[11] or 'english', 'created_at': row[12]
})
return exams
except sqlite3.Error as e:
print(f"❌ Error getting all exams: {e}")
return []
def delete_exam(self, exam_id: str) -> bool:
"""Delete an exam and all related data"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# Delete in order due to foreign key constraints
cursor.execute('DELETE FROM live_sessions WHERE exam_id = ?', (exam_id,))
# Delete question images first
cursor.execute('''
DELETE FROM question_images
WHERE question_id IN (SELECT id FROM questions WHERE exam_id = ?)
''', (exam_id,))
cursor.execute('DELETE FROM questions WHERE exam_id = ?', (exam_id,))
# Delete detailed evaluations
cursor.execute('''
DELETE FROM detailed_evaluations
WHERE answer_id IN (
SELECT ca.id FROM candidate_answers ca
JOIN exam_results er ON ca.result_id = er.id
WHERE er.exam_id = ?
)
''', (exam_id,))
# Delete candidate answers
cursor.execute('''
DELETE FROM candidate_answers
WHERE result_id IN (
SELECT id FROM exam_results WHERE exam_id = ?
)
''', (exam_id,))
# Delete exam results
cursor.execute('DELETE FROM exam_results WHERE exam_id = ?', (exam_id,))
# Delete exam
cursor.execute('DELETE FROM exams WHERE id = ?', (exam_id,))
conn.commit()
print(f"✅ Exam {exam_id} deleted successfully")
return True
except sqlite3.Error as e:
print(f"❌ Error deleting exam: {e}")
return False
# Question Management
def save_exam_question(self, exam_id: str, question_data: Dict, section_type: str = 'technical') -> str:
"""Save a question for an exam with multi-select MCQ support"""
try:
question_id = str(uuid.uuid4())
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# Get current max order for this exam and section
cursor.execute('SELECT MAX(question_order) FROM questions WHERE exam_id = ? AND section_type = ?',
(exam_id, section_type))
max_order = cursor.fetchone()[0] or 0
# Handle multi-select MCQ
is_multi_select = question_data.get('is_multi_select', False)
correct_answers = question_data.get('correct_answers')
correct_answers_json = None
correct_answer_single = question_data.get('correct_answer')
if is_multi_select and correct_answers:
correct_answers_json = json.dumps(correct_answers)
correct_answer_single = correct_answers[0] if correct_answers else None
cursor.execute('''
INSERT INTO questions (id, exam_id, section_type, question_type, question_text,
options, correct_answer, expected_answer,
evaluation_criteria, marks, question_order, explanation,
image_url, image_caption, is_multi_select, correct_answers)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
question_id, exam_id, section_type, question_data['type'], question_data['question'],
json.dumps(question_data.get('options', [])) if question_data.get('options') else None,
correct_answer_single,
question_data.get('expected_answer'),
question_data.get('evaluation_criteria'),
question_data['marks'],
max_order + 1,
question_data.get('explanation'),
question_data.get('image_url'),
question_data.get('image_caption'),
is_multi_select,
correct_answers_json
))
conn.commit()
return question_id
except sqlite3.Error as e:
print(f"❌ Error saving question: {e}")
return None
def get_exam_questions(self, exam_id: str) -> List[Dict]:
"""Get all questions for an exam with images"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT id, section_type, question_type, question_text, options, correct_answer,
expected_answer, evaluation_criteria, marks, explanation, image_url, image_caption,
is_multi_select, correct_answers
FROM questions WHERE exam_id = ? ORDER BY section_type, question_order
''', (exam_id,))
questions = []
for row in cursor.fetchall():
question = {
'id': row[0], 'section_type': row[1], 'type': row[2],
'question': row[3], 'marks': row[8], 'explanation': row[9],
'image_url': row[10], 'image_caption': row[11],
'is_multi_select': bool(row[12]) if row[12] is not None else False
}