Mananjp commited on
Commit
092e3d5
·
0 Parent(s):

feat: Complete ExamIDE with test cases, auto-judging, scoring, PDF reports, room expiry & Docker

Browse files

- Backend: FastAPI with multi-language code execution (Python, JS, Java, C++)
- Frontend: Streamlit with teacher dashboard and student workspace
- Test Cases: Example (visible) + hidden test cases per question
- Auto-Judging: LeetCode-style judging with per-case verdicts
- Scoring: Score = (passed/total) * 100 per question, best score tracked
- PDF Reports: ReportLab-based reports with scores, violations, grades
- Room Expiry: Auto-revoke room codes after exam ends (blocks join/save/submit)
- Violations: Tab-switch detection with severity ratings
- Docker: docker-compose with backend + frontend containers
- MongoDB Atlas: TLS/SRV support with certifi + dnspython

.dockerignore ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Virtual environments
2
+ .venv
3
+ venv
4
+ .env.local
5
+
6
+ # Python cache
7
+ __pycache__
8
+ *.pyc
9
+ *.pyo
10
+ *.pyd
11
+
12
+ # IDE files
13
+ .idea
14
+ .vscode
15
+ *.swp
16
+ *.swo
17
+
18
+ # Git
19
+ .git
20
+ .gitignore
21
+
22
+ # Logs and temp files
23
+ *.log
24
+ conn_test_log.txt
25
+ conn_test_log_2.txt
26
+ connection_test_output.txt
27
+
28
+ # Docs (not needed in container)
29
+ docs/
30
+
31
+ # OS files
32
+ Thumbs.db
33
+ .DS_Store
.gitignore ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ *.egg
10
+
11
+ # Virtual environments
12
+ .venv/
13
+ venv/
14
+ env/
15
+
16
+ # Environment variables (SENSITIVE - never commit)
17
+ .env
18
+ .env.local
19
+ .env.production
20
+
21
+ # IDE
22
+ .idea/
23
+ .vscode/
24
+ *.swp
25
+ *.swo
26
+ *~
27
+
28
+ # OS
29
+ .DS_Store
30
+ Thumbs.db
31
+ desktop.ini
32
+
33
+ # Logs & temp
34
+ *.log
35
+ conn_test_log.txt
36
+ conn_test_log_2.txt
37
+ connection_test_output.txt
38
+
39
+ # Test files
40
+ test_report.pdf
41
+ verify_api.py
Dockerfile.backend ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # Online Exam IDE — Backend Dockerfile
3
+ # ============================================================================
4
+ # Multi-language code execution: Python, Node.js, Java (JDK), C++ (g++)
5
+ # ============================================================================
6
+
7
+ FROM python:3.11-slim
8
+
9
+ # Set environment variables
10
+ ENV PYTHONDONTWRITEBYTECODE=1
11
+ ENV PYTHONUNBUFFERED=1
12
+
13
+ # Install system dependencies for multi-language code execution
14
+ RUN apt-get update && apt-get install -y --no-install-recommends \
15
+ # Node.js for JavaScript execution
16
+ nodejs \
17
+ npm \
18
+ # C++ compiler
19
+ g++ \
20
+ # Java JDK for Java execution
21
+ default-jdk \
22
+ # DNS utils (needed for MongoDB Atlas SRV records)
23
+ dnsutils \
24
+ # Cleanup
25
+ && rm -rf /var/lib/apt/lists/*
26
+
27
+ # Create app directory
28
+ WORKDIR /app
29
+
30
+ # Copy requirements and install Python dependencies
31
+ COPY backend/requirements.txt .
32
+ RUN pip install --no-cache-dir -r requirements.txt
33
+
34
+ # Copy backend source code
35
+ COPY backend/ .
36
+
37
+ # Expose port
38
+ EXPOSE 8000
39
+
40
+ # Health check
41
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
42
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
43
+
44
+ # Run the application
45
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Dockerfile.frontend ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # Online Exam IDE — Frontend Dockerfile
3
+ # ============================================================================
4
+ # Streamlit-based frontend
5
+ # ============================================================================
6
+
7
+ FROM python:3.11-slim
8
+
9
+ # Set environment variables
10
+ ENV PYTHONDONTWRITEBYTECODE=1
11
+ ENV PYTHONUNBUFFERED=1
12
+
13
+ # Create app directory
14
+ WORKDIR /app
15
+
16
+ # Copy requirements and install Python dependencies
17
+ COPY frontend/requirements.txt .
18
+ RUN pip install --no-cache-dir -r requirements.txt
19
+
20
+ # Copy frontend source code
21
+ COPY frontend/ .
22
+
23
+ # Expose Streamlit default port
24
+ EXPOSE 8501
25
+
26
+ # Health check
27
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
28
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8501/_stcore/health')" || exit 1
29
+
30
+ # Run Streamlit
31
+ CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0", "--server.headless=true"]
backend/.env.example ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # MongoDB Atlas connection string
2
+ # Get yours at https://cloud.mongodb.com
3
+ # IMPORTANT: Whitelist your IP in Atlas → Network Access before connecting
4
+ MONGO_URI=mongodb+srv://<username>:<password>@<cluster>.mongodb.net/?retryWrites=true&w=majority
backend/code_executor.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import subprocess
2
+ import os
3
+ import tempfile
4
+ from typing import Dict, Optional
5
+
6
+
7
+ class CodeExecutor:
8
+ def __init__(self, timeout: int = 10):
9
+ self.timeout = timeout
10
+
11
+ def execute(self, code: str, language: str = "python", stdin_input: str = None) -> Dict:
12
+ """Execute code in the specified language, optionally piping stdin_input."""
13
+ language = language.lower().strip()
14
+
15
+ if language in ["python", "py"]:
16
+ return self._execute_python(code, stdin_input)
17
+ elif language in ["javascript", "js"]:
18
+ return self._execute_javascript(code, stdin_input)
19
+ elif language == "java":
20
+ return self._execute_java(code, stdin_input)
21
+ elif language in ["cpp", "c++"]:
22
+ return self._execute_cpp(code, stdin_input)
23
+ else:
24
+ return {
25
+ "success": False,
26
+ "error": f"Unsupported language: {language}"
27
+ }
28
+
29
+ def execute_with_test_case(self, code: str, language: str, input_data: str, expected_output: str) -> Dict:
30
+ """
31
+ Execute code with given stdin input and compare output with expected.
32
+ Returns result with pass/fail, actual output, etc.
33
+ """
34
+ result = self.execute(code, language, stdin_input=input_data)
35
+
36
+ if not result.get("success"):
37
+ return {
38
+ "passed": False,
39
+ "actual_output": result.get("error", ""),
40
+ "expected_output": expected_output.strip(),
41
+ "error": result.get("error", "Execution failed"),
42
+ "status": "Runtime Error" if "timeout" not in result.get("error", "").lower() else "Time Limit Exceeded"
43
+ }
44
+
45
+ actual = result.get("output", "").strip()
46
+ expected = expected_output.strip()
47
+
48
+ passed = actual == expected
49
+
50
+ return {
51
+ "passed": passed,
52
+ "actual_output": actual,
53
+ "expected_output": expected,
54
+ "error": None,
55
+ "status": "Accepted" if passed else "Wrong Answer"
56
+ }
57
+
58
+ def _execute_python(self, code: str, stdin_input: str = None) -> Dict:
59
+ """Execute Python code"""
60
+ try:
61
+ result = subprocess.run(
62
+ ["python", "-c", code],
63
+ capture_output=True,
64
+ text=True,
65
+ timeout=self.timeout,
66
+ input=stdin_input
67
+ )
68
+
69
+ if result.returncode == 0:
70
+ return {
71
+ "success": True,
72
+ "output": result.stdout if result.stdout else "(No output)"
73
+ }
74
+ else:
75
+ return {
76
+ "success": False,
77
+ "error": result.stderr if result.stderr else "Unknown error"
78
+ }
79
+ except subprocess.TimeoutExpired:
80
+ return {
81
+ "success": False,
82
+ "error": f"Code execution timeout (>{self.timeout}s)"
83
+ }
84
+ except Exception as e:
85
+ return {
86
+ "success": False,
87
+ "error": str(e)
88
+ }
89
+
90
+ def _execute_javascript(self, code: str, stdin_input: str = None) -> Dict:
91
+ """Execute JavaScript code using Node.js"""
92
+ try:
93
+ result = subprocess.run(
94
+ ["node", "-e", code],
95
+ capture_output=True,
96
+ text=True,
97
+ timeout=self.timeout,
98
+ input=stdin_input
99
+ )
100
+
101
+ if result.returncode == 0:
102
+ return {
103
+ "success": True,
104
+ "output": result.stdout if result.stdout else "(No output)"
105
+ }
106
+ else:
107
+ return {
108
+ "success": False,
109
+ "error": result.stderr if result.stderr else "Unknown error"
110
+ }
111
+ except subprocess.TimeoutExpired:
112
+ return {
113
+ "success": False,
114
+ "error": f"Code execution timeout (>{self.timeout}s)"
115
+ }
116
+ except FileNotFoundError:
117
+ return {
118
+ "success": False,
119
+ "error": "Node.js not installed. Install Node.js to run JavaScript"
120
+ }
121
+ except Exception as e:
122
+ return {
123
+ "success": False,
124
+ "error": str(e)
125
+ }
126
+
127
+ def _execute_java(self, code: str, stdin_input: str = None) -> Dict:
128
+ """Execute Java code"""
129
+ try:
130
+ # Create temp directory
131
+ with tempfile.TemporaryDirectory() as tmpdir:
132
+ # Write Java file
133
+ java_file = os.path.join(tmpdir, "Main.java")
134
+ with open(java_file, "w") as f:
135
+ f.write(code)
136
+
137
+ # Compile
138
+ compile_result = subprocess.run(
139
+ ["javac", java_file],
140
+ capture_output=True,
141
+ text=True,
142
+ timeout=self.timeout
143
+ )
144
+
145
+ if compile_result.returncode != 0:
146
+ return {
147
+ "success": False,
148
+ "error": compile_result.stderr if compile_result.stderr else "Compilation error"
149
+ }
150
+
151
+ # Run
152
+ run_result = subprocess.run(
153
+ ["java", "-cp", tmpdir, "Main"],
154
+ capture_output=True,
155
+ text=True,
156
+ timeout=self.timeout,
157
+ input=stdin_input
158
+ )
159
+
160
+ if run_result.returncode == 0:
161
+ return {
162
+ "success": True,
163
+ "output": run_result.stdout if run_result.stdout else "(No output)"
164
+ }
165
+ else:
166
+ return {
167
+ "success": False,
168
+ "error": run_result.stderr if run_result.stderr else "Runtime error"
169
+ }
170
+
171
+ except subprocess.TimeoutExpired:
172
+ return {
173
+ "success": False,
174
+ "error": f"Code execution timeout (>{self.timeout}s)"
175
+ }
176
+ except FileNotFoundError:
177
+ return {
178
+ "success": False,
179
+ "error": "Java not installed. Install JDK to run Java code"
180
+ }
181
+ except Exception as e:
182
+ return {
183
+ "success": False,
184
+ "error": str(e)
185
+ }
186
+
187
+ def _execute_cpp(self, code: str, stdin_input: str = None) -> Dict:
188
+ """Execute C++ code"""
189
+ try:
190
+ with tempfile.TemporaryDirectory() as tmpdir:
191
+ # Write C++ file
192
+ cpp_file = os.path.join(tmpdir, "main.cpp")
193
+ exe_file = os.path.join(tmpdir, "main")
194
+
195
+ with open(cpp_file, "w") as f:
196
+ f.write(code)
197
+
198
+ # Compile
199
+ compile_result = subprocess.run(
200
+ ["g++", cpp_file, "-o", exe_file],
201
+ capture_output=True,
202
+ text=True,
203
+ timeout=self.timeout
204
+ )
205
+
206
+ if compile_result.returncode != 0:
207
+ return {
208
+ "success": False,
209
+ "error": compile_result.stderr if compile_result.stderr else "Compilation error"
210
+ }
211
+
212
+ # Run
213
+ run_result = subprocess.run(
214
+ [exe_file],
215
+ capture_output=True,
216
+ text=True,
217
+ timeout=self.timeout,
218
+ input=stdin_input
219
+ )
220
+
221
+ if run_result.returncode == 0:
222
+ return {
223
+ "success": True,
224
+ "output": run_result.stdout if run_result.stdout else "(No output)"
225
+ }
226
+ else:
227
+ return {
228
+ "success": False,
229
+ "error": run_result.stderr if run_result.stderr else "Runtime error"
230
+ }
231
+
232
+ except subprocess.TimeoutExpired:
233
+ return {
234
+ "success": False,
235
+ "error": f"Code execution timeout (>{self.timeout}s)"
236
+ }
237
+ except FileNotFoundError:
238
+ return {
239
+ "success": False,
240
+ "error": "G++ not installed. Install GCC to run C++ code"
241
+ }
242
+ except Exception as e:
243
+ return {
244
+ "success": False,
245
+ "error": str(e)
246
+ }
backend/database.py ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import motor.motor_asyncio
3
+ import uuid
4
+ import asyncio
5
+ from datetime import datetime, timedelta
6
+ from typing import List, Dict, Optional
7
+ from models import Room, Question, Worksheet, ExecutionResult
8
+
9
+ import certifi
10
+
11
+ class Database:
12
+ def __init__(self, uri: str):
13
+ self.client = motor.motor_asyncio.AsyncIOMotorClient(
14
+ uri,
15
+ tlsCAFile=certifi.where()
16
+ )
17
+ self.db = self.client.exam_ide_db
18
+ self.rooms_collection = self.db.rooms
19
+ self.questions_collection = self.db.questions
20
+ self.worksheets_collection = self.db.worksheets
21
+
22
+ async def create_room(
23
+ self,
24
+ room_name: str,
25
+ teacher_name: str,
26
+ language: str,
27
+ duration_minutes: int,
28
+ start_time: Optional[str] = None
29
+ ) -> dict:
30
+ room_id = str(uuid.uuid4())
31
+ room_code = str(uuid.uuid4())[:6].upper()
32
+
33
+ # Calculate end time if start time is provided
34
+ end_time: Optional[str] = None
35
+ if start_time and duration_minutes:
36
+ try:
37
+ # Normalize and parse start_time
38
+ start_dt = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
39
+ end_dt = start_dt + timedelta(minutes=duration_minutes)
40
+ end_time = end_dt.isoformat()
41
+ except Exception:
42
+ # If parsing fails, just leave end_time as None
43
+ end_time = None
44
+
45
+ room_doc = {
46
+ "room_id": room_id,
47
+ "room_code": room_code,
48
+ "room_name": room_name,
49
+ "teacher_name": teacher_name,
50
+ "language": language,
51
+ "duration_minutes": duration_minutes,
52
+ "start_time": start_time,
53
+ "end_time": end_time,
54
+ "students": [],
55
+ "student_names": {},
56
+ "student_red_flags": {},
57
+ "questions": [],
58
+ "status": "active",
59
+ "created_at": datetime.now(),
60
+ }
61
+
62
+ await self.rooms_collection.insert_one(room_doc)
63
+ room_doc.pop("_id", None) # Remove Mongo ID
64
+ return room_doc
65
+
66
+ async def get_room(self, room_id: str) -> Optional[dict]:
67
+ room = await self.rooms_collection.find_one({"room_id": room_id}, {"_id": 0})
68
+ return room
69
+
70
+ async def get_room_by_code(self, room_code: str) -> Optional[dict]:
71
+ room = await self.rooms_collection.find_one({"room_code": room_code}, {"_id": 0})
72
+ return room
73
+
74
+ def is_room_expired(self, room: dict) -> bool:
75
+ """Check if a room's exam time has ended."""
76
+ end_time_str = room.get("end_time")
77
+ if not end_time_str:
78
+ return False
79
+ try:
80
+ end_dt = datetime.fromisoformat(end_time_str.replace("Z", "+00:00"))
81
+ # Compare as naive if needed
82
+ now = datetime.now(end_dt.tzinfo) if end_dt.tzinfo else datetime.now()
83
+ return now > end_dt
84
+ except Exception:
85
+ return False
86
+
87
+ async def expire_room(self, room_id: str):
88
+ """Mark a room as expired."""
89
+ await self.rooms_collection.update_one(
90
+ {"room_id": room_id},
91
+ {"$set": {"status": "expired"}}
92
+ )
93
+
94
+ async def join_room(self, room_code: str, student_name: str) -> Optional[dict]:
95
+ room = await self.get_room_by_code(room_code)
96
+ if not room:
97
+ return None
98
+
99
+ # Security: reject joins if room is expired
100
+ if room.get("status") == "expired" or self.is_room_expired(room):
101
+ # Auto-mark as expired in DB if not already
102
+ if room.get("status") != "expired":
103
+ await self.expire_room(room["room_id"])
104
+ return {"error": "expired", "detail": "This exam has ended. Room code is no longer valid."}
105
+
106
+ room_id = room["room_id"]
107
+ student_id = str(uuid.uuid4())
108
+
109
+ # Update room with new student
110
+ await self.rooms_collection.update_one(
111
+ {"room_id": room_id},
112
+ {
113
+ "$push": {"students": student_id},
114
+ "$set": {f"student_names.{student_id}": student_name}
115
+ }
116
+ )
117
+
118
+ return {
119
+ "room_id": room_id,
120
+ "student_id": student_id,
121
+ "student_name": student_name
122
+ }
123
+
124
+ async def create_question(
125
+ self,
126
+ room_id: str,
127
+ question_text: str,
128
+ language: str,
129
+ test_cases: Optional[List[dict]] = None
130
+ ) -> Optional[dict]:
131
+ """Create a question with optional test cases (example + hidden)."""
132
+ room = await self.get_room(room_id)
133
+ if not room:
134
+ return None
135
+
136
+ question_id = str(uuid.uuid4())
137
+
138
+ # Build test case list with generated IDs
139
+ processed_test_cases = []
140
+ if test_cases:
141
+ for tc in test_cases:
142
+ processed_test_cases.append({
143
+ "test_id": tc.get("test_id", str(uuid.uuid4())),
144
+ "input_data": tc.get("input_data", ""),
145
+ "expected_output": tc.get("expected_output", ""),
146
+ "is_hidden": tc.get("is_hidden", False)
147
+ })
148
+
149
+ question = {
150
+ "question_id": question_id,
151
+ "question_text": question_text,
152
+ "language": language,
153
+ "test_cases": processed_test_cases
154
+ }
155
+
156
+ # Embed question in room document
157
+ await self.rooms_collection.update_one(
158
+ {"room_id": room_id},
159
+ {"$push": {"questions": question}}
160
+ )
161
+
162
+ # Also store in separate collection for direct lookups
163
+ await self.questions_collection.insert_one(question.copy())
164
+ if "_id" in question:
165
+ question.pop("_id")
166
+
167
+ return question
168
+
169
+ async def get_questions(self, room_id: str) -> List[dict]:
170
+ room = await self.get_room(room_id)
171
+ if room:
172
+ return room.get("questions", [])
173
+ return []
174
+
175
+ async def get_question(self, room_id: str, question_id: str) -> Optional[dict]:
176
+ """Get a single question by ID (includes hidden test cases for judging)."""
177
+ room = await self.get_room(room_id)
178
+ if not room:
179
+ return None
180
+ for q in room.get("questions", []):
181
+ if q["question_id"] == question_id:
182
+ return q
183
+ return None
184
+
185
+ async def create_worksheet(self, room_id: str, student_id: str, question_id: str, language: str) -> dict:
186
+ worksheet_id = str(uuid.uuid4())
187
+
188
+ worksheet = {
189
+ "worksheet_id": worksheet_id,
190
+ "room_id": room_id,
191
+ "student_id": student_id,
192
+ "question_id": question_id,
193
+ "code": "",
194
+ "language": language,
195
+ "status": "working",
196
+ "last_updated": datetime.now().isoformat(),
197
+ "submission_results": [] # track submission history
198
+ }
199
+
200
+ await self.worksheets_collection.insert_one(worksheet)
201
+ worksheet.pop("_id")
202
+ return worksheet
203
+
204
+ async def get_worksheet(self, room_id: str, student_id: str, question_id: str) -> Optional[dict]:
205
+ ws = await self.worksheets_collection.find_one({
206
+ "room_id": room_id,
207
+ "student_id": student_id,
208
+ "question_id": question_id
209
+ }, {"_id": 0})
210
+ return ws
211
+
212
+ async def get_worksheet_by_id(self, worksheet_id: str) -> Optional[dict]:
213
+ ws = await self.worksheets_collection.find_one({"worksheet_id": worksheet_id}, {"_id": 0})
214
+ return ws
215
+
216
+ async def save_worksheet(self, worksheet_id: str, code: str) -> bool:
217
+ result = await self.worksheets_collection.update_one(
218
+ {"worksheet_id": worksheet_id},
219
+ {
220
+ "$set": {
221
+ "code": code,
222
+ "last_updated": datetime.now().isoformat()
223
+ }
224
+ }
225
+ )
226
+ return result.modified_count > 0 or result.matched_count > 0
227
+
228
+ async def save_submission_result(self, worksheet_id: str, submission_result: dict) -> bool:
229
+ """Save a submission result to the worksheet's history."""
230
+ result = await self.worksheets_collection.update_one(
231
+ {"worksheet_id": worksheet_id},
232
+ {
233
+ "$push": {"submission_results": submission_result},
234
+ "$set": {
235
+ "status": "accepted" if submission_result.get("overall") == "Accepted" else "attempted",
236
+ "last_updated": datetime.now().isoformat()
237
+ }
238
+ }
239
+ )
240
+ return result.modified_count > 0 or result.matched_count > 0
241
+
242
+ async def get_student_worksheets(self, room_id: str, student_id: str) -> Dict:
243
+ cursor = self.worksheets_collection.find({"room_id": room_id, "student_id": student_id})
244
+ student_worksheets = {}
245
+ async for ws in cursor:
246
+ student_worksheets[ws["worksheet_id"]] = {
247
+ "code": ws["code"],
248
+ "language": ws["language"],
249
+ "status": ws["status"],
250
+ "last_updated": ws["last_updated"],
251
+ "question_id": ws["question_id"]
252
+ }
253
+ return student_worksheets
254
+
255
+ async def update_red_flags(self, room_id: str, student_id: str, count: int):
256
+ await self.rooms_collection.update_one(
257
+ {"room_id": room_id},
258
+ {"$set": {f"student_red_flags.{student_id}": count}}
259
+ )
260
+
261
+ async def get_student_codes_with_names(self, room_id: str) -> Dict:
262
+ room = await self.get_room(room_id)
263
+ if not room:
264
+ return {}
265
+
266
+ student_codes = {}
267
+ students = room.get("students", [])
268
+ student_names = room.get("student_names", {})
269
+ student_red_flags = room.get("student_red_flags", {})
270
+
271
+ for student_id in students:
272
+ student_name = student_names.get(student_id, "Unknown Student")
273
+
274
+ # Get latest worksheet
275
+ cursor = self.worksheets_collection.find({"room_id": room_id, "student_id": student_id}).sort("last_updated", -1).limit(1)
276
+ latest_ws = None
277
+ async for ws in cursor:
278
+ latest_ws = ws
279
+
280
+ if latest_ws:
281
+ student_codes[student_id] = {
282
+ "student_name": student_name,
283
+ "red_flags": student_red_flags.get(student_id, 0),
284
+ "code": latest_ws.get("code", ""),
285
+ "language": latest_ws.get("language", "python"),
286
+ "status": latest_ws.get("status", "working"),
287
+ "last_updated": latest_ws.get("last_updated", ""),
288
+ "question_id": latest_ws.get("question_id", "")
289
+ }
290
+ else:
291
+ student_codes[student_id] = {
292
+ "student_name": student_name,
293
+ "red_flags": student_red_flags.get(student_id, 0),
294
+ "code": "",
295
+ "language": "python",
296
+ "status": "idle",
297
+ "last_updated": "",
298
+ "question_id": ""
299
+ }
300
+
301
+ return student_codes
302
+
303
+ async def get_all_worksheets_for_room(self, room_id: str) -> list:
304
+ """Get all worksheets for a room (for scoring/report)."""
305
+ cursor = self.worksheets_collection.find({"room_id": room_id}, {"_id": 0})
306
+ worksheets = []
307
+ async for ws in cursor:
308
+ worksheets.append(ws)
309
+ return worksheets
backend/main.py ADDED
@@ -0,0 +1,596 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.responses import Response
4
+ from typing import List, Dict, Optional
5
+ import os
6
+ from datetime import datetime, timedelta
7
+ from dotenv import load_dotenv
8
+
9
+ from code_executor import CodeExecutor
10
+ from database import Database
11
+ from models import Room, Question, Worksheet, ExecutionResult, TestCase, SubmissionResult
12
+ from report_generator import generate_exam_report
13
+
14
+ # Load environment variables
15
+ load_dotenv()
16
+ MONGO_URI = os.getenv("MONGO_URI")
17
+
18
+ if not MONGO_URI:
19
+ print("WARNING: MONGO_URI not found in .env")
20
+
21
+ # ============================================================================
22
+ # INITIALIZE APP
23
+ # ============================================================================
24
+
25
+ app = FastAPI(title="Online Exam IDE API",docs_url="/docs", redoc_url="/redoc")
26
+ # Add CORS Middleware
27
+ app.add_middleware(
28
+ CORSMiddleware,
29
+ allow_origins=["*"],
30
+ allow_credentials=True,
31
+ allow_methods=["*"],
32
+ allow_headers=["*"],
33
+ )
34
+
35
+ # Initialize Database
36
+ db = Database(MONGO_URI)
37
+ code_executor = CodeExecutor()
38
+
39
+ # Max score per question (each question is worth this many points)
40
+ MAX_SCORE_PER_QUESTION = 100
41
+
42
+
43
+ # ============================================================================
44
+ # HELPER: Room expiry check
45
+ # ============================================================================
46
+
47
+ async def check_room_active(room_id: str) -> dict:
48
+ """Check if room exists and is active. Auto-expire if time is up."""
49
+ room = await db.get_room(room_id)
50
+ if not room:
51
+ raise HTTPException(status_code=404, detail="Room not found")
52
+
53
+ if room.get("status") == "expired":
54
+ raise HTTPException(status_code=403, detail="This exam has ended. Room is no longer accessible.")
55
+
56
+ if db.is_room_expired(room):
57
+ await db.expire_room(room_id)
58
+ raise HTTPException(status_code=403, detail="This exam has ended. Room code has been revoked.")
59
+
60
+ return room
61
+
62
+
63
+ # ============================================================================
64
+ # HEALTH CHECK
65
+ # ============================================================================
66
+
67
+ @app.get("/health")
68
+ async def health_check():
69
+ return {"status": "healthy", "timestamp": datetime.now().isoformat()}
70
+
71
+
72
+ # ============================================================================
73
+ # ROOM ENDPOINTS
74
+ # ============================================================================
75
+
76
+ @app.post("/api/rooms/create")
77
+ async def create_room(data: dict):
78
+ """Create a new exam room"""
79
+ room_name = data.get("room_name")
80
+ teacher_name = data.get("teacher_name")
81
+ language = data.get("language", "Python")
82
+ duration_minutes = int(data.get("duration", 30))
83
+ start_time = data.get("start_time") # ISO String
84
+
85
+ room = await db.create_room(
86
+ room_name=room_name,
87
+ teacher_name=teacher_name,
88
+ language=language,
89
+ duration_minutes=duration_minutes,
90
+ start_time=start_time
91
+ )
92
+
93
+ return {
94
+ "room_id": room["room_id"],
95
+ "room_code": room["room_code"],
96
+ "room_name": room["room_name"],
97
+ "teacher_name": room["teacher_name"],
98
+ "duration_minutes": room["duration_minutes"],
99
+ "start_time": room["start_time"],
100
+ "end_time": room["end_time"]
101
+ }
102
+
103
+
104
+ @app.post("/api/rooms/{room_id}/report_violation")
105
+ async def report_violation(room_id: str, data: dict):
106
+ """Report a student violation (tab switch, etc.)"""
107
+ student_id = data.get("student_id")
108
+
109
+ room = await db.get_room(room_id)
110
+
111
+ if not room:
112
+ raise HTTPException(status_code=404, detail="Room not found")
113
+
114
+ if student_id:
115
+ current_flags = room.get("student_red_flags", {}).get(student_id, 0)
116
+ new_flags = current_flags + 1
117
+ await db.update_red_flags(room_id, student_id, new_flags)
118
+
119
+ return {"success": True, "new_count": new_flags}
120
+
121
+ return {"success": False}
122
+
123
+
124
+ @app.get("/api/rooms/{room_id}")
125
+ async def get_room(room_id: str):
126
+ """Get room details. Auto-detects and marks expired rooms."""
127
+ room = await db.get_room(room_id)
128
+ if not room:
129
+ raise HTTPException(status_code=404, detail="Room not found")
130
+
131
+ # Auto-expire if time is up (but still return data for teacher to view)
132
+ status = room.get("status", "active")
133
+ if status != "expired" and db.is_room_expired(room):
134
+ await db.expire_room(room_id)
135
+ status = "expired"
136
+
137
+ return {
138
+ "room_id": room["room_id"],
139
+ "room_code": room["room_code"],
140
+ "room_name": room["room_name"],
141
+ "teacher_name": room["teacher_name"],
142
+ "students": room.get("students", []),
143
+ "student_names": room.get("student_names", {}),
144
+ "questions": room.get("questions", []),
145
+ "language": room.get("language"),
146
+ "duration_minutes": room.get("duration_minutes"),
147
+ "start_time": room.get("start_time"),
148
+ "end_time": room.get("end_time"),
149
+ "student_red_flags": room.get("student_red_flags", {}),
150
+ "status": status
151
+ }
152
+
153
+
154
+ @app.post("/api/rooms/{room_code}/join")
155
+ async def join_room(room_code: str, data: dict):
156
+ """Join an existing room. BLOCKED if room is expired."""
157
+ result = await db.join_room(room_code, data.get("student_name"))
158
+ if not result:
159
+ raise HTTPException(status_code=404, detail="Room not found")
160
+
161
+ # Check if db.join_room returned an error (expired room)
162
+ if "error" in result and result["error"] == "expired":
163
+ raise HTTPException(status_code=403, detail=result["detail"])
164
+
165
+ return result
166
+
167
+
168
+ # ============================================================================
169
+ # QUESTION ENDPOINTS
170
+ # ============================================================================
171
+
172
+ @app.post("/api/rooms/{room_id}/questions")
173
+ async def create_question(room_id: str, data: dict):
174
+ """Create a question in a room, with optional test cases."""
175
+ question = await db.create_question(
176
+ room_id=room_id,
177
+ question_text=data.get("question_text"),
178
+ language=data.get("language", "Python"),
179
+ test_cases=data.get("test_cases", [])
180
+ )
181
+
182
+ if not question:
183
+ raise HTTPException(status_code=404, detail="Room not found")
184
+
185
+ return question
186
+
187
+
188
+ @app.get("/api/rooms/{room_id}/questions")
189
+ async def get_questions(room_id: str):
190
+ """
191
+ Get all questions in a room.
192
+ Hidden test cases are filtered out for student consumption.
193
+ """
194
+ questions = await db.get_questions(room_id)
195
+
196
+ filtered_questions = []
197
+ for q in questions:
198
+ filtered_q = {
199
+ "question_id": q["question_id"],
200
+ "question_text": q["question_text"],
201
+ "language": q.get("language", "Python"),
202
+ "test_cases": [
203
+ tc for tc in q.get("test_cases", [])
204
+ if not tc.get("is_hidden", False)
205
+ ],
206
+ "total_test_cases": len(q.get("test_cases", [])),
207
+ "hidden_test_cases_count": len([
208
+ tc for tc in q.get("test_cases", [])
209
+ if tc.get("is_hidden", False)
210
+ ])
211
+ }
212
+ filtered_questions.append(filtered_q)
213
+
214
+ return {"questions": filtered_questions}
215
+
216
+
217
+ @app.get("/api/rooms/{room_id}/questions/full")
218
+ async def get_questions_full(room_id: str):
219
+ """Get all questions including hidden test cases (for teacher use)."""
220
+ questions = await db.get_questions(room_id)
221
+ return {"questions": questions}
222
+
223
+
224
+ # ============================================================================
225
+ # WORKSHEET ENDPOINTS
226
+ # ============================================================================
227
+
228
+ @app.get("/api/worksheets/{room_id}/{student_id}/{question_id}")
229
+ async def get_worksheet(room_id: str, student_id: str, question_id: str):
230
+ """Get or create a worksheet"""
231
+ ws = await db.get_worksheet(room_id, student_id, question_id)
232
+
233
+ if ws:
234
+ pass
235
+ else:
236
+ room = await db.get_room(room_id)
237
+ language = "Python" # Default
238
+ if room:
239
+ for q in room.get("questions", []):
240
+ if q["question_id"] == question_id:
241
+ language = q.get("language", "Python")
242
+ break
243
+
244
+ ws = await db.create_worksheet(room_id, student_id, question_id, language)
245
+
246
+ return {
247
+ "worksheet_id": ws["worksheet_id"],
248
+ "code": ws["code"],
249
+ "language": ws["language"],
250
+ "status": ws["status"],
251
+ "last_updated": ws["last_updated"]
252
+ }
253
+
254
+
255
+ @app.post("/api/worksheets/{worksheet_id}/save")
256
+ async def save_worksheet(worksheet_id: str, data: dict):
257
+ """Save worksheet code. BLOCKED if room is expired."""
258
+ # Check if the worksheet's room is still active
259
+ ws = await db.get_worksheet_by_id(worksheet_id)
260
+ if not ws:
261
+ raise HTTPException(status_code=404, detail="Worksheet not found")
262
+
263
+ room = await db.get_room(ws["room_id"])
264
+ if room and (room.get("status") == "expired" or db.is_room_expired(room)):
265
+ if room.get("status") != "expired":
266
+ await db.expire_room(room["room_id"])
267
+ raise HTTPException(status_code=403, detail="Exam has ended. Cannot save code.")
268
+
269
+ success = await db.save_worksheet(worksheet_id, data.get("code", ""))
270
+
271
+ if not success:
272
+ return {"success": True, "message": "Code saved (no changes)"}
273
+
274
+ return {"success": True, "message": "Code saved"}
275
+
276
+
277
+ # ============================================================================
278
+ # CODE EXECUTION ENDPOINT
279
+ # ============================================================================
280
+
281
+ @app.post("/api/execute")
282
+ async def execute_code(data: dict):
283
+ """Execute code (freeform run, no test case checking)"""
284
+ code = data.get("code")
285
+ language = data.get("language", "python").lower()
286
+
287
+ result = code_executor.execute(code, language)
288
+
289
+ return result
290
+
291
+
292
+ # ============================================================================
293
+ # SUBMISSION / AUTO-JUDGE ENDPOINT (WITH SCORING)
294
+ # ============================================================================
295
+
296
+ @app.post("/api/submit")
297
+ async def submit_solution(data: dict):
298
+ """
299
+ Submit a solution for judging against all test cases.
300
+ Returns per-case results AND a score.
301
+
302
+ Score = (passed_cases / total_cases) * MAX_SCORE_PER_QUESTION
303
+
304
+ BLOCKED if room is expired.
305
+ """
306
+ code = data.get("code", "")
307
+ language = data.get("language", "python").lower()
308
+ room_id = data.get("room_id")
309
+ question_id = data.get("question_id")
310
+ student_id = data.get("student_id")
311
+
312
+ if not all([code, room_id, question_id, student_id]):
313
+ raise HTTPException(status_code=400, detail="Missing required fields")
314
+
315
+ # Security: check room is active
316
+ room = await db.get_room(room_id)
317
+ if not room:
318
+ raise HTTPException(status_code=404, detail="Room not found")
319
+
320
+ if room.get("status") == "expired" or db.is_room_expired(room):
321
+ if room.get("status") != "expired":
322
+ await db.expire_room(room_id)
323
+ raise HTTPException(status_code=403, detail="Exam has ended. Cannot submit solutions.")
324
+
325
+ # Get the question with ALL test cases (including hidden)
326
+ question = await db.get_question(room_id, question_id)
327
+ if not question:
328
+ raise HTTPException(status_code=404, detail="Question not found")
329
+
330
+ test_cases = question.get("test_cases", [])
331
+ if not test_cases:
332
+ raise HTTPException(status_code=400, detail="No test cases defined for this question")
333
+
334
+ # Run code against each test case
335
+ results = []
336
+ passed_count = 0
337
+ has_error = False
338
+
339
+ for i, tc in enumerate(test_cases):
340
+ tc_result = code_executor.execute_with_test_case(
341
+ code=code,
342
+ language=language,
343
+ input_data=tc.get("input_data", ""),
344
+ expected_output=tc.get("expected_output", "")
345
+ )
346
+
347
+ is_hidden = tc.get("is_hidden", False)
348
+
349
+ case_result = {
350
+ "case_number": i + 1,
351
+ "is_hidden": is_hidden,
352
+ "passed": tc_result["passed"],
353
+ "status": tc_result["status"],
354
+ }
355
+
356
+ # For example (visible) test cases, show full details
357
+ if not is_hidden:
358
+ case_result["input_data"] = tc.get("input_data", "")
359
+ case_result["expected_output"] = tc_result["expected_output"]
360
+ case_result["actual_output"] = tc_result["actual_output"]
361
+
362
+ if tc_result.get("error"):
363
+ case_result["error"] = tc_result["error"] if not is_hidden else "Runtime Error"
364
+ has_error = True
365
+
366
+ if tc_result["passed"]:
367
+ passed_count += 1
368
+
369
+ results.append(case_result)
370
+
371
+ # Determine overall verdict
372
+ total = len(test_cases)
373
+ if passed_count == total:
374
+ overall = "Accepted"
375
+ elif has_error:
376
+ overall = "Runtime Error"
377
+ else:
378
+ overall = "Wrong Answer"
379
+
380
+ # Calculate score
381
+ score = (passed_count / total) * MAX_SCORE_PER_QUESTION if total > 0 else 0
382
+
383
+ submission_result = {
384
+ "question_id": question_id,
385
+ "total_cases": total,
386
+ "passed_cases": passed_count,
387
+ "results": results,
388
+ "overall": overall,
389
+ "score": round(score, 1),
390
+ "max_score": MAX_SCORE_PER_QUESTION,
391
+ "submitted_at": datetime.now().isoformat()
392
+ }
393
+
394
+ # Save submission result to worksheet
395
+ try:
396
+ ws = await db.get_worksheet(room_id, student_id, question_id)
397
+ if ws:
398
+ await db.save_worksheet(ws["worksheet_id"], code)
399
+ await db.save_submission_result(ws["worksheet_id"], submission_result)
400
+ except Exception as e:
401
+ print(f"Warning: Could not save submission result: {e}")
402
+
403
+ return submission_result
404
+
405
+
406
+ # ============================================================================
407
+ # SCORES ENDPOINT
408
+ # ============================================================================
409
+
410
+ @app.get("/api/rooms/{room_id}/scores")
411
+ async def get_room_scores(room_id: str):
412
+ """
413
+ Get aggregated scores for all students in a room.
414
+ Returns per-student, per-question scores based on their best submission.
415
+ """
416
+ room = await db.get_room(room_id)
417
+ if not room:
418
+ raise HTTPException(status_code=404, detail="Room not found")
419
+
420
+ questions = room.get("questions", [])
421
+ students = room.get("students", [])
422
+ student_names = room.get("student_names", {})
423
+ student_red_flags = room.get("student_red_flags", {})
424
+
425
+ # Get all worksheets for the room
426
+ all_worksheets = await db.get_all_worksheets_for_room(room_id)
427
+
428
+ # Build lookup: (student_id, question_id) -> best submission
429
+ ws_lookup = {}
430
+ for ws in all_worksheets:
431
+ key = (ws["student_id"], ws["question_id"])
432
+ ws_lookup[key] = ws
433
+
434
+ scores_data = []
435
+ for sid in students:
436
+ student_entry = {
437
+ "student_id": sid,
438
+ "student_name": student_names.get(sid, "Unknown"),
439
+ "red_flags": student_red_flags.get(sid, 0),
440
+ "questions": [],
441
+ "total_score": 0,
442
+ "max_total": len(questions) * MAX_SCORE_PER_QUESTION
443
+ }
444
+
445
+ for q in questions:
446
+ qid = q["question_id"]
447
+ ws = ws_lookup.get((sid, qid))
448
+
449
+ best_score = 0
450
+ status = "not_attempted"
451
+
452
+ if ws and ws.get("submission_results"):
453
+ # Find best submission score
454
+ for sub in ws["submission_results"]:
455
+ sub_score = sub.get("score", 0)
456
+ if sub_score > best_score:
457
+ best_score = sub_score
458
+ if sub.get("overall") == "Accepted":
459
+ status = "accepted"
460
+ elif status != "accepted":
461
+ status = "attempted"
462
+
463
+ student_entry["questions"].append({
464
+ "question_id": qid,
465
+ "score": best_score,
466
+ "max_score": MAX_SCORE_PER_QUESTION,
467
+ "status": status
468
+ })
469
+ student_entry["total_score"] += best_score
470
+
471
+ scores_data.append(student_entry)
472
+
473
+ # Sort by total score descending
474
+ scores_data.sort(key=lambda x: x["total_score"], reverse=True)
475
+
476
+ return {
477
+ "room_id": room_id,
478
+ "room_name": room.get("room_name"),
479
+ "max_score_per_question": MAX_SCORE_PER_QUESTION,
480
+ "total_questions": len(questions),
481
+ "total_students": len(students),
482
+ "scores": scores_data
483
+ }
484
+
485
+
486
+ # ============================================================================
487
+ # PDF REPORT ENDPOINT
488
+ # ============================================================================
489
+
490
+ @app.get("/api/rooms/{room_id}/report")
491
+ async def generate_report(room_id: str):
492
+ """
493
+ Generate and download a PDF exam report with scores and violations.
494
+ Uses ReportLab library.
495
+ """
496
+ room = await db.get_room(room_id)
497
+ if not room:
498
+ raise HTTPException(status_code=404, detail="Room not found")
499
+
500
+ questions = room.get("questions", [])
501
+ students = room.get("students", [])
502
+ student_names = room.get("student_names", {})
503
+ student_red_flags = room.get("student_red_flags", {})
504
+
505
+ # Get all worksheets for scoring
506
+ all_worksheets = await db.get_all_worksheets_for_room(room_id)
507
+
508
+ ws_lookup = {}
509
+ for ws in all_worksheets:
510
+ key = (ws["student_id"], ws["question_id"])
511
+ ws_lookup[key] = ws
512
+
513
+ # Build scores data for report
514
+ scores_data = []
515
+ for sid in students:
516
+ student_entry = {
517
+ "student_id": sid,
518
+ "student_name": student_names.get(sid, "Unknown"),
519
+ "red_flags": student_red_flags.get(sid, 0),
520
+ "questions": [],
521
+ "total_score": 0,
522
+ "max_total": len(questions) * MAX_SCORE_PER_QUESTION
523
+ }
524
+
525
+ for q in questions:
526
+ qid = q["question_id"]
527
+ ws = ws_lookup.get((sid, qid))
528
+
529
+ best_score = 0
530
+ status = "not_attempted"
531
+
532
+ if ws and ws.get("submission_results"):
533
+ for sub in ws["submission_results"]:
534
+ sub_score = sub.get("score", 0)
535
+ if sub_score > best_score:
536
+ best_score = sub_score
537
+ if sub.get("overall") == "Accepted":
538
+ status = "accepted"
539
+ elif status != "accepted":
540
+ status = "attempted"
541
+
542
+ student_entry["questions"].append({
543
+ "question_id": qid,
544
+ "score": best_score,
545
+ "max_score": MAX_SCORE_PER_QUESTION,
546
+ "status": status
547
+ })
548
+ student_entry["total_score"] += best_score
549
+
550
+ scores_data.append(student_entry)
551
+
552
+ # Sort by total score descending
553
+ scores_data.sort(key=lambda x: x["total_score"], reverse=True)
554
+
555
+ # Generate PDF
556
+ pdf_bytes = generate_exam_report(
557
+ room_data=room,
558
+ scores_data=scores_data,
559
+ questions=questions
560
+ )
561
+
562
+ # Return as downloadable PDF
563
+ filename = f"exam_report_{room.get('room_name', 'report').replace(' ', '_')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
564
+
565
+ return Response(
566
+ content=pdf_bytes,
567
+ media_type="application/pdf",
568
+ headers={
569
+ "Content-Disposition": f"attachment; filename={filename}"
570
+ }
571
+ )
572
+
573
+
574
+ # ============================================================================
575
+ # LIVE CODE MONITOR ENDPOINT
576
+ # ============================================================================
577
+
578
+ @app.get("/api/rooms/{room_id}/student-codes")
579
+ async def get_student_codes(room_id: str):
580
+ """Get all student codes in a room with student NAMES"""
581
+ room = await db.get_room(room_id)
582
+ if not room:
583
+ raise HTTPException(status_code=404, detail="Room not found")
584
+
585
+ student_codes = await db.get_student_codes_with_names(room_id)
586
+
587
+ return {
588
+ "room_id": room_id,
589
+ "student_codes": student_codes,
590
+ "timestamp": datetime.now().isoformat()
591
+ }
592
+
593
+
594
+ if __name__ == "__main__":
595
+ import uvicorn
596
+ uvicorn.run(app, host="0.0.0.0", port=8000)
backend/models.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import List, Optional, Dict
3
+
4
+ class TestCase(BaseModel):
5
+ test_id: str
6
+ input_data: str # stdin input to feed the program
7
+ expected_output: str # expected stdout output (stripped for comparison)
8
+ is_hidden: bool = False # True = hidden from students, used only for judging
9
+
10
+ class Room(BaseModel):
11
+ room_id: str
12
+ room_code: str
13
+ room_name: str
14
+ teacher_name: str
15
+ language: str
16
+ duration_minutes: int
17
+ start_time: Optional[str] = None
18
+ end_time: Optional[str] = None
19
+ students: List[str] = []
20
+ student_names: Dict[str, str] = {}
21
+ student_red_flags: Dict[str, int] = {}
22
+ questions: List[dict] = []
23
+ status: str = "active"
24
+
25
+ class Question(BaseModel):
26
+ question_id: str
27
+ question_text: str
28
+ language: str
29
+ test_cases: List[TestCase] = []
30
+
31
+ class Worksheet(BaseModel):
32
+ worksheet_id: str
33
+ room_id: str
34
+ student_id: str
35
+ question_id: str
36
+ code: str = ""
37
+ language: str
38
+ status: str = "working"
39
+ last_updated: str
40
+
41
+ class ExecutionResult(BaseModel):
42
+ success: bool
43
+ output: Optional[str] = None
44
+ error: Optional[str] = None
45
+
46
+ class CodeExecutionRequest(BaseModel):
47
+ code: str
48
+ language: str
49
+
50
+ class SubmissionResult(BaseModel):
51
+ question_id: str
52
+ total_cases: int
53
+ passed_cases: int
54
+ results: List[dict] = [] # per-case verdict details
55
+ overall: str # "Accepted" / "Wrong Answer" / "Runtime Error" / "Compilation Error"
backend/report_generator.py ADDED
@@ -0,0 +1,432 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PDF Report Generator for Online Exam IDE
3
+ Uses ReportLab to generate exam reports with student scores and violations.
4
+ """
5
+
6
+ import io
7
+ from datetime import datetime
8
+ from reportlab.lib import colors
9
+ from reportlab.lib.pagesizes import A4, landscape
10
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
11
+ from reportlab.lib.units import inch, mm
12
+ from reportlab.platypus import (
13
+ SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer,
14
+ HRFlowable, PageBreak
15
+ )
16
+ from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
17
+
18
+
19
+ def generate_exam_report(room_data: dict, scores_data: list, questions: list) -> bytes:
20
+ """
21
+ Generate a PDF exam report.
22
+
23
+ Args:
24
+ room_data: Room details (name, teacher, duration, start/end, etc.)
25
+ scores_data: List of dicts per student:
26
+ {
27
+ "student_id": str,
28
+ "student_name": str,
29
+ "red_flags": int,
30
+ "questions": [
31
+ {"question_id": str, "score": float, "max_score": float, "status": str}
32
+ ],
33
+ "total_score": float,
34
+ "max_total": float
35
+ }
36
+ questions: List of question dicts with question_text and question_id
37
+
38
+ Returns:
39
+ PDF file as bytes
40
+ """
41
+ buffer = io.BytesIO()
42
+ doc = SimpleDocTemplate(
43
+ buffer,
44
+ pagesize=landscape(A4),
45
+ rightMargin=30,
46
+ leftMargin=30,
47
+ topMargin=30,
48
+ bottomMargin=30,
49
+ title=f"Exam Report - {room_data.get('room_name', 'Unknown')}",
50
+ author=room_data.get("teacher_name", "Teacher")
51
+ )
52
+
53
+ styles = getSampleStyleSheet()
54
+ elements = []
55
+
56
+ # ============================
57
+ # Custom styles
58
+ # ============================
59
+ title_style = ParagraphStyle(
60
+ 'CustomTitle',
61
+ parent=styles['Title'],
62
+ fontSize=22,
63
+ spaceAfter=6,
64
+ textColor=colors.HexColor("#1a1a2e"),
65
+ fontName="Helvetica-Bold"
66
+ )
67
+ subtitle_style = ParagraphStyle(
68
+ 'CustomSubtitle',
69
+ parent=styles['Normal'],
70
+ fontSize=12,
71
+ spaceAfter=4,
72
+ textColor=colors.HexColor("#555555"),
73
+ fontName="Helvetica"
74
+ )
75
+ heading_style = ParagraphStyle(
76
+ 'CustomHeading',
77
+ parent=styles['Heading2'],
78
+ fontSize=14,
79
+ spaceBefore=12,
80
+ spaceAfter=6,
81
+ textColor=colors.HexColor("#16213e"),
82
+ fontName="Helvetica-Bold"
83
+ )
84
+ small_style = ParagraphStyle(
85
+ 'SmallText',
86
+ parent=styles['Normal'],
87
+ fontSize=8,
88
+ textColor=colors.HexColor("#888888"),
89
+ )
90
+
91
+ # ============================
92
+ # HEADER
93
+ # ============================
94
+ elements.append(Paragraph(f"📋 Exam Report", title_style))
95
+ elements.append(Paragraph(
96
+ f"<b>{room_data.get('room_name', 'Untitled Exam')}</b>",
97
+ subtitle_style
98
+ ))
99
+ elements.append(Spacer(1, 4))
100
+
101
+ # Exam info table
102
+ start_time = room_data.get("start_time", "N/A")
103
+ end_time = room_data.get("end_time", "N/A")
104
+ if start_time and start_time != "N/A":
105
+ try:
106
+ start_time = datetime.fromisoformat(start_time).strftime("%Y-%m-%d %H:%M")
107
+ except:
108
+ pass
109
+ if end_time and end_time != "N/A":
110
+ try:
111
+ end_time = datetime.fromisoformat(end_time).strftime("%Y-%m-%d %H:%M")
112
+ except:
113
+ pass
114
+
115
+ info_data = [
116
+ ["Teacher", room_data.get("teacher_name", "N/A"),
117
+ "Room Code", room_data.get("room_code", "N/A"),
118
+ "Language", room_data.get("language", "N/A")],
119
+ ["Duration", f"{room_data.get('duration_minutes', 'N/A')} min",
120
+ "Start Time", str(start_time),
121
+ "End Time", str(end_time)],
122
+ ["Total Students", str(len(room_data.get("students", []))),
123
+ "Total Questions", str(len(questions)),
124
+ "Status", room_data.get("status", "N/A").upper()],
125
+ ]
126
+
127
+ info_table = Table(info_data, colWidths=[80, 140, 80, 140, 80, 140])
128
+ info_table.setStyle(TableStyle([
129
+ ('FONTNAME', (0, 0), (-1, -1), 'Helvetica'),
130
+ ('FONTSIZE', (0, 0), (-1, -1), 9),
131
+ ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
132
+ ('FONTNAME', (2, 0), (2, -1), 'Helvetica-Bold'),
133
+ ('FONTNAME', (4, 0), (4, -1), 'Helvetica-Bold'),
134
+ ('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor("#333333")),
135
+ ('TEXTCOLOR', (2, 0), (2, -1), colors.HexColor("#333333")),
136
+ ('TEXTCOLOR', (4, 0), (4, -1), colors.HexColor("#333333")),
137
+ ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
138
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
139
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor("#cccccc")),
140
+ ('BACKGROUND', (0, 0), (0, -1), colors.HexColor("#f0f0f0")),
141
+ ('BACKGROUND', (2, 0), (2, -1), colors.HexColor("#f0f0f0")),
142
+ ('BACKGROUND', (4, 0), (4, -1), colors.HexColor("#f0f0f0")),
143
+ ('TOPPADDING', (0, 0), (-1, -1), 4),
144
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 4),
145
+ ('LEFTPADDING', (0, 0), (-1, -1), 6),
146
+ ]))
147
+ elements.append(info_table)
148
+ elements.append(Spacer(1, 12))
149
+ elements.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor("#16213e")))
150
+ elements.append(Spacer(1, 8))
151
+
152
+ # ============================
153
+ # SCORES TABLE
154
+ # ============================
155
+ elements.append(Paragraph("📊 Student Scores & Violations", heading_style))
156
+ elements.append(Spacer(1, 4))
157
+
158
+ # Build header row
159
+ q_headers = []
160
+ for i, q in enumerate(questions):
161
+ q_text = q.get("question_text", f"Q{i+1}")
162
+ # Truncate long question text
163
+ if len(q_text) > 25:
164
+ q_text = q_text[:22] + "..."
165
+ q_headers.append(f"Q{i+1}")
166
+
167
+ header_row = ["#", "Student Name"] + q_headers + ["Total Score", "Percentage", "Violations", "Grade"]
168
+
169
+ # Build data rows
170
+ table_data = [header_row]
171
+
172
+ for idx, student in enumerate(scores_data):
173
+ row = [
174
+ str(idx + 1),
175
+ student.get("student_name", "Unknown"),
176
+ ]
177
+
178
+ # Per-question scores
179
+ for q_score in student.get("questions", []):
180
+ score = q_score.get("score", 0)
181
+ max_s = q_score.get("max_score", 100)
182
+ status = q_score.get("status", "not_attempted")
183
+
184
+ if status == "accepted":
185
+ cell = f"{score:.0f}/{max_s:.0f} ✓"
186
+ elif status == "attempted":
187
+ cell = f"{score:.0f}/{max_s:.0f}"
188
+ else:
189
+ cell = "—"
190
+ row.append(cell)
191
+
192
+ # Total score
193
+ total = student.get("total_score", 0)
194
+ max_total = student.get("max_total", 0)
195
+ percentage = (total / max_total * 100) if max_total > 0 else 0
196
+
197
+ row.append(f"{total:.0f}/{max_total:.0f}")
198
+ row.append(f"{percentage:.1f}%")
199
+
200
+ # Violations
201
+ red_flags = student.get("red_flags", 0)
202
+ flag_text = f"🚩 {red_flags}" if red_flags > 0 else "✅ 0"
203
+ row.append(flag_text)
204
+
205
+ # Grade
206
+ if percentage >= 90:
207
+ grade = "A+"
208
+ elif percentage >= 80:
209
+ grade = "A"
210
+ elif percentage >= 70:
211
+ grade = "B"
212
+ elif percentage >= 60:
213
+ grade = "C"
214
+ elif percentage >= 50:
215
+ grade = "D"
216
+ else:
217
+ grade = "F"
218
+
219
+ # Flag if violations exist
220
+ if red_flags >= 5:
221
+ grade += " ⚠"
222
+
223
+ row.append(grade)
224
+ table_data.append(row)
225
+
226
+ # Calculate column widths dynamically
227
+ num_questions = len(questions)
228
+ q_col_width = max(40, min(60, (500 - 200) // max(num_questions, 1)))
229
+ col_widths = [25, 120] + [q_col_width] * num_questions + [70, 60, 60, 45]
230
+
231
+ scores_table = Table(table_data, colWidths=col_widths, repeatRows=1)
232
+
233
+ # Style the table
234
+ table_style_cmds = [
235
+ # Header
236
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
237
+ ('FONTSIZE', (0, 0), (-1, 0), 8),
238
+ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor("#16213e")),
239
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
240
+ ('ALIGN', (0, 0), (-1, 0), 'CENTER'),
241
+
242
+ # Body
243
+ ('FONTNAME', (0, 1), (-1, -1), 'Helvetica'),
244
+ ('FONTSIZE', (0, 1), (-1, -1), 8),
245
+ ('ALIGN', (0, 1), (0, -1), 'CENTER'),
246
+ ('ALIGN', (2, 1), (-1, -1), 'CENTER'),
247
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
248
+
249
+ # Grid
250
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor("#cccccc")),
251
+ ('TOPPADDING', (0, 0), (-1, -1), 4),
252
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 4),
253
+ ('LEFTPADDING', (0, 0), (-1, -1), 3),
254
+ ('RIGHTPADDING', (0, 0), (-1, -1), 3),
255
+ ]
256
+
257
+ # Alternating row colors
258
+ for i in range(1, len(table_data)):
259
+ if i % 2 == 0:
260
+ table_style_cmds.append(
261
+ ('BACKGROUND', (0, i), (-1, i), colors.HexColor("#f8f9fa"))
262
+ )
263
+
264
+ # Highlight violation cells
265
+ for i in range(1, len(table_data)):
266
+ student = scores_data[i - 1]
267
+ red_flags = student.get("red_flags", 0)
268
+ violations_col = len(header_row) - 2 # violations column index
269
+ if red_flags > 0:
270
+ table_style_cmds.append(
271
+ ('TEXTCOLOR', (violations_col, i), (violations_col, i), colors.red)
272
+ )
273
+ if red_flags >= 5:
274
+ table_style_cmds.append(
275
+ ('BACKGROUND', (violations_col, i), (violations_col, i), colors.HexColor("#ffe0e0"))
276
+ )
277
+
278
+ scores_table.setStyle(TableStyle(table_style_cmds))
279
+ elements.append(scores_table)
280
+ elements.append(Spacer(1, 12))
281
+
282
+ # ============================
283
+ # SUMMARY STATISTICS
284
+ # ============================
285
+ elements.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#cccccc")))
286
+ elements.append(Spacer(1, 6))
287
+ elements.append(Paragraph("📈 Summary Statistics", heading_style))
288
+
289
+ if scores_data:
290
+ all_percentages = []
291
+ for s in scores_data:
292
+ mt = s.get("max_total", 0)
293
+ ts = s.get("total_score", 0)
294
+ pct = (ts / mt * 100) if mt > 0 else 0
295
+ all_percentages.append(pct)
296
+
297
+ avg_score = sum(all_percentages) / len(all_percentages)
298
+ max_score = max(all_percentages)
299
+ min_score = min(all_percentages)
300
+ passed = sum(1 for p in all_percentages if p >= 50)
301
+ failed = len(all_percentages) - passed
302
+ total_violations = sum(s.get("red_flags", 0) for s in scores_data)
303
+ students_with_violations = sum(1 for s in scores_data if s.get("red_flags", 0) > 0)
304
+
305
+ summary_data = [
306
+ ["Metric", "Value"],
307
+ ["Average Score", f"{avg_score:.1f}%"],
308
+ ["Highest Score", f"{max_score:.1f}%"],
309
+ ["Lowest Score", f"{min_score:.1f}%"],
310
+ ["Passed (≥50%)", f"{passed}/{len(scores_data)}"],
311
+ ["Failed (<50%)", f"{failed}/{len(scores_data)}"],
312
+ ["Total Violations", str(total_violations)],
313
+ ["Students with Violations", f"{students_with_violations}/{len(scores_data)}"],
314
+ ]
315
+
316
+ summary_table = Table(summary_data, colWidths=[180, 120])
317
+ summary_table.setStyle(TableStyle([
318
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
319
+ ('FONTSIZE', (0, 0), (-1, -1), 9),
320
+ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor("#16213e")),
321
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
322
+ ('FONTNAME', (0, 1), (0, -1), 'Helvetica-Bold'),
323
+ ('ALIGN', (1, 0), (1, -1), 'CENTER'),
324
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor("#cccccc")),
325
+ ('TOPPADDING', (0, 0), (-1, -1), 4),
326
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 4),
327
+ ('LEFTPADDING', (0, 0), (-1, -1), 6),
328
+ ]))
329
+ elements.append(summary_table)
330
+ else:
331
+ elements.append(Paragraph("No student data available.", styles['Normal']))
332
+
333
+ # ============================
334
+ # QUESTION DETAILS
335
+ # ============================
336
+ elements.append(Spacer(1, 12))
337
+ elements.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#cccccc")))
338
+ elements.append(Paragraph("📝 Questions", heading_style))
339
+
340
+ for i, q in enumerate(questions):
341
+ q_text = q.get("question_text", "N/A")
342
+ tc_count = len(q.get("test_cases", []))
343
+ elements.append(Paragraph(
344
+ f"<b>Q{i+1}:</b> {q_text} <i>({tc_count} test cases)</i>",
345
+ styles['Normal']
346
+ ))
347
+ elements.append(Spacer(1, 3))
348
+
349
+ # ============================
350
+ # VIOLATION DETAILS
351
+ # ============================
352
+ flagged_students = [s for s in scores_data if s.get("red_flags", 0) > 0]
353
+ if flagged_students:
354
+ elements.append(Spacer(1, 12))
355
+ elements.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#cccccc")))
356
+ elements.append(Paragraph("🚩 Violation Report", heading_style))
357
+ elements.append(Paragraph(
358
+ "The following students had security violations during the exam:",
359
+ styles['Normal']
360
+ ))
361
+ elements.append(Spacer(1, 4))
362
+
363
+ violation_data = [["#", "Student Name", "Violations", "Severity", "Recommendation"]]
364
+ for idx, s in enumerate(flagged_students):
365
+ flags = s.get("red_flags", 0)
366
+ if flags >= 10:
367
+ severity = "CRITICAL"
368
+ recommendation = "Disqualify / Manual Review"
369
+ elif flags >= 5:
370
+ severity = "HIGH"
371
+ recommendation = "Flag for Review"
372
+ elif flags >= 3:
373
+ severity = "MEDIUM"
374
+ recommendation = "Warning Issued"
375
+ else:
376
+ severity = "LOW"
377
+ recommendation = "Monitor"
378
+
379
+ violation_data.append([
380
+ str(idx + 1),
381
+ s.get("student_name", "Unknown"),
382
+ str(flags),
383
+ severity,
384
+ recommendation
385
+ ])
386
+
387
+ viol_table = Table(violation_data, colWidths=[25, 150, 70, 80, 160])
388
+ viol_table.setStyle(TableStyle([
389
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
390
+ ('FONTSIZE', (0, 0), (-1, -1), 9),
391
+ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor("#8b0000")),
392
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
393
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
394
+ ('ALIGN', (1, 1), (1, -1), 'LEFT'),
395
+ ('ALIGN', (4, 1), (4, -1), 'LEFT'),
396
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor("#cccccc")),
397
+ ('TOPPADDING', (0, 0), (-1, -1), 4),
398
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 4),
399
+ ]))
400
+
401
+ # Color severity cells
402
+ for i in range(1, len(violation_data)):
403
+ severity = violation_data[i][3]
404
+ if severity == "CRITICAL":
405
+ viol_table.setStyle(TableStyle([
406
+ ('BACKGROUND', (3, i), (3, i), colors.HexColor("#ffcccc")),
407
+ ('TEXTCOLOR', (3, i), (3, i), colors.red),
408
+ ]))
409
+ elif severity == "HIGH":
410
+ viol_table.setStyle(TableStyle([
411
+ ('BACKGROUND', (3, i), (3, i), colors.HexColor("#ffe0cc")),
412
+ ('TEXTCOLOR', (3, i), (3, i), colors.HexColor("#cc6600")),
413
+ ]))
414
+
415
+ elements.append(viol_table)
416
+
417
+ # ============================
418
+ # FOOTER
419
+ # ============================
420
+ elements.append(Spacer(1, 20))
421
+ elements.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#cccccc")))
422
+ elements.append(Spacer(1, 4))
423
+ elements.append(Paragraph(
424
+ f"Generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | Online Exam IDE | Confidential",
425
+ small_style
426
+ ))
427
+
428
+ # Build PDF
429
+ doc.build(elements)
430
+ pdf_bytes = buffer.getvalue()
431
+ buffer.close()
432
+ return pdf_bytes
backend/requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ websockets
4
+ python-dotenv
5
+ pydantic
6
+ sqlalchemy
7
+ aiofiles
8
+ requests
9
+ python-multipart
10
+ motor
11
+ dnspython
12
+ certifi
13
+ reportlab
backend/room_manager.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from database import db
2
+ from models import Room, Question, Session, Worksheet
3
+ from typing import List, Optional
4
+ import uuid
5
+
6
+
7
+ class RoomManager:
8
+ """Room and exam management logic"""
9
+
10
+ @staticmethod
11
+ def create_exam_room(room_name: str, teacher_id: str, teacher_name: str, max_students: int = 50) -> Room:
12
+ """Create a new exam room"""
13
+ room = Room(
14
+ room_name=room_name,
15
+ teacher_id=teacher_id,
16
+ teacher_name=teacher_name,
17
+ max_students=max_students
18
+ )
19
+ return db.create_room(room)
20
+
21
+ @staticmethod
22
+ def get_room(room_id: str) -> Optional[Room]:
23
+ """Get room by ID"""
24
+ return db.get_room(room_id)
25
+
26
+ @staticmethod
27
+ def join_room(room_id: str, student_id: str) -> bool:
28
+ """Add student to room"""
29
+ return db.add_student_to_room(room_id, student_id)
30
+
31
+ @staticmethod
32
+ def create_question(room_id: str, title: str, description: str, allowed_languages: List[str] = None) -> Question:
33
+ """Create a question in a room"""
34
+ if allowed_languages is None:
35
+ allowed_languages = ["python", "javascript", "java", "cpp"]
36
+
37
+ question = Question(
38
+ room_id=room_id,
39
+ title=title,
40
+ description=description,
41
+ allowed_languages=allowed_languages
42
+ )
43
+ return db.create_question(question)
44
+
45
+ @staticmethod
46
+ def get_room_questions(room_id: str) -> List[Question]:
47
+ """Get all questions in a room"""
48
+ return db.get_room_questions(room_id)
49
+
50
+ @staticmethod
51
+ def get_student_worksheet(room_id: str, student_id: str, question_id: str) -> Worksheet:
52
+ """Get or create worksheet for student"""
53
+ return db.get_or_create_worksheet(room_id, student_id, question_id)
54
+
55
+ @staticmethod
56
+ def save_worksheet(worksheet: Worksheet):
57
+ """Save worksheet code"""
58
+ db.update_worksheet(worksheet)
59
+
60
+ @staticmethod
61
+ def get_room_students(room_id: str) -> List[str]:
62
+ """Get students in room"""
63
+ room = db.get_room(room_id)
64
+ return room.students if room else []
65
+
66
+ @staticmethod
67
+ def get_student_progress(room_id: str, student_id: str) -> dict:
68
+ """Get student's progress in all questions"""
69
+ questions = db.get_room_questions(room_id)
70
+ worksheets = db.get_student_worksheets(room_id, student_id)
71
+
72
+ ws_map = {ws.question_id: ws for ws in worksheets}
73
+
74
+ progress = {
75
+ "total_questions": len(questions),
76
+ "questions": []
77
+ }
78
+
79
+ for q in questions:
80
+ ws = ws_map.get(q.question_id)
81
+ progress["questions"].append({
82
+ "question_id": q.question_id,
83
+ "title": q.title,
84
+ "status": ws.status if ws else "not_started",
85
+ "submissions": ws.submission_count if ws else 0,
86
+ "last_edited": ws.last_edited_timestamp if ws else None
87
+ })
88
+
89
+ return progress
docker-compose.yml ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # Online Exam IDE — Docker Compose
3
+ # ============================================================================
4
+ #
5
+ # IMPORTANT (MongoDB Atlas IP Whitelisting):
6
+ # MongoDB Atlas requires your IP address to be whitelisted before allowing
7
+ # connections. When running in Docker (locally or on a server), you must:
8
+ #
9
+ # 1. Go to MongoDB Atlas → Network Access → Add IP Address
10
+ # 2. Either add your server/host IP, OR use 0.0.0.0/0 to allow all IPs
11
+ # (suitable for development; restrict in production)
12
+ # 3. The backend container uses certifi for TLS and dnspython for SRV
13
+ # record resolution, so Atlas SRV connection strings work correctly.
14
+ #
15
+ # Usage:
16
+ # docker-compose up --build # Build and start
17
+ # docker-compose up -d # Detached mode
18
+ # docker-compose down # Stop and remove
19
+ # docker-compose logs -f backend # Follow backend logs
20
+ #
21
+ # Access:
22
+ # Frontend: http://localhost:8501
23
+ # Backend: http://localhost:8000
24
+ # API Docs: http://localhost:8000/docs
25
+ # ============================================================================
26
+
27
+ services:
28
+ backend:
29
+ build:
30
+ context: .
31
+ dockerfile: Dockerfile.backend
32
+ container_name: exam-ide-backend
33
+ ports:
34
+ - "8000:8000"
35
+ env_file:
36
+ - backend/.env
37
+ restart: unless-stopped
38
+ healthcheck:
39
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
40
+ interval: 30s
41
+ timeout: 10s
42
+ retries: 3
43
+ start_period: 10s
44
+
45
+ frontend:
46
+ build:
47
+ context: .
48
+ dockerfile: Dockerfile.frontend
49
+ container_name: exam-ide-frontend
50
+ ports:
51
+ - "8501:8501"
52
+ environment:
53
+ - BACKEND_URL=http://backend:8000
54
+ depends_on:
55
+ backend:
56
+ condition: service_healthy
57
+ restart: unless-stopped
frontend/.streamlit/config.toml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [theme]
2
+ primaryColor = "#0066cc"
3
+ backgroundColor = "#ffffff"
4
+ secondaryBackgroundColor = "#f0f2f6"
5
+ textColor = "#262730"
6
+ font = "sans serif"
7
+
8
+ [client]
9
+ showErrorDetails = true
10
+ toolbarMode = "minimal"
11
+
12
+ [server]
13
+ maxUploadSize = 200
14
+ enableXsrfProtection = true
frontend/app.py ADDED
@@ -0,0 +1,1274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import streamlit.components.v1 as components
3
+ import requests
4
+ import json
5
+ import os
6
+ from datetime import datetime
7
+ import time
8
+
9
+ # ============================================================================
10
+ # STEP 4: CODE TEMPLATES CONFIGURATION
11
+ # ============================================================================
12
+
13
+ # Python Templates (8 templates)
14
+ PYTHON_TEMPLATES = {
15
+ "Simple Function": '''def greet(name):
16
+ """A simple greeting function"""
17
+ print(f"Hello, {name}!")
18
+
19
+ greet("World")''',
20
+
21
+ " Function with Math": '''def add_numbers(a, b):
22
+ """Add two numbers and return result"""
23
+ result = a + b
24
+ print(f"{a} + {b} = {result}")
25
+ return result
26
+
27
+ add_numbers(5, 10)''',
28
+
29
+ " For Loop": '''fruits = ["apple", "banana", "orange"]
30
+
31
+ for fruit in fruits:
32
+ print(f"I like {fruit}")''',
33
+
34
+ " If-Else": '''age = 20
35
+
36
+ if age >= 18:
37
+ print("You are an adult")
38
+ else:
39
+ print("You are a minor")''',
40
+
41
+ " List Operations": '''numbers = [1, 2, 3, 4, 5]
42
+
43
+ # Add element
44
+ numbers.append(6)
45
+
46
+ # Remove element
47
+ numbers.remove(3)
48
+
49
+ # Print all
50
+ for num in numbers:
51
+ print(num)''',
52
+
53
+ " While Loop": '''count = 0
54
+
55
+ while count < 5:
56
+ print(f"Count: {count}")
57
+ count += 1''',
58
+
59
+ " List Comprehension": '''# Create a list of squares
60
+ squares = [x**2 for x in range(5)]
61
+ print(squares)
62
+
63
+ # Filter even numbers
64
+ numbers = [x for x in range(10) if x % 2 == 0]
65
+ print(numbers)''',
66
+
67
+ "Dictionary": '''student = {
68
+ "name": "John",
69
+ "age": 20,
70
+ "grade": "A"
71
+ }
72
+
73
+ print(student["name"])
74
+ print(student["grade"])'''
75
+ }
76
+
77
+ # JavaScript Templates (5 templates)
78
+ JAVASCRIPT_TEMPLATES = {
79
+ " Simple Function": '''function greet(name) {
80
+ console.log(`Hello, ${name}!`);
81
+ }
82
+
83
+ greet("World");''',
84
+
85
+ " Function with Math": '''function addNumbers(a, b) {
86
+ let result = a + b;
87
+ console.log(`${a} + ${b} = ${result}`);
88
+ return result;
89
+ }
90
+
91
+ addNumbers(5, 10);''',
92
+
93
+ " For Loop": '''let fruits = ["apple", "banana", "orange"];
94
+
95
+ for (let i = 0; i < fruits.length; i++) {
96
+ console.log(`I like ${fruits[i]}`);
97
+ }''',
98
+
99
+ " If-Else": '''let age = 20;
100
+
101
+ if (age >= 18) {
102
+ console.log("You are an adult");
103
+ } else {
104
+ console.log("You are a minor");
105
+ }''',
106
+
107
+ " Array Operations": '''let numbers = [1, 2, 3, 4, 5];
108
+
109
+ // Add element
110
+ numbers.push(6);
111
+
112
+ // Remove element
113
+ numbers.splice(2, 1);
114
+
115
+ // Print all
116
+ for (let num of numbers) {
117
+ console.log(num);
118
+ }'''
119
+ }
120
+
121
+ # ============================================================================
122
+ # CONFIGURATION
123
+ # ============================================================================
124
+
125
+ BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000")
126
+
127
+
128
+ # ============================================================================
129
+ # API CLIENT
130
+ # ============================================================================
131
+
132
+ class APIClient:
133
+ def __init__(self, base_url):
134
+ self.base_url = base_url
135
+ self.session_id = None
136
+ self.role = None
137
+
138
+ def create_room(self, room_name: str, teacher_name: str, language: str = "Python", duration: int = 30,
139
+ start_time: str = None):
140
+ """Create a new exam room"""
141
+ payload = {
142
+ "room_name": room_name,
143
+ "teacher_name": teacher_name,
144
+ "language": language,
145
+ "duration": duration,
146
+ "session_id": self.session_id
147
+ }
148
+ if start_time:
149
+ payload["start_time"] = start_time
150
+
151
+ response = requests.post(
152
+ f"{self.base_url}/api/rooms/create",
153
+ json=payload
154
+ )
155
+ return response.json()
156
+
157
+ def join_room(self, room_code: str, student_name: str):
158
+ """Join an existing exam room"""
159
+ response = requests.post(
160
+ f"{self.base_url}/api/rooms/{room_code}/join",
161
+ json={
162
+ "student_name": student_name,
163
+ "session_id": self.session_id
164
+ }
165
+ )
166
+ return response.json()
167
+
168
+ def get_room(self, room_id: str):
169
+ """Get room details"""
170
+ response = requests.get(f"{self.base_url}/api/rooms/{room_id}")
171
+ return response.json()
172
+
173
+ def create_question(self, room_id: str, question_text: str, language: str, test_cases: list = None):
174
+ """Create a question in the room with optional test cases"""
175
+ payload = {
176
+ "question_text": question_text,
177
+ "language": language,
178
+ }
179
+ if test_cases:
180
+ payload["test_cases"] = test_cases
181
+
182
+ response = requests.post(
183
+ f"{self.base_url}/api/rooms/{room_id}/questions",
184
+ json=payload
185
+ )
186
+ return response.json()
187
+
188
+ def get_questions(self, room_id: str):
189
+ """Get all questions in a room (hidden test cases filtered out)"""
190
+ response = requests.get(f"{self.base_url}/api/rooms/{room_id}/questions")
191
+ return response.json()
192
+
193
+ def get_questions_full(self, room_id: str):
194
+ """Get all questions including hidden test cases (teacher use)"""
195
+ response = requests.get(f"{self.base_url}/api/rooms/{room_id}/questions/full")
196
+ return response.json()
197
+
198
+ def get_worksheet(self, room_id: str, student_id: str, question_id: str):
199
+ """Get student's worksheet"""
200
+ response = requests.get(
201
+ f"{self.base_url}/api/worksheets/{room_id}/{student_id}/{question_id}"
202
+ )
203
+ return response.json()
204
+
205
+ def save_code(self, worksheet_id: str, code: str):
206
+ """Save student code"""
207
+ response = requests.post(
208
+ f"{self.base_url}/api/worksheets/{worksheet_id}/save",
209
+ json={"code": code}
210
+ )
211
+ return response.json()
212
+
213
+ def execute_code(self, code: str, language: str):
214
+ """Execute code and get output"""
215
+ response = requests.post(
216
+ f"{self.base_url}/api/execute",
217
+ json={
218
+ "code": code,
219
+ "language": language
220
+ }
221
+ )
222
+ return response.json()
223
+
224
+ def submit_solution(self, code: str, language: str, room_id: str, question_id: str, student_id: str):
225
+ """Submit solution for auto-judging against all test cases"""
226
+ response = requests.post(
227
+ f"{self.base_url}/api/submit",
228
+ json={
229
+ "code": code,
230
+ "language": language,
231
+ "room_id": room_id,
232
+ "question_id": question_id,
233
+ "student_id": student_id
234
+ }
235
+ )
236
+ return response.json()
237
+
238
+ def get_student_codes(self, room_id: str):
239
+ """Get all student codes with NAMES in the room (Live Monitor)"""
240
+ response = requests.get(f"{self.base_url}/api/rooms/{room_id}/student-codes")
241
+ return response.json()
242
+
243
+ def get_scores(self, room_id: str):
244
+ """Get aggregated scores for all students"""
245
+ response = requests.get(f"{self.base_url}/api/rooms/{room_id}/scores")
246
+ return response.json()
247
+
248
+ def download_report(self, room_id: str):
249
+ """Download PDF exam report. Returns raw bytes."""
250
+ response = requests.get(f"{self.base_url}/api/rooms/{room_id}/report")
251
+ if response.status_code == 200:
252
+ return response.content
253
+ return None
254
+
255
+ def report_violation(self, room_id: str, student_id: str):
256
+ """Report a security violation"""
257
+ try:
258
+ requests.post(
259
+ f"{self.base_url}/api/rooms/{room_id}/report_violation",
260
+ json={"student_id": student_id}
261
+ )
262
+ except:
263
+ pass
264
+
265
+
266
+ # ============================================================================
267
+ # PAGE FUNCTIONS
268
+ # ============================================================================
269
+
270
+ def teacher_page(api_client):
271
+ """Teacher dashboard"""
272
+ st.title("📋 Teacher Dashboard")
273
+
274
+ # Sidebar for room creation
275
+ with st.sidebar:
276
+ st.header("Create Exam Room")
277
+ room_name = st.text_input("Exam/Assignment Name")
278
+ teacher_name = st.text_input("Your Name")
279
+ language = st.selectbox("Programming Language", ["Python", "JavaScript", "Java", "C++"])
280
+
281
+ # Duration and Start Time
282
+ duration = st.slider("Duration (minutes)", 5, 180, 60, step=5)
283
+
284
+ start_option = st.radio("Start Time", ["Start Now", "Schedule for Later"])
285
+ start_time_iso = None
286
+
287
+ if start_option == "Schedule for Later":
288
+ exam_date = st.date_input("Exam Date", min_value=datetime.now().date())
289
+ exam_time = st.time_input("Exam Time", value=datetime.now().time())
290
+ if exam_date and exam_time:
291
+ start_dt = datetime.combine(exam_date, exam_time)
292
+ start_time_iso = start_dt.isoformat()
293
+ else:
294
+ start_time_iso = datetime.now().isoformat()
295
+
296
+ if st.button("Create Exam Room", key="create_room"):
297
+ if room_name and teacher_name:
298
+ result = api_client.create_room(room_name, teacher_name, language, duration, start_time_iso)
299
+ if "room_id" in result:
300
+ st.session_state.room_id = result["room_id"]
301
+ st.session_state.room_code = result["room_code"]
302
+ st.success(f"Room created! Code: {result['room_code']}")
303
+ else:
304
+ st.error(f"Error: {result.get('detail', 'Unknown error')}")
305
+
306
+ # Display current room
307
+ if "room_id" in st.session_state:
308
+ col1, col2 = st.columns(2)
309
+ with col1:
310
+ st.info(f"🔑 Room Code: `{st.session_state.room_code}`")
311
+ with col2:
312
+ st.info(f"🆔 Room ID: `{st.session_state.room_id}`")
313
+
314
+ # ================================================================
315
+ # CREATE QUESTION WITH TEST CASES
316
+ # ================================================================
317
+ with st.expander("📝 Create New Question", expanded=True):
318
+ question_text = st.text_area("Question Text", height=100,
319
+ placeholder="Write your coding question here...\ne.g., Write a function that reads two integers from stdin and prints their sum.")
320
+ question_lang = st.selectbox("Language", ["Python", "JavaScript", "Java", "C++"], key="q_lang")
321
+
322
+ st.divider()
323
+ st.subheader("🧪 Test Cases")
324
+ st.caption("Add example test cases (visible to students) and hidden test cases (used for judging only).")
325
+
326
+ # Dynamic test case management using session state
327
+ if "test_cases_draft" not in st.session_state:
328
+ st.session_state.test_cases_draft = []
329
+
330
+ # Show existing draft test cases
331
+ cases_to_remove = []
332
+ for idx, tc in enumerate(st.session_state.test_cases_draft):
333
+ with st.container():
334
+ tc_col1, tc_col2, tc_col3 = st.columns([3, 3, 1])
335
+ with tc_col1:
336
+ tc["input_data"] = st.text_area(
337
+ f"Input (Test Case {idx + 1})",
338
+ value=tc.get("input_data", ""),
339
+ key=f"tc_input_{idx}",
340
+ height=80,
341
+ placeholder="e.g., 5 10"
342
+ )
343
+ with tc_col2:
344
+ tc["expected_output"] = st.text_area(
345
+ f"Expected Output (Test Case {idx + 1})",
346
+ value=tc.get("expected_output", ""),
347
+ key=f"tc_output_{idx}",
348
+ height=80,
349
+ placeholder="e.g., 15"
350
+ )
351
+ with tc_col3:
352
+ tc["is_hidden"] = st.checkbox(
353
+ "Hidden?",
354
+ value=tc.get("is_hidden", False),
355
+ key=f"tc_hidden_{idx}",
356
+ help="Hidden test cases are NOT shown to students"
357
+ )
358
+ if st.button("🗑️", key=f"tc_remove_{idx}"):
359
+ cases_to_remove.append(idx)
360
+
361
+ # Visual indicator
362
+ badge = "🔒 Hidden" if tc.get("is_hidden") else "👁️ Visible"
363
+ st.caption(f"Test Case {idx + 1} — {badge}")
364
+ st.divider()
365
+
366
+ # Remove marked test cases
367
+ if cases_to_remove:
368
+ for idx in sorted(cases_to_remove, reverse=True):
369
+ st.session_state.test_cases_draft.pop(idx)
370
+ st.rerun()
371
+
372
+ # Add new test case button
373
+ col_add1, col_add2 = st.columns(2)
374
+ with col_add1:
375
+ if st.button("➕ Add Example Test Case", key="add_example_tc"):
376
+ st.session_state.test_cases_draft.append({
377
+ "input_data": "",
378
+ "expected_output": "",
379
+ "is_hidden": False
380
+ })
381
+ st.rerun()
382
+ with col_add2:
383
+ if st.button("➕ Add Hidden Test Case", key="add_hidden_tc"):
384
+ st.session_state.test_cases_draft.append({
385
+ "input_data": "",
386
+ "expected_output": "",
387
+ "is_hidden": True
388
+ })
389
+ st.rerun()
390
+
391
+ # Summary
392
+ example_count = sum(1 for tc in st.session_state.test_cases_draft if not tc.get("is_hidden"))
393
+ hidden_count = sum(1 for tc in st.session_state.test_cases_draft if tc.get("is_hidden"))
394
+ st.info(f"📊 **{example_count}** example test case(s) | **{hidden_count}** hidden test case(s)")
395
+
396
+ # Submit question
397
+ if st.button("✅ Add Question", key="add_question"):
398
+ if question_text:
399
+ # Validate test cases
400
+ valid_test_cases = [
401
+ tc for tc in st.session_state.test_cases_draft
402
+ if tc.get("expected_output", "").strip() # at least expected output must exist
403
+ ]
404
+
405
+ result = api_client.create_question(
406
+ st.session_state.room_id,
407
+ question_text,
408
+ question_lang,
409
+ test_cases=valid_test_cases if valid_test_cases else None
410
+ )
411
+
412
+ if "question_id" in result:
413
+ st.success(f"✅ Question created with {len(valid_test_cases)} test case(s)!")
414
+ st.session_state.test_cases_draft = [] # Reset draft
415
+ st.rerun()
416
+ else:
417
+ st.error(f"Error: {result.get('detail', 'Unknown error')}")
418
+ else:
419
+ st.warning("Please enter a question text!")
420
+
421
+ # ================================================================
422
+ # ROOM OVERVIEW
423
+ # ================================================================
424
+ try:
425
+ room = api_client.get_room(st.session_state.room_id)
426
+ room_status = room.get("status", "active")
427
+
428
+ col1, col2, col3 = st.columns(3)
429
+ with col1:
430
+ st.metric("Connected Students", len(room.get("students", [])))
431
+ with col2:
432
+ st.metric("Questions", len(room.get("questions", [])))
433
+ with col3:
434
+ if room_status == "expired":
435
+ st.metric("Status", "🔴 Expired")
436
+ else:
437
+ st.metric("Status", "✅ Active")
438
+
439
+ # Expired room banner
440
+ if room_status == "expired":
441
+ st.warning("⏰ **This exam has ended.** Room code is revoked — no new students can join or submit.")
442
+
443
+ # Questions list with test case details
444
+ if room.get("questions"):
445
+ st.subheader("📋 Questions")
446
+ for i, q in enumerate(room["questions"]):
447
+ tc_list = q.get("test_cases", [])
448
+ example_tc = [tc for tc in tc_list if not tc.get("is_hidden")]
449
+ hidden_tc = [tc for tc in tc_list if tc.get("is_hidden")]
450
+
451
+ with st.expander(f"Q{i+1}: {q.get('question_text', 'N/A')[:80]}..."):
452
+ st.write(f"**{q.get('question_text', 'N/A')}**")
453
+ st.write(f"Language: `{q.get('language', 'Python')}`")
454
+ st.write(f"Test Cases: **{len(example_tc)}** example, **{len(hidden_tc)}** hidden")
455
+
456
+ if example_tc:
457
+ st.write("**👁️ Example Test Cases:**")
458
+ for j, tc in enumerate(example_tc):
459
+ st.markdown(f"**Case {j+1}:** Input: `{tc.get('input_data', '')}` → Expected: `{tc.get('expected_output', '')}`")
460
+
461
+ if hidden_tc:
462
+ st.write("**🔒 Hidden Test Cases:**")
463
+ for j, tc in enumerate(hidden_tc):
464
+ st.markdown(f"**Case {j+1}:** Input: `{tc.get('input_data', '')}` → Expected: `{tc.get('expected_output', '')}`")
465
+
466
+ # ================================================================
467
+ # SCORES & REPORT SECTION
468
+ # ================================================================
469
+ st.divider()
470
+ st.subheader("📊 Student Scores")
471
+
472
+ try:
473
+ scores_response = api_client.get_scores(st.session_state.room_id)
474
+ scores_list = scores_response.get("scores", [])
475
+
476
+ if scores_list:
477
+ # Build scoreboard table
478
+ q_list = room.get("questions", [])
479
+ scoreboard_data = []
480
+ for rank, s in enumerate(scores_list, 1):
481
+ row = {
482
+ "Rank": rank,
483
+ "Student": s.get("student_name", "Unknown"),
484
+ }
485
+ for qi, qs in enumerate(s.get("questions", [])):
486
+ score = qs.get("score", 0)
487
+ max_s = qs.get("max_score", 100)
488
+ status = qs.get("status", "not_attempted")
489
+ icon = "✅" if status == "accepted" else ("📝" if status == "attempted" else "—")
490
+ row[f"Q{qi+1}"] = f"{icon} {score:.0f}/{max_s:.0f}"
491
+
492
+ total = s.get("total_score", 0)
493
+ max_total = s.get("max_total", 0)
494
+ pct = (total / max_total * 100) if max_total > 0 else 0
495
+ row["Total"] = f"{total:.0f}/{max_total:.0f}"
496
+ row["Percentage"] = f"{pct:.1f}%"
497
+ row["Violations"] = f"🚩 {s.get('red_flags', 0)}" if s.get('red_flags', 0) > 0 else "✅ 0"
498
+ scoreboard_data.append(row)
499
+
500
+ st.dataframe(scoreboard_data, use_container_width=True, hide_index=True)
501
+ else:
502
+ st.info("No submissions yet.")
503
+ except Exception as e:
504
+ st.error(f"Error loading scores: {e}")
505
+
506
+ # Download report button
507
+ st.divider()
508
+ st.subheader("📄 Generate PDF Report")
509
+ st.caption("Download a comprehensive PDF report with scores, violations, and exam statistics.")
510
+
511
+ if st.button("📥 Download Exam Report (PDF)", key="download_report", use_container_width=True):
512
+ with st.spinner("Generating PDF report..."):
513
+ pdf_bytes = api_client.download_report(st.session_state.room_id)
514
+ if pdf_bytes:
515
+ st.download_button(
516
+ label="💾 Save Report",
517
+ data=pdf_bytes,
518
+ file_name=f"exam_report_{room.get('room_name', 'report').replace(' ', '_')}.pdf",
519
+ mime="application/pdf",
520
+ key="save_report_btn"
521
+ )
522
+ st.success("✅ Report generated! Click 'Save Report' above to download.")
523
+ else:
524
+ st.error("Failed to generate report.")
525
+
526
+ except Exception as e:
527
+ st.error(f"Error loading room: {e}")
528
+
529
+ # ====================================================================
530
+ # LIVE CODE MONITOR
531
+ # ====================================================================
532
+
533
+ st.divider()
534
+ st.subheader("👁️ Live Student Monitor")
535
+
536
+ col1, col2, col3 = st.columns([2, 1, 1])
537
+ with col1:
538
+ st.subheader("📺 Live Code Monitor")
539
+
540
+ # CLASSROOM STATUS TABLE
541
+ try:
542
+ detailed_data = api_client.get_student_codes(st.session_state.room_id)
543
+ student_codes_map = detailed_data.get("student_codes", {})
544
+ student_ids_list = room.get("students", [])
545
+
546
+ if student_ids_list:
547
+ status_data = []
548
+ for sid in student_ids_list:
549
+ s_info = student_codes_map.get(sid, {})
550
+ nm = room.get("student_names", {}).get(sid, "Unknown")
551
+ flgs = s_info.get("red_flags", 0)
552
+ sts = s_info.get("status", "idle")
553
+ last_up = s_info.get("last_updated", "")
554
+ if last_up:
555
+ last_up = last_up.split("T")[1][:8]
556
+
557
+ status_icon = "🟩" if sts == "working" else ("✅" if sts == "accepted" else "🔴")
558
+ flag_icon = "🚩" if flgs > 0 else "✅"
559
+
560
+ status_data.append({
561
+ "Student Name": nm,
562
+ "Status": f"{status_icon} {sts}",
563
+ "Violations": f"{flag_icon} {flgs}",
564
+ "Last Active": last_up,
565
+ "ID": sid
566
+ })
567
+
568
+ st.info("**Classroom Overview**")
569
+ st.dataframe(
570
+ status_data,
571
+ column_config={
572
+ "Student Name": st.column_config.TextColumn("Student Name", width="medium"),
573
+ "Status": st.column_config.TextColumn("Status", width="small"),
574
+ "Violations": st.column_config.TextColumn("Violations (Red Flags)", width="small"),
575
+ "Last Active": st.column_config.TextColumn("Last Active", width="small"),
576
+ "ID": st.column_config.TextColumn("Student ID", width="small")
577
+ },
578
+ use_container_width=True,
579
+ hide_index=True
580
+ )
581
+ else:
582
+ st.warning("Waiting for students to join...")
583
+
584
+ except Exception as ex:
585
+ st.error(f"Error loading classroom status: {ex}")
586
+
587
+ col1, col2, col3 = st.columns([2, 1, 1])
588
+ with col1:
589
+ # Get student names from room
590
+ room = api_client.get_room(st.session_state.room_id)
591
+ student_names_map = room.get("student_names", {})
592
+ student_ids = room.get("students", [])
593
+
594
+ student_display = []
595
+
596
+ try:
597
+ detailed_data = api_client.get_student_codes(st.session_state.room_id)
598
+ student_codes_map = detailed_data.get("student_codes", {})
599
+ except:
600
+ student_codes_map = {}
601
+
602
+ for sid in student_ids:
603
+ name = student_names_map.get(sid, f"Student ({sid[:8]}...)")
604
+ s_data = student_codes_map.get(sid, {})
605
+ flags = s_data.get("red_flags", 0)
606
+
607
+ flag_str = f" ({flags})" if flags > 0 else ""
608
+ display_name = f"{name}{flag_str}"
609
+
610
+ student_display.append({"id": sid, "name": display_name})
611
+
612
+ if student_display:
613
+ selected_idx = st.selectbox(
614
+ "Select Student to Monitor",
615
+ range(len(student_display)),
616
+ format_func=lambda i: student_display[i]["name"],
617
+ key="monitor_student"
618
+ )
619
+ selected_student_id = student_display[selected_idx]["id"]
620
+ selected_student_name = student_display[selected_idx]["name"]
621
+ else:
622
+ st.info("No students connected yet")
623
+ selected_student_id = None
624
+ selected_student_name = None
625
+
626
+ with col2:
627
+ auto_refresh = st.checkbox("☑️ Auto Refresh", value=True, key="auto_refresh")
628
+ with col3:
629
+ refresh_interval = st.slider("Interval (sec)", 1, 10, 2, key="refresh_interval")
630
+
631
+ # Live code display area
632
+ if selected_student_id:
633
+ st.write(f"**Monitoring:** {selected_student_name}")
634
+
635
+ code_placeholder = st.empty()
636
+ status_placeholder = st.empty()
637
+
638
+ while auto_refresh:
639
+ try:
640
+ student_codes_response = api_client.get_student_codes(st.session_state.room_id)
641
+
642
+ if student_codes_response and "student_codes" in student_codes_response:
643
+ student_data = student_codes_response["student_codes"].get(selected_student_id, {})
644
+
645
+ code = student_data.get("code", "")
646
+ student_name = student_data.get("student_name", "Unknown")
647
+ language = student_data.get("language", "python").lower()
648
+ status = student_data.get("status", "idle")
649
+ last_updated = student_data.get("last_updated", "")
650
+ red_flags = student_data.get("red_flags", 0)
651
+
652
+ with code_placeholder.container():
653
+ if red_flags > 0:
654
+ st.error(
655
+ f"🔴 **VIOLATION ALERT**: This student has left the exam window {red_flags} times!")
656
+
657
+ if code.strip():
658
+ st.code(code, language=language)
659
+ else:
660
+ st.info("⌛ Waiting for student to start coding...")
661
+
662
+ with status_placeholder.container():
663
+ col1, col2, col3 = st.columns(3)
664
+
665
+ if status == "idle":
666
+ status_icon = ""
667
+ elif status == "working":
668
+ status_icon = "🟩"
669
+ elif status == "accepted":
670
+ status_icon = "✅"
671
+ else:
672
+ status_icon = "🔴"
673
+
674
+ with col1:
675
+ st.write(f"**Status:** {status_icon} {status.upper()}")
676
+ with col2:
677
+ st.write(f"**Language:** {language.upper()}")
678
+
679
+ if last_updated:
680
+ time_str = last_updated.split('T')[1][:8] if 'T' in last_updated else last_updated
681
+ with col3:
682
+ st.write(f"**Updated:** {time_str}")
683
+
684
+ time.sleep(refresh_interval)
685
+ st.rerun()
686
+
687
+ except Exception as e:
688
+ st.error(f"Error fetching code: {e}")
689
+ break
690
+ else:
691
+ st.info("📌 Create a room using the sidebar to get started!")
692
+
693
+
694
+ def student_page(api_client):
695
+ """Student workspace"""
696
+ st.title("💻 Student Workspace")
697
+
698
+ # Sidebar for room joining
699
+ with st.sidebar:
700
+ st.header("Join Exam Room")
701
+ room_code = st.text_input("Room Code")
702
+ student_name = st.text_input("Your Name")
703
+
704
+ if st.button("Join Room", key="join_room"):
705
+ if room_code and student_name:
706
+ try:
707
+ result = api_client.join_room(room_code, student_name)
708
+ if "room_id" in result:
709
+ st.session_state.room_id = result["room_id"]
710
+ st.session_state.student_id = result["student_id"]
711
+ st.session_state.student_name = student_name
712
+ st.success("Room joined!")
713
+ else:
714
+ st.error(f"Error: {result.get('detail', 'Unknown error')}")
715
+ except requests.exceptions.HTTPError as e:
716
+ if e.response and e.response.status_code == 403:
717
+ st.error("🔴 This exam has ended. The room code is no longer valid.")
718
+ else:
719
+ st.error(f"Error: {e}")
720
+ except Exception as e:
721
+ # The API might return 403 as JSON
722
+ st.error(f"Error: {e}")
723
+
724
+ if "room_id" in st.session_state:
725
+ st.info(f"✅ Connected as **{st.session_state.student_name}**")
726
+
727
+ # Get room details to check timing
728
+ try:
729
+ room = api_client.get_room(st.session_state.room_id)
730
+ start_time_str = room.get("start_time")
731
+ end_time_str = room.get("end_time")
732
+
733
+ now = datetime.now()
734
+ start_time = datetime.fromisoformat(start_time_str) if start_time_str else None
735
+ end_time = datetime.fromisoformat(end_time_str) if end_time_str else None
736
+
737
+ if start_time and now < start_time:
738
+ st.warning(f"Exam has not started yet. Start time: {start_time.strftime('%Y-%m-%d %H:%M:%S')}")
739
+ time_diff = start_time - now
740
+ st.metric("Time until start", str(time_diff).split('.')[0])
741
+ if st.button("Refresh Status"):
742
+ st.rerun()
743
+ return
744
+
745
+ if end_time and now > end_time:
746
+ st.error("Exam has ended. You can no longer submit code.")
747
+ st.metric("Exam Ended", end_time.strftime('%Y-%m-%d %H:%M:%S'))
748
+ return
749
+
750
+ # EXAM IN PROGRESS
751
+ if end_time:
752
+ time_left = end_time - now
753
+
754
+ st_room_id = st.session_state.room_id
755
+ st_student_id = st.session_state.student_id
756
+
757
+ security_script = f"""
758
+ <script>
759
+ const targetWindow = window.parent;
760
+ const targetDocument = targetWindow.document;
761
+
762
+ function reportViolation() {{
763
+ fetch("{BACKEND_URL}/api/rooms/{st_room_id}/report_violation", {{
764
+ method: "POST",
765
+ headers: {{
766
+ "Content-Type": "application/json"
767
+ }},
768
+ body: JSON.stringify({{
769
+ "student_id": "{st_student_id}"
770
+ }})
771
+ }}).catch(e => console.error(e));
772
+ }}
773
+
774
+ function requestFullScreen() {{
775
+ var elem = targetDocument.documentElement;
776
+ if (elem.requestFullscreen) {{
777
+ elem.requestFullscreen().catch(err => {{
778
+ console.log("Error attempting to enable full-screen mode: " + err.message);
779
+ }});
780
+ }} else if (elem.webkitRequestFullscreen) {{
781
+ elem.webkitRequestFullscreen();
782
+ }} else if (elem.msRequestFullscreen) {{
783
+ elem.msRequestFullscreen();
784
+ }}
785
+ }}
786
+
787
+ function showOverlay(message, isRed = true) {{
788
+ var overlay = targetDocument.getElementById('security-overlay');
789
+ if (!overlay) {{
790
+ overlay = targetDocument.createElement('div');
791
+ overlay.id = 'security-overlay';
792
+ overlay.style.position = 'fixed';
793
+ overlay.style.top = '0';
794
+ overlay.style.left = '0';
795
+ overlay.style.width = '100vw';
796
+ overlay.style.height = '100vh';
797
+ overlay.style.zIndex = '999999';
798
+ overlay.style.color = 'white';
799
+ overlay.style.display = 'flex';
800
+ overlay.style.justifyContent = 'center';
801
+ overlay.style.alignItems = 'center';
802
+ overlay.style.flexDirection = 'column';
803
+
804
+ overlay.onclick = function() {{
805
+ overlay.style.display = 'none';
806
+ targetDocument.title = "Online Exam IDE";
807
+ requestFullScreen();
808
+ }};
809
+
810
+ targetDocument.body.appendChild(overlay);
811
+ }}
812
+
813
+ overlay.style.backgroundColor = isRed ? 'rgba(255, 0, 0, 0.98)' : 'rgba(0, 0, 0, 0.9)';
814
+ overlay.innerHTML = message;
815
+ overlay.style.display = 'flex';
816
+ }}
817
+
818
+ targetDocument.addEventListener('contextmenu', event => {{
819
+ event.preventDefault();
820
+ event.stopPropagation();
821
+ reportViolation();
822
+ showOverlay('<h1 style="font-size: 50px;"> VIOLATION</h1><h2 style="font-size: 30px;">Right-click is forbidden!</h2><p style="font-size: 20px;">Return and CLICK HERE to resume.</p>');
823
+ return false;
824
+ }}, true);
825
+
826
+ targetDocument.addEventListener('keydown', function(e) {{
827
+ if (e.ctrlKey || e.altKey || e.metaKey) {{
828
+ e.preventDefault();
829
+ e.stopPropagation();
830
+ reportViolation();
831
+ showOverlay('<h1 style="font-size: 50px;"> VIOLATION</h1><h2 style="font-size: 30px;">Keyboard shortcuts are forbidden!</h2><p style="font-size: 20px;">Return and CLICK HERE to resume.</p>');
832
+ return false;
833
+ }}
834
+ if (e.keyCode >= 112 && e.keyCode <= 123) {{
835
+ e.preventDefault();
836
+ e.stopPropagation();
837
+ reportViolation();
838
+ showOverlay('<h1 style="font-size: 50px;"> VIOLATION</h1><h2 style="font-size: 30px;">Function keys are forbidden!</h2><p style="font-size: 20px;">Return and CLICK HERE to resume.</p>');
839
+ return false;
840
+ }}
841
+ }}, true);
842
+
843
+ ['copy', 'cut', 'paste'].forEach(e => {{
844
+ targetDocument.addEventListener(e, function(event) {{
845
+ event.preventDefault();
846
+ event.stopPropagation();
847
+ reportViolation();
848
+ let action = e.toUpperCase();
849
+ showOverlay('<h1 style="font-size: 50px;"> VIOLATION</h1><h2 style="font-size: 30px;">' + action + ' is forbidden!</h2><p style="font-size: 20px;">Return and CLICK HERE to resume.</p>');
850
+ return false;
851
+ }}, true);
852
+ }});
853
+
854
+ targetWindow.addEventListener('blur', function() {{
855
+ targetDocument.title = " EXAM WARNING: COME BACK!";
856
+ reportViolation();
857
+ showOverlay('<h1 style="font-size: 50px;"> VIOLATION</h1><h2 style="font-size: 30px;">You left the exam window!</h2><p style="font-size: 20px;">Return and CLICK HERE to resume.</p>');
858
+ }});
859
+
860
+ function handleFullscreenChange() {{
861
+ if (!targetDocument.fullscreenElement && !targetDocument.webkitFullscreenElement && !targetDocument.msFullscreenElement) {{
862
+ reportViolation();
863
+ showOverlay('<h1 style="font-size: 40px;"> FULLSCREEN REQUIRED</h1><h2 style="font-size: 25px;">You cannot leave fullscreen mode.</h2><p style="font-size: 20px;">CLICK HERE to return to fullscreen.</p>', true);
864
+ }}
865
+ }}
866
+
867
+ targetDocument.addEventListener('fullscreenchange', handleFullscreenChange);
868
+ targetDocument.addEventListener('webkitfullscreenchange', handleFullscreenChange);
869
+ targetDocument.addEventListener('mozfullscreenchange', handleFullscreenChange);
870
+ targetDocument.addEventListener('MSFullscreenChange', handleFullscreenChange);
871
+
872
+ const style = targetDocument.createElement('style');
873
+ style.innerHTML = `
874
+ [data-testid="stSidebar"] {{ display: none !important; }}
875
+ [data-testid="stToolbar"] {{ visibility: hidden !important; }}
876
+ header {{ visibility: hidden !important; }}
877
+ `;
878
+ targetDocument.head.appendChild(style);
879
+
880
+ setInterval(function() {{
881
+ if (!targetDocument.fullscreenElement && !targetDocument.webkitFullscreenElement && !targetDocument.msFullscreenElement) {{
882
+ var overlay = targetDocument.getElementById('security-overlay');
883
+ if (!overlay || overlay.style.display == 'none') {{
884
+ showOverlay('<h1 style="font-size: 40px;"> FULLSCREEN REQUIRED</h1><h2 style="font-size: 25px;">You cannot leave fullscreen mode.</h2><p style="font-size: 20px;">CLICK HERE to return to fullscreen.</p>', true);
885
+ }}
886
+ }}
887
+ }}, 1000);
888
+
889
+ setTimeout(requestFullScreen, 500);
890
+ console.log("Security script loaded and attached to parent");
891
+ </script>
892
+ """
893
+ components.html(security_script, height=0, width=0)
894
+
895
+ # Top bar
896
+ col1, col2, col3 = st.columns([1, 1, 1])
897
+ with col1:
898
+ st.metric("⏱ Time Remaining", str(time_left).split('.')[0])
899
+ with col3:
900
+ st.markdown("""
901
+ <button onclick="parent.document.documentElement.requestFullscreen()" style="
902
+ background-color: #FF4B4B;
903
+ color: white;
904
+ padding: 10px 20px;
905
+ border: none;
906
+ border-radius: 5px;
907
+ cursor: pointer;
908
+ font-weight: bold;">
909
+ Force Full Screen
910
+ </button>
911
+ """, unsafe_allow_html=True)
912
+
913
+ if st.button("Refresh Timer"):
914
+ st.rerun()
915
+
916
+ except Exception as e:
917
+ st.error(f"Error checking exam status: {e}")
918
+
919
+ # ================================================================
920
+ # QUESTION + TEST CASES DISPLAY + CODE EDITOR
921
+ # ================================================================
922
+ try:
923
+ questions = api_client.get_questions(st.session_state.room_id)
924
+ question_list = questions.get("questions", [])
925
+
926
+ if question_list:
927
+ # Question selection
928
+ question_names = [q.get("question_text", f"Question {i + 1}")[:60] for i, q in enumerate(question_list)]
929
+ selected_q_idx = st.selectbox("Select Question", range(len(question_names)),
930
+ format_func=lambda i: question_names[i])
931
+ selected_question = question_list[selected_q_idx]
932
+ question_id = selected_question.get("question_id")
933
+
934
+ # Display question text
935
+ st.markdown("---")
936
+ st.subheader("📝 Problem Statement")
937
+ st.write(f"**{selected_question['question_text']}**")
938
+
939
+ language = selected_question.get("language", "Python").lower()
940
+
941
+ # ================================================================
942
+ # DISPLAY EXAMPLE TEST CASES (visible to students)
943
+ # ================================================================
944
+ example_test_cases = selected_question.get("test_cases", [])
945
+ hidden_count = selected_question.get("hidden_test_cases_count", 0)
946
+ total_count = selected_question.get("total_test_cases", 0)
947
+
948
+ if example_test_cases or hidden_count > 0:
949
+ st.markdown("---")
950
+ st.subheader("🧪 Test Cases")
951
+
952
+ if example_test_cases:
953
+ st.write("**Example Test Cases** (visible to you):")
954
+ for j, tc in enumerate(example_test_cases):
955
+ with st.container():
956
+ col_in, col_out = st.columns(2)
957
+ with col_in:
958
+ st.markdown(f"**Input {j+1}:**")
959
+ input_display = tc.get("input_data", "(no input)")
960
+ st.code(input_display if input_display else "(no input)", language="text")
961
+ with col_out:
962
+ st.markdown(f"**Expected Output {j+1}:**")
963
+ st.code(tc.get("expected_output", ""), language="text")
964
+
965
+ if hidden_count > 0:
966
+ st.info(f"🔒 **{hidden_count}** hidden test case(s) will be used for final judging.")
967
+
968
+ # ================================================================
969
+ # CODE TEMPLATES
970
+ # ================================================================
971
+ st.divider()
972
+ st.subheader("📋 Code Templates")
973
+
974
+ if language in ["python", "py"]:
975
+ templates = PYTHON_TEMPLATES
976
+ else:
977
+ templates = JAVASCRIPT_TEMPLATES
978
+
979
+ selected_template = st.selectbox("Choose a Template", list(templates.keys()), key="template_select")
980
+
981
+ if st.button("📥 Load Template"):
982
+ st.session_state.code = templates[selected_template]
983
+ try:
984
+ worksheet = api_client.get_worksheet(
985
+ st.session_state.room_id,
986
+ st.session_state.student_id,
987
+ question_id
988
+ )
989
+ api_client.save_code(worksheet["worksheet_id"], st.session_state.code)
990
+ st.success("Template loaded and saved!")
991
+ except:
992
+ st.success("Template loaded!")
993
+ st.rerun()
994
+
995
+ # Indentation help
996
+ with st.expander("📐 Indentation Help"):
997
+ if language in ["python", "py"]:
998
+ st.write("""
999
+ **Python Indentation Rules:**
1000
+ - Use **4 spaces** per indentation level
1001
+ - Consistent indentation is required
1002
+ - Common places for indentation:
1003
+ - Inside functions: 4 spaces
1004
+ - Inside loops (for, while): 4 spaces
1005
+ - Inside if/else blocks: 4 spaces
1006
+ - Class methods: 8 spaces from class definition
1007
+
1008
+ **Example:**
1009
+ ```python
1010
+ def greet(name): # No indentation
1011
+ print(name) # 4 spaces
1012
+ if name: # 4 spaces
1013
+ print("Hi") # 8 spaces
1014
+ ```
1015
+ """)
1016
+ else:
1017
+ st.write("""
1018
+ **JavaScript Indentation Rules:**
1019
+ - Use **2 spaces** per indentation level
1020
+ - Common places for indentation:
1021
+ - Inside functions: 2 spaces
1022
+ - Inside loops (for, while): 2 spaces
1023
+ - Inside if/else blocks: 2 spaces
1024
+ - Inside objects: 2 spaces
1025
+
1026
+ **Example:**
1027
+ ```javascript
1028
+ function greet(name) { // No indentation
1029
+ console.log(name); // 2 spaces
1030
+ if (name) { // 2 spaces
1031
+ console.log("Hi"); // 4 spaces
1032
+ }
1033
+ }
1034
+ ```
1035
+ """)
1036
+
1037
+ # ================================================================
1038
+ # CODE EDITOR
1039
+ # ================================================================
1040
+ st.divider()
1041
+ st.subheader("⌨️ Code Editor")
1042
+
1043
+ if "code" not in st.session_state:
1044
+ st.session_state.code = ""
1045
+
1046
+ # Initialize worksheet
1047
+ if "current_worksheet_id" not in st.session_state or st.session_state.get(
1048
+ "current_question_id") != question_id:
1049
+ try:
1050
+ worksheet = api_client.get_worksheet(
1051
+ st.session_state.room_id,
1052
+ st.session_state.student_id,
1053
+ question_id
1054
+ )
1055
+ st.session_state.current_worksheet_id = worksheet["worksheet_id"]
1056
+ st.session_state.current_question_id = question_id
1057
+ if worksheet.get("code"):
1058
+ st.session_state.code = worksheet["code"]
1059
+ except Exception as e:
1060
+ st.error(f"Error loading worksheet: {e}")
1061
+
1062
+ code_input = st.text_area(
1063
+ "Write your code here:",
1064
+ value=st.session_state.code,
1065
+ height=250,
1066
+ key="code_input"
1067
+ )
1068
+
1069
+ # Auto-save
1070
+ if code_input != st.session_state.code:
1071
+ st.session_state.code = code_input
1072
+ if "current_worksheet_id" in st.session_state:
1073
+ try:
1074
+ api_client.save_code(st.session_state.current_worksheet_id, code_input)
1075
+ except:
1076
+ pass
1077
+
1078
+ # ================================================================
1079
+ # CODE QUALITY CHECK
1080
+ # ================================================================
1081
+ st.divider()
1082
+ st.subheader("📊 Code Quality Check")
1083
+
1084
+ if code_input.strip():
1085
+ col1, col2, col3 = st.columns(3)
1086
+
1087
+ has_output = False
1088
+ if language in ["python", "py"]:
1089
+ has_output = "print(" in code_input
1090
+ else:
1091
+ has_output = "console.log(" in code_input
1092
+
1093
+ with col1:
1094
+ if has_output:
1095
+ st.success(f"✅ Has Output Statement")
1096
+ else:
1097
+ st.warning(f"⚠️ No Output Statement")
1098
+
1099
+ char_count = len(code_input)
1100
+ with col2:
1101
+ st.info(f"📝 Characters: {char_count}")
1102
+
1103
+ lines = code_input.split('\n')
1104
+ with col3:
1105
+ st.info(f"📄 Lines: {len(lines)}")
1106
+
1107
+ # ================================================================
1108
+ # RUN & SUBMIT BUTTONS
1109
+ # ================================================================
1110
+ st.divider()
1111
+ col_run, col_submit, col_save = st.columns(3)
1112
+
1113
+ with col_run:
1114
+ if st.button("▶️ Run Code", use_container_width=True):
1115
+ if code_input.strip():
1116
+ with st.spinner("Executing..."):
1117
+ result = api_client.execute_code(code_input, language)
1118
+ if "output" in result and result.get("success", True):
1119
+ st.success("✅ Execution successful!")
1120
+ st.code(result["output"], language="text")
1121
+ elif "error" in result:
1122
+ st.error("❌ Execution failed!")
1123
+ st.code(result["error"], language="text")
1124
+ else:
1125
+ st.warning("Write some code first!")
1126
+
1127
+ with col_submit:
1128
+ if st.button("🚀 Submit Solution", use_container_width=True, type="primary"):
1129
+ if code_input.strip():
1130
+ with st.spinner("Judging your solution against all test cases..."):
1131
+ result = api_client.submit_solution(
1132
+ code=code_input,
1133
+ language=language,
1134
+ room_id=st.session_state.room_id,
1135
+ question_id=question_id,
1136
+ student_id=st.session_state.student_id
1137
+ )
1138
+
1139
+ # Store result for display
1140
+ st.session_state.last_submission = result
1141
+ else:
1142
+ st.warning("Write some code first!")
1143
+
1144
+ with col_save:
1145
+ if st.button("💾 Save Code", use_container_width=True):
1146
+ if "current_worksheet_id" in st.session_state:
1147
+ try:
1148
+ api_client.save_code(st.session_state.current_worksheet_id, code_input)
1149
+ st.success("✅ Code saved!")
1150
+ except Exception as e:
1151
+ st.error(f"Error saving: {e}")
1152
+
1153
+ # ================================================================
1154
+ # SUBMISSION RESULTS DISPLAY (LeetCode-style)
1155
+ # ================================================================
1156
+ if "last_submission" in st.session_state and st.session_state.last_submission:
1157
+ submission = st.session_state.last_submission
1158
+
1159
+ st.markdown("---")
1160
+ st.subheader("📊 Submission Results")
1161
+
1162
+ overall = submission.get("overall", "Unknown")
1163
+ passed = submission.get("passed_cases", 0)
1164
+ total = submission.get("total_cases", 0)
1165
+ score = submission.get("score", 0)
1166
+ max_score = submission.get("max_score", 100)
1167
+
1168
+ # Overall verdict banner with score
1169
+ if overall == "Accepted":
1170
+ st.success(f"🎉 **{overall}** — All {total} test case(s) passed! Score: **{score:.0f}/{max_score:.0f}**")
1171
+ elif overall == "Wrong Answer":
1172
+ st.error(f"❌ **{overall}** — {passed}/{total} test case(s) passed. Score: **{score:.0f}/{max_score:.0f}**")
1173
+ elif overall == "Runtime Error":
1174
+ st.error(f"💥 **{overall}** — {passed}/{total} test case(s) passed. Score: **{score:.0f}/{max_score:.0f}**")
1175
+ else:
1176
+ st.warning(f"⚠️ **{overall}** — {passed}/{total} test case(s) passed. Score: **{score:.0f}/{max_score:.0f}**")
1177
+
1178
+ # Per-case results
1179
+ case_results = submission.get("results", [])
1180
+ for cr in case_results:
1181
+ case_num = cr.get("case_number", "?")
1182
+ is_hidden = cr.get("is_hidden", False)
1183
+ case_passed = cr.get("passed", False)
1184
+ case_status = cr.get("status", "")
1185
+
1186
+ icon = "✅" if case_passed else "❌"
1187
+
1188
+ if is_hidden:
1189
+ # Hidden test case — only show pass/fail
1190
+ st.write(f"{icon} **Hidden Test Case {case_num}**: {case_status}")
1191
+ else:
1192
+ # Example test case — show full details
1193
+ with st.expander(f"{icon} Test Case {case_num}: {case_status}", expanded=not case_passed):
1194
+ tc_col1, tc_col2, tc_col3 = st.columns(3)
1195
+ with tc_col1:
1196
+ st.markdown("**Input:**")
1197
+ st.code(cr.get("input_data", "(no input)"), language="text")
1198
+ with tc_col2:
1199
+ st.markdown("**Expected Output:**")
1200
+ st.code(cr.get("expected_output", ""), language="text")
1201
+ with tc_col3:
1202
+ st.markdown("**Your Output:**")
1203
+ actual = cr.get("actual_output", "")
1204
+ if cr.get("error"):
1205
+ st.code(cr.get("error", ""), language="text")
1206
+ else:
1207
+ st.code(actual if actual else "(no output)", language="text")
1208
+
1209
+ else:
1210
+ st.info("No questions available yet. Ask your teacher to create some!")
1211
+
1212
+ except Exception as e:
1213
+ st.error(f"Error: {e}")
1214
+
1215
+ else:
1216
+ st.info("📌 Join a room using the sidebar to get started!")
1217
+
1218
+
1219
+ # ============================================================================
1220
+ # MAIN APP
1221
+ # ============================================================================
1222
+
1223
+ def main():
1224
+ st.set_page_config(
1225
+ page_title="Online Exam IDE",
1226
+ page_icon="💻",
1227
+ layout="wide"
1228
+ )
1229
+
1230
+ # Initialize session state
1231
+ if "role" not in st.session_state:
1232
+ st.session_state.role = None
1233
+
1234
+ if "monitoring" not in st.session_state:
1235
+ st.session_state.monitoring = False
1236
+
1237
+ # Create API client
1238
+ api_client = APIClient(BACKEND_URL)
1239
+ api_client.session_id = "session_123"
1240
+
1241
+ # Role selection
1242
+ if not st.session_state.role:
1243
+ col1, col2, col3 = st.columns([1, 1, 1])
1244
+ with col1:
1245
+ st.title("💻 Online Exam IDE")
1246
+
1247
+ col1, col2 = st.columns(2)
1248
+ with col1:
1249
+ if st.button("📋 Continue as Teacher", use_container_width=True, key="teacher_btn"):
1250
+ st.session_state.role = "teacher"
1251
+ st.rerun()
1252
+
1253
+ with col2:
1254
+ if st.button("💻 Continue as Student", use_container_width=True, key="student_btn"):
1255
+ st.session_state.role = "student"
1256
+ st.rerun()
1257
+
1258
+ else:
1259
+ if st.session_state.role == "teacher":
1260
+ teacher_page(api_client)
1261
+ else:
1262
+ student_page(api_client)
1263
+
1264
+ if st.sidebar.button("🔓 Logout"):
1265
+ st.session_state.role = None
1266
+ st.session_state.pop("room_id", None)
1267
+ st.session_state.pop("room_code", None)
1268
+ st.session_state.pop("last_submission", None)
1269
+ st.session_state.pop("test_cases_draft", None)
1270
+ st.rerun()
1271
+
1272
+
1273
+ if __name__ == "__main__":
1274
+ main()
frontend/requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ streamlit==1.28.1
2
+ requests==2.31.0
3
+ python-dotenv==1.0.0
frontend/utils/api_client.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import streamlit as st
3
+ from .constants import BACKEND_URL
4
+ from typing import Optional, Dict, Any
5
+ import json
6
+
7
+
8
+ class APIClient:
9
+ """API client for backend communication"""
10
+
11
+ BASE_URL = BACKEND_URL
12
+
13
+ @staticmethod
14
+ def create_room(room_name: str, teacher_id: str, teacher_name: str) -> Optional[Dict]:
15
+ """Create exam room"""
16
+ try:
17
+ response = requests.post(
18
+ f"{APIClient.BASE_URL}/api/rooms/create",
19
+ params={
20
+ "room_name": room_name,
21
+ "teacher_id": teacher_id,
22
+ "teacher_name": teacher_name
23
+ }
24
+ )
25
+ response.raise_for_status()
26
+ return response.json()
27
+ except Exception as e:
28
+ st.error(f"Failed to create room: {str(e)}")
29
+ return None
30
+
31
+ @staticmethod
32
+ def get_room(room_id: str) -> Optional[Dict]:
33
+ """Get room details"""
34
+ try:
35
+ response = requests.get(f"{APIClient.BASE_URL}/api/rooms/{room_id}")
36
+ response.raise_for_status()
37
+ return response.json()
38
+ except Exception as e:
39
+ st.error(f"Room not found: {str(e)}")
40
+ return None
41
+
42
+ @staticmethod
43
+ def join_room(room_id: str, student_id: str, student_name: str) -> Optional[Dict]:
44
+ """Student joins room"""
45
+ try:
46
+ response = requests.post(
47
+ f"{APIClient.BASE_URL}/api/rooms/{room_id}/join",
48
+ params={
49
+ "student_id": student_id,
50
+ "student_name": student_name
51
+ }
52
+ )
53
+ response.raise_for_status()
54
+ return response.json()
55
+ except Exception as e:
56
+ st.error(f"Failed to join room: {str(e)}")
57
+ return None
58
+
59
+ @staticmethod
60
+ def create_question(room_id: str, title: str, description: str, languages: list = None) -> Optional[Dict]:
61
+ """Create question"""
62
+ try:
63
+ response = requests.post(
64
+ f"{APIClient.BASE_URL}/api/rooms/{room_id}/questions",
65
+ params={
66
+ "title": title,
67
+ "description": description,
68
+ "allowed_languages": languages or ["python", "javascript", "java", "cpp"]
69
+ }
70
+ )
71
+ response.raise_for_status()
72
+ return response.json()
73
+ except Exception as e:
74
+ st.error(f"Failed to create question: {str(e)}")
75
+ return None
76
+
77
+ @staticmethod
78
+ def get_questions(room_id: str) -> Optional[Dict]:
79
+ """Get all questions in room"""
80
+ try:
81
+ response = requests.get(f"{APIClient.BASE_URL}/api/rooms/{room_id}/questions")
82
+ response.raise_for_status()
83
+ return response.json()
84
+ except Exception as e:
85
+ st.error(f"Failed to load questions: {str(e)}")
86
+ return None
87
+
88
+ @staticmethod
89
+ def get_worksheet(room_id: str, student_id: str, question_id: str) -> Optional[Dict]:
90
+ """Get student's worksheet"""
91
+ try:
92
+ response = requests.get(
93
+ f"{APIClient.BASE_URL}/api/worksheets/{room_id}/{student_id}/{question_id}"
94
+ )
95
+ response.raise_for_status()
96
+ return response.json()
97
+ except Exception as e:
98
+ st.error(f"Failed to load worksheet: {str(e)}")
99
+ return None
100
+
101
+ @staticmethod
102
+ def save_code(worksheet_id: str, code: str, language: str) -> bool:
103
+ """Auto-save code"""
104
+ try:
105
+ response = requests.post(
106
+ f"{APIClient.BASE_URL}/api/worksheets/{worksheet_id}/save",
107
+ params={
108
+ "code": code,
109
+ "language": language
110
+ }
111
+ )
112
+ response.raise_for_status()
113
+ return True
114
+ except:
115
+ return False
116
+
117
+ @staticmethod
118
+ def execute_code(worksheet_id: str, code: str, language: str, input_data: str = "") -> Optional[Dict]:
119
+ """Execute code"""
120
+ try:
121
+ response = requests.post(
122
+ f"{APIClient.BASE_URL}/api/worksheets/{worksheet_id}/execute",
123
+ json={
124
+ "code": code,
125
+ "language": language,
126
+ "input_data": input_data
127
+ }
128
+ )
129
+ response.raise_for_status()
130
+ return response.json()
131
+ except Exception as e:
132
+ return {
133
+ "status": "error",
134
+ "output": "None",
135
+ "stderr": str(e)
136
+ }
137
+
138
+ @staticmethod
139
+ def get_students(room_id: str) -> Optional[Dict]:
140
+ """Get room students (Teacher)"""
141
+ try:
142
+ response = requests.get(f"{APIClient.BASE_URL}/api/rooms/{room_id}/students")
143
+ response.raise_for_status()
144
+ return response.json()
145
+ except Exception as e:
146
+ st.error(f"Failed to load students: {str(e)}")
147
+ return None
148
+
149
+ @staticmethod
150
+ def get_student_progress(room_id: str, student_id: str) -> Optional[Dict]:
151
+ """Get student progress"""
152
+ try:
153
+ response = requests.get(
154
+ f"{APIClient.BASE_URL}/api/rooms/{room_id}/student/{student_id}/progress"
155
+ )
156
+ response.raise_for_status()
157
+ return response.json()
158
+ except Exception as e:
159
+ return None
frontend/utils/constants.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend API URL (change for production)
2
+ BACKEND_URL = "http://localhost:8000" # Local dev
3
+
4
+ # Supported languages
5
+ SUPPORTED_LANGUAGES = {
6
+ "python": "python",
7
+ "javascript": "javascript",
8
+ "java": "java",
9
+ "c": "c",
10
+ "c++": "cpp"
11
+ }
12
+
13
+ # UI Settings
14
+ EDITOR_HEIGHT = 400
15
+ REFRESH_INTERVAL = 2 # seconds for polling updates