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 +33 -0
- .gitignore +41 -0
- Dockerfile.backend +45 -0
- Dockerfile.frontend +31 -0
- backend/.env.example +4 -0
- backend/code_executor.py +246 -0
- backend/database.py +309 -0
- backend/main.py +596 -0
- backend/models.py +55 -0
- backend/report_generator.py +432 -0
- backend/requirements.txt +13 -0
- backend/room_manager.py +89 -0
- docker-compose.yml +57 -0
- frontend/.streamlit/config.toml +14 -0
- frontend/app.py +1274 -0
- frontend/requirements.txt +3 -0
- frontend/utils/api_client.py +159 -0
- frontend/utils/constants.py +15 -0
.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
|