Spaces:
Running
Running
File size: 52,488 Bytes
21c192a ebb4d1d 7547388 21c192a 23af0a7 21c192a ebb4d1d 7547388 2484080 7547388 dcab2e7 7547388 21c192a a2cbc5c 7547388 21c192a 6737361 b88dc75 a2cbc5c 7547388 a2cbc5c 6737361 7547388 21c192a 6737361 21c192a 7547388 6737361 ab6d17c dcab2e7 6737361 23af0a7 7e8293f 7547388 dcab2e7 6737361 a2cbc5c b88dc75 ab6d17c 23af0a7 ab6d17c 23af0a7 5cacf86 7e8293f 2484080 7e8293f 2484080 7e8293f 5cacf86 7e8293f 7547388 dcab2e7 bfe0d21 dcab2e7 21c192a dcab2e7 21c192a dcab2e7 6737361 dcab2e7 6737361 a2cbc5c b88dc75 7547388 23af0a7 7547388 23af0a7 b88dc75 ab6d17c 23af0a7 ab6d17c 21c192a 23af0a7 6737361 21c192a 23af0a7 21c192a 60dfd5a 7e8293f ab6d17c 21c192a ebb4d1d b88dc75 a2cbc5c b88dc75 a2cbc5c b88dc75 a2cbc5c 23af0a7 ab6d17c 7547388 23af0a7 7547388 ab6d17c 23af0a7 ab6d17c 21c192a ab6d17c 21c192a 6737361 a2cbc5c 6737361 2ad50f4 a2cbc5c 2ad50f4 b88dc75 dcab2e7 b88dc75 a2cbc5c 7547388 6737361 b88dc75 6737361 23af0a7 6737361 dcab2e7 b88dc75 6737361 a2cbc5c 21c192a ab6d17c 23af0a7 21c192a 60dfd5a 21c192a ab6d17c 21c192a 7547388 a2cbc5c 21c192a 7547388 bfe0d21 7547388 21c192a 23af0a7 21c192a dcab2e7 ab6d17c 7547388 dcab2e7 7547388 dcab2e7 21c192a ab6d17c 23af0a7 dcab2e7 21c192a dcab2e7 21c192a dcab2e7 21c192a dcab2e7 21c192a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 | """Browser-side multi-file Python classroom for the Telearn demo Space."""
from __future__ import annotations
import asyncio, hmac, io, json, logging, os, re, sqlite3, threading, time, zipfile
from collections import deque
from pathlib import Path, PurePosixPath
from typing import Any
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pydantic import BaseModel
ROOT=Path(__file__).resolve().parent; STATIC=ROOT/"classroom_static"; DATA=ROOT/"classroom_data"; TEMPLATES=DATA/"templates"; REPAIRS=DATA/"repairs"; CHECKPOINTS=DATA/"checkpoints"; VERSIONS=DATA/"versions"; PUBLISHED=ROOT/"projects"
RUNTIME=Path(os.getenv("CLASSROOM_DATA_DIR","/tmp/telearn_classroom")); DB=RUNTIME/"classroom.sqlite3"
PASSWORD=os.getenv("CLASSROOM_TEACHER_PASSWORD",""); REPO=os.getenv("CLASSROOM_DEMO_REPO",os.getenv("SPACE_ID","Telearn/Project_Demo")).strip(); DEMO_CLASS=os.getenv("CLASSROOM_DEMO_CLASS","2026Fall進階專題"); TOKEN=os.getenv("HF_TOKEN","").strip()
COURSE_STORAGE_QUOTA_BYTES=max(16, int(os.getenv("CLASSROOM_COURSE_QUOTA_MB", "1024"))) * 1024 * 1024
MAX_VERSIONS_PER_KIND=max(2, int(os.getenv("CLASSROOM_MAX_VERSIONS_PER_KIND", "100")))
CHECKPOINTS_PER_HOUR=max(1, int(os.getenv("CLASSROOM_CHECKPOINTS_PER_HOUR", "60")))
SUBMISSIONS_PER_HOUR=max(1, int(os.getenv("CLASSROOM_SUBMISSIONS_PER_HOUR", "20")))
# Bundled syntax repairs target work saved before 2026-10-04 00:00 Asia/Taipei.
LEGACY_REPAIR_CUTOFF_MS=1791043200000
VERSION_WRITE_COOLDOWN_MS=max(0, int(os.getenv("CLASSROOM_VERSION_COOLDOWN_MS", "1500")))
HELP_REQUEST_COOLDOWN_MS=max(0, int(os.getenv("CLASSROOM_HELP_COOLDOWN_MS", "300000")))
STUDENT_WS_CONNECTIONS_PER_MINUTE=max(10, int(os.getenv("CLASSROOM_WS_CONNECTIONS_PER_MINUTE", "300")))
STUDENT_WS_MESSAGES_PER_MINUTE=max(60, int(os.getenv("CLASSROOM_WS_MESSAGES_PER_MINUTE", "300")))
ALLOWED={".py",".json",".txt",".csv",".md",".toml",".yaml",".yml"}; ID_RE=re.compile(r"[^0-9A-Za-z_\-一-鿿]")
router=APIRouter(); lock=threading.Lock(); persist_lock=threading.Lock(); students={}; teachers={}; live={}; logger=logging.getLogger(__name__)
_student_connection_windows={}
_student_message_windows={}
def allow_student_connection(remote_ip):
"""Bound repeated WebSocket handshakes per client IP without persisting IPs."""
current=time.monotonic(); window=_student_connection_windows.get(remote_ip)
if window is None:
window=deque(); _student_connection_windows[remote_ip]=window
while window and current-window[0]>=60:window.popleft()
if len(window)>=STUDENT_WS_CONNECTIONS_PER_MINUTE:return False
window.append(current)
if len(_student_connection_windows)>4096:
for key,old_window in list(_student_connection_windows.items()):
while old_window and current-old_window[0]>=60:old_window.popleft()
if not old_window:_student_connection_windows.pop(key,None)
if len(_student_connection_windows)<=3072:break
return True
def allow_student_message(student_key):
"""Apply a rolling message budget across reconnects for the same student/workspace."""
current=time.monotonic(); window=_student_message_windows.get(student_key)
if window is None:
window=deque(); _student_message_windows[student_key]=window
while window and current-window[0]>=60:window.popleft()
if len(window)>=STUDENT_WS_MESSAGES_PER_MINUTE:return False
window.append(current)
if len(_student_message_windows)>4096:
for key,old_window in list(_student_message_windows.items()):
while old_window and current-old_window[0]>=60:old_window.popleft()
if not old_window:_student_message_windows.pop(key,None)
if len(_student_message_windows)<=3072:break
return True
class TeacherRequest(BaseModel): class_code:str; week_id:str; password:str
class CourseCreate(BaseModel): class_code:str; course_name:str=""; password:str
class CourseUpdate(BaseModel): class_code:str; course_name:str; password:str
class CourseDelete(BaseModel): class_code:str; password:str
class StudentCreate(BaseModel): class_code:str; display_names:list[str]; password:str
class StudentUpdate(BaseModel): class_code:str; student_id:str; display_name:str; password:str
class StudentLogin(BaseModel): class_code:str; display_name:str
class StudentDelete(BaseModel): class_code:str; student_id:str; password:str
class VersionLimitError(Exception): pass
def clean(v,fallback="item",limit=48): return ID_RE.sub("",str(v).strip())[:limit] or fallback
def clean_label(v,fallback="",limit=80): return re.sub(r"[\x00-\x1f\x7f]", "", str(v).strip())[:limit] or fallback
def safe_path(v):
v=str(v).replace("\\","/").strip("/"); p=PurePosixPath(v)
return p.as_posix() if v and not p.is_absolute() and ".." not in p.parts and p.suffix.lower() in ALLOWED and len(v)<161 else None
def norm_files(value):
out={}; total=0
if isinstance(value,dict):
for raw,content in list(value.items())[:40]:
path=safe_path(raw); text=str(content); size=len(text.encode())
if path and total+size<=800000: out[path]=text; total+=size
out.setdefault("main.py","# 專案入口\n"); return out
def now(): return int(time.time()*1000)
def init_db():
RUNTIME.mkdir(parents=True,exist_ok=True)
os.chmod(RUNTIME,0o700)
with lock,sqlite3.connect(DB) as c:
c.execute("""CREATE TABLE IF NOT EXISTS classroom_projects(class_code TEXT,student_id TEXT,display_name TEXT,week_id TEXT,project_name TEXT,draft_files TEXT,final_files TEXT,active_file TEXT,output TEXT,status TEXT,needs_help INTEGER,updated_at INTEGER,submitted_at INTEGER,checkpoint_files TEXT,checkpoint_at INTEGER,PRIMARY KEY(class_code,student_id,week_id))""")
columns={row[1] for row in c.execute("PRAGMA table_info(classroom_projects)")}
if "checkpoint_files" not in columns:c.execute("ALTER TABLE classroom_projects ADD COLUMN checkpoint_files TEXT")
if "checkpoint_at" not in columns:c.execute("ALTER TABLE classroom_projects ADD COLUMN checkpoint_at INTEGER")
if "help_requested_at" not in columns:c.execute("ALTER TABLE classroom_projects ADD COLUMN help_requested_at INTEGER")
c.execute("""CREATE TABLE IF NOT EXISTS classroom_courses(class_code TEXT PRIMARY KEY,course_name TEXT,created_at INTEGER)""")
c.execute("""CREATE TABLE IF NOT EXISTS classroom_members(class_code TEXT,student_id TEXT,display_name TEXT,created_at INTEGER,PRIMARY KEY(class_code,student_id))""")
c.execute("""CREATE TABLE IF NOT EXISTS classroom_versions(class_code TEXT,student_id TEXT,display_name TEXT,week_id TEXT,version_id TEXT,kind TEXT,project_name TEXT,files TEXT,created_at INTEGER,PRIMARY KEY(class_code,student_id,week_id,version_id))""")
c.execute("""CREATE TABLE IF NOT EXISTS classroom_migrations(migration_id TEXT PRIMARY KEY,applied_at INTEGER)""")
c.execute("""CREATE TABLE IF NOT EXISTS classroom_write_windows(class_code TEXT,student_id TEXT,week_id TEXT,kind TEXT,window_started INTEGER,write_count INTEGER,last_write INTEGER,PRIMARY KEY(class_code,student_id,week_id,kind))""")
c.execute("""CREATE TABLE IF NOT EXISTS classroom_help_cooldowns(class_code TEXT,student_id TEXT,last_requested_at INTEGER,cooldown_until INTEGER,PRIMARY KEY(class_code,student_id))""")
for item in json.loads((DATA/"classes.json").read_text(encoding="utf-8")):
code=clean(item.get("code"),"L6A")
c.execute("""INSERT INTO classroom_courses VALUES(?,?,?) ON CONFLICT(class_code) DO UPDATE SET course_name=excluded.course_name""",(code,clean_label(item.get("name"),code),now()))
for student in item.get("students",[]):
name=clean_label(student.get("display_name") or student.get("name"),"")
student_id=clean(student.get("student_id") or name,"")
if name and student_id:c.execute("INSERT OR IGNORE INTO classroom_members VALUES(?,?,?,?)",(code,student_id,name,now()))
submissions=DATA/"submissions"
if submissions.is_dir():
for path in submissions.glob("*/*/*.json"):
try:record=json.loads(path.read_text(encoding="utf-8"));files=norm_files(record.get("files",{}));code=clean(record.get("class_code"),"");student_id=clean(record.get("student_id"),"");name=clean_label(record.get("display_name"),"");week_id=clean(record.get("week_id"),"")
except (OSError,ValueError,TypeError):continue
if not all((code,student_id,name,week_id)):continue
submitted_at=int(record.get("submitted_at") or int(path.stat().st_mtime*1000));project_name=clean_label(record.get("project_name"),"")
c.execute("INSERT OR IGNORE INTO classroom_courses VALUES(?,?,?)",(code,code,now()))
c.execute("INSERT OR IGNORE INTO classroom_members VALUES(?,?,?,?)",(code,student_id,name,now()))
c.execute("""INSERT OR IGNORE INTO classroom_projects(class_code,student_id,display_name,week_id,project_name,draft_files,final_files,active_file,output,status,needs_help,updated_at,submitted_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",(code,student_id,name,week_id,project_name,json.dumps(files,ensure_ascii=False),json.dumps(files,ensure_ascii=False),"main.py","","idle",0,submitted_at,submitted_at))
c.execute("INSERT OR IGNORE INTO classroom_versions VALUES(?,?,?,?,?,?,?,?,?)",(code,student_id,name,week_id,f"legacy-submit-{submitted_at}","submit",project_name,json.dumps(files,ensure_ascii=False),submitted_at))
if CHECKPOINTS.is_dir():
for path in CHECKPOINTS.glob("*/*/*.json"):
try:record=json.loads(path.read_text(encoding="utf-8"));files=norm_files(record.get("files",{}));code=clean(record.get("class_code"),"");student_id=clean(record.get("student_id"),"");name=clean_label(record.get("display_name"),"");week_id=clean(record.get("week_id"),"")
except (OSError,ValueError,TypeError):continue
if not all((code,student_id,name,week_id)):continue
checkpoint_at=int(record.get("checkpoint_at") or int(path.stat().st_mtime*1000));project_name=clean_label(record.get("project_name"),"")
encoded=json.dumps(files,ensure_ascii=False)
c.execute("INSERT OR IGNORE INTO classroom_courses VALUES(?,?,?)",(code,code,now()))
c.execute("INSERT OR IGNORE INTO classroom_members VALUES(?,?,?,?)",(code,student_id,name,now()))
c.execute("""INSERT INTO classroom_projects(class_code,student_id,display_name,week_id,project_name,draft_files,final_files,active_file,output,status,needs_help,updated_at,submitted_at,checkpoint_files,checkpoint_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(class_code,student_id,week_id) DO UPDATE SET checkpoint_files=excluded.checkpoint_files,checkpoint_at=excluded.checkpoint_at,draft_files=CASE WHEN excluded.checkpoint_at>=classroom_projects.updated_at THEN excluded.draft_files ELSE classroom_projects.draft_files END,project_name=CASE WHEN excluded.checkpoint_at>=classroom_projects.updated_at THEN excluded.project_name ELSE classroom_projects.project_name END,updated_at=MAX(classroom_projects.updated_at,excluded.updated_at)""",(code,student_id,name,week_id,project_name,encoded,None,"main.py","","idle",0,checkpoint_at,None,encoded,checkpoint_at))
c.execute("INSERT OR IGNORE INTO classroom_versions VALUES(?,?,?,?,?,?,?,?,?)",(code,student_id,name,week_id,f"legacy-checkpoint-{checkpoint_at}","checkpoint",project_name,encoded,checkpoint_at))
if VERSIONS.is_dir():
for path in VERSIONS.glob("*/*/*/*.json"):
try:
record=json.loads(path.read_text(encoding="utf-8"));files=norm_files(record.get("files",{}));code=clean(record.get("class_code"),"");student_id=clean(record.get("student_id"),"");name=clean_label(record.get("display_name"),"");week_id=clean(record.get("week_id"),"");kind=record.get("kind");created_at=int(record.get("created_at") or int(path.stat().st_mtime*1000));version_id=clean(record.get("version_id") or path.stem,"");project_name=clean_label(record.get("project_name"),"")
except (OSError,ValueError,TypeError):continue
if not all((code,student_id,name,week_id,version_id)) or kind not in {"checkpoint","submit"}:continue
encoded=json.dumps(files,ensure_ascii=False)
c.execute("INSERT OR IGNORE INTO classroom_courses VALUES(?,?,?)",(code,code,now()))
c.execute("INSERT OR IGNORE INTO classroom_members VALUES(?,?,?,?)",(code,student_id,name,now()))
c.execute("INSERT OR IGNORE INTO classroom_versions VALUES(?,?,?,?,?,?,?,?,?)",(code,student_id,name,week_id,version_id,kind,project_name,encoded,created_at))
c.execute("""INSERT OR IGNORE INTO classroom_projects(class_code,student_id,display_name,week_id,project_name,draft_files,final_files,active_file,output,status,needs_help,updated_at,submitted_at,checkpoint_files,checkpoint_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",(code,student_id,name,week_id,project_name,encoded,encoded if kind=="submit" else None,"main.py","","idle",0,created_at,created_at if kind=="submit" else None,encoded if kind=="checkpoint" else None,created_at if kind=="checkpoint" else None))
time_column="checkpoint_at" if kind=="checkpoint" else "submitted_at";files_column="checkpoint_files" if kind=="checkpoint" else "final_files"
c.execute(f"UPDATE classroom_projects SET {files_column}=CASE WHEN ? >= COALESCE({time_column},0) THEN ? ELSE {files_column} END,{time_column}=MAX(COALESCE({time_column},0),?),draft_files=CASE WHEN ?>=updated_at THEN ? ELSE draft_files END,project_name=CASE WHEN ?>=updated_at THEN ? ELSE project_name END,updated_at=MAX(updated_at,?) WHERE class_code=? AND student_id=? AND week_id=?",(created_at,encoded,created_at,created_at,encoded,created_at,project_name,created_at,code,student_id,week_id))
c.execute("""DELETE FROM classroom_versions AS legacy
WHERE legacy.version_id LIKE 'legacy-%'
AND EXISTS (
SELECT 1 FROM classroom_versions AS saved
WHERE saved.class_code=legacy.class_code
AND saved.student_id=legacy.student_id
AND saved.week_id=legacy.week_id
AND saved.kind=legacy.kind
AND saved.created_at=legacy.created_at
AND saved.version_id NOT LIKE 'legacy-%'
)""")
migration_id="lesson2_syntax_repair_20260916"
if not c.execute("SELECT 1 FROM classroom_migrations WHERE migration_id=?",(migration_id,)).fetchone():
for repair in REPAIRS.glob("*/*/*.py") if REPAIRS.is_dir() else []:
class_code,week_id=repair.parts[-3],repair.parts[-2];student_id=repair.stem
row=c.execute("SELECT draft_files,final_files,checkpoint_at,submitted_at,updated_at FROM classroom_projects WHERE class_code=? AND student_id=? AND week_id=?",(class_code,student_id,week_id)).fetchone()
if not row:continue
if max(int(row[2] or 0),int(row[3] or 0),int(row[4] or 0))>=LEGACY_REPAIR_CUTOFF_MS:continue
draft=json.loads(row[0]);final=json.loads(row[1]) if row[1] else None;fixed=repair.read_text(encoding="utf-8")
if row[2] is None or row[2]<int(row[3] or 0):draft["main.py"]=fixed
if final is not None:final["main.py"]=fixed
c.execute("UPDATE classroom_projects SET draft_files=?,final_files=?,updated_at=? WHERE class_code=? AND student_id=? AND week_id=?",(json.dumps(draft,ensure_ascii=False),json.dumps(final,ensure_ascii=False) if final is not None else None,now(),class_code,student_id,week_id))
c.execute("INSERT INTO classroom_migrations VALUES(?,?)",(migration_id,now()))
c.execute("""UPDATE classroom_projects SET display_name=(
SELECT display_name FROM classroom_members
WHERE classroom_members.class_code=classroom_projects.class_code
AND classroom_members.student_id=classroom_projects.student_id
) WHERE EXISTS (
SELECT 1 FROM classroom_members
WHERE classroom_members.class_code=classroom_projects.class_code
AND classroom_members.student_id=classroom_projects.student_id
)""")
c.execute("""UPDATE classroom_versions SET display_name=(
SELECT display_name FROM classroom_members
WHERE classroom_members.class_code=classroom_versions.class_code
AND classroom_members.student_id=classroom_versions.student_id
) WHERE EXISTS (
SELECT 1 FROM classroom_members
WHERE classroom_members.class_code=classroom_versions.class_code
AND classroom_members.student_id=classroom_versions.student_id
)""")
def help_cooldown_until(class_code,student_id):
if HELP_REQUEST_COOLDOWN_MS<=0:return None
with lock,sqlite3.connect(DB) as db:row=db.execute("SELECT cooldown_until FROM classroom_help_cooldowns WHERE class_code=? AND student_id=?",(class_code,student_id)).fetchone()
until=int(row[0]) if row and row[0] else None
return until if until and until>now() else None
def start_help_cooldown(class_code,student_id):
current=now()
if HELP_REQUEST_COOLDOWN_MS<=0:return current,None
with lock,sqlite3.connect(DB) as db:
db.execute("BEGIN IMMEDIATE")
row=db.execute("SELECT cooldown_until FROM classroom_help_cooldowns WHERE class_code=? AND student_id=?",(class_code,student_id)).fetchone()
until=int(row[0]) if row and row[0] else 0
if until>current:return None,until
cooldown_until=current+HELP_REQUEST_COOLDOWN_MS
db.execute("INSERT INTO classroom_help_cooldowns VALUES(?,?,?,?) ON CONFLICT(class_code,student_id) DO UPDATE SET last_requested_at=excluded.last_requested_at,cooldown_until=excluded.cooldown_until",(class_code,student_id,current,cooldown_until))
return current,cooldown_until
def empty(c,s,n,w): return {"class_code":c,"student_id":s,"display_name":n,"week_id":w,"project_name":"","files":{"main.py":"# 在這裡寫 Python\n"},"final_files":None,"checkpoint_files":None,"active_file":"main.py","output":"","status":"idle","needs_help":False,"updated_at":0,"submitted_at":None,"checkpoint_at":None,"help_requested_at":None,"help_cooldown_until":help_cooldown_until(c,s),"help_cooldown_ms":HELP_REQUEST_COOLDOWN_MS,"connected":True}
def from_row(r,connected=False): return {"class_code":r[0],"student_id":r[1],"display_name":r[2],"week_id":r[3],"project_name":r[4],"files":json.loads(r[5]),"final_files":json.loads(r[6]) if r[6] else None,"active_file":r[7],"output":r[8],"status":r[9],"needs_help":bool(r[10]),"updated_at":r[11],"submitted_at":r[12],"checkpoint_files":json.loads(r[13]) if r[13] else None,"checkpoint_at":r[14],"help_requested_at":r[15],"help_cooldown_ms":HELP_REQUEST_COOLDOWN_MS,"connected":connected}
SELECT="SELECT class_code,student_id,display_name,week_id,project_name,draft_files,final_files,active_file,output,status,needs_help,updated_at,submitted_at,checkpoint_files,checkpoint_at,help_requested_at FROM classroom_projects"
def load(c,s,n,w):
with lock,sqlite3.connect(DB) as db:r=db.execute(SELECT+" WHERE class_code=? AND student_id=? AND week_id=?",(c,s,w)).fetchone()
state=from_row(r,True) if r else empty(c,s,n,w); state["display_name"]=n; state["help_cooldown_until"]=help_cooldown_until(c,s); state["help_cooldown_ms"]=HELP_REQUEST_COOLDOWN_MS; return state
def save(x):
values=(x["class_code"],x["student_id"],x["display_name"],x["week_id"],x["project_name"],json.dumps(x["files"],ensure_ascii=False),json.dumps(x["final_files"],ensure_ascii=False) if x["final_files"] else None,x["active_file"],x["output"][-40000:],x["status"],int(x["needs_help"]),x["updated_at"],x["submitted_at"],json.dumps(x["checkpoint_files"],ensure_ascii=False) if x.get("checkpoint_files") else None,x.get("checkpoint_at"),x.get("help_requested_at"))
with lock,sqlite3.connect(DB) as db:
db.execute("INSERT OR IGNORE INTO classroom_courses VALUES(?,?,?)",(x["class_code"],x["class_code"],now()))
db.execute("""INSERT INTO classroom_members VALUES(?,?,?,?) ON CONFLICT(class_code,student_id) DO UPDATE SET display_name=excluded.display_name""",(x["class_code"],x["student_id"],x["display_name"],now()))
db.execute("""INSERT INTO classroom_projects(class_code,student_id,display_name,week_id,project_name,draft_files,final_files,active_file,output,status,needs_help,updated_at,submitted_at,checkpoint_files,checkpoint_at,help_requested_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(class_code,student_id,week_id) DO UPDATE SET display_name=excluded.display_name,project_name=excluded.project_name,draft_files=excluded.draft_files,final_files=excluded.final_files,active_file=excluded.active_file,output=excluded.output,status=excluded.status,needs_help=excluded.needs_help,updated_at=excluded.updated_at,submitted_at=excluded.submitted_at,checkpoint_files=excluded.checkpoint_files,checkpoint_at=excluded.checkpoint_at,help_requested_at=excluded.help_requested_at""",values)
def catalog():
with lock,sqlite3.connect(DB) as db:
courses=db.execute("SELECT class_code,course_name FROM classroom_courses ORDER BY course_name,class_code").fetchall()
members=db.execute("SELECT class_code,student_id,display_name FROM classroom_members ORDER BY display_name").fetchall()
by_class={code:[] for code,_ in courses}
for code,student_id,name in members:by_class.setdefault(code,[]).append({"student_id":student_id,"display_name":name})
return {"courses":[{"class_code":code,"course_name":name,"students":by_class.get(code,[])} for code,name in courses]}
def public_catalog():return {"courses":[{"class_code":x["class_code"],"course_name":x["course_name"]} for x in catalog()["courses"]]}
def find_member(class_code,display_name="",student_id=""):
with lock,sqlite3.connect(DB) as db:rows=db.execute("SELECT student_id,display_name FROM classroom_members WHERE class_code=?",(class_code,)).fetchall()
wanted=clean_label(display_name,"").casefold()
for member_id,name in rows:
if student_id and member_id==student_id and (not wanted or name.casefold()==wanted):return {"student_id":member_id,"display_name":name}
if not student_id and wanted and name.casefold()==wanted:return {"student_id":member_id,"display_name":name}
return None
def submission_path(class_code,student_id,week_id):return DATA/"submissions"/clean(class_code,"")/clean(student_id,"")/f"{clean(week_id,'')}.json"
def submission_payload(state):
return {key:state[key] for key in ("class_code","student_id","display_name","week_id","project_name","submitted_at")}|{"files":state["final_files"]}
def submission_catalog():
with lock,sqlite3.connect(DB) as db:rows=db.execute(SELECT+" WHERE final_files IS NOT NULL ORDER BY class_code,display_name,week_id").fetchall()
return [{"class_code":x["class_code"],"student_id":x["student_id"],"display_name":x["display_name"],"week_id":x["week_id"],"project_name":x["project_name"],"submitted_at":x["submitted_at"],"file_count":len(x["final_files"] or {})} for x in (from_row(row) for row in rows)]
def get_submission(class_code,student_id,week_id):
with lock,sqlite3.connect(DB) as db:row=db.execute(SELECT+" WHERE class_code=? AND student_id=? AND week_id=? AND final_files IS NOT NULL",(clean(class_code,""),clean(student_id,""),clean(week_id,""))).fetchone()
if not row:raise HTTPException(404,"找不到這份提交")
return from_row(row)
def consume_version_write(class_code,student_id,week_id,kind):
"""Persist a per-student/workspace hourly write budget in SQLite."""
current=now(); window_ms=60*60*1000
limit=CHECKPOINTS_PER_HOUR if kind=="checkpoint" else SUBMISSIONS_PER_HOUR
with lock,sqlite3.connect(DB) as db:
row=db.execute("SELECT window_started,write_count,last_write FROM classroom_write_windows WHERE class_code=? AND student_id=? AND week_id=? AND kind=?",(class_code,student_id,week_id,kind)).fetchone()
if row is None or current-row[0]>=window_ms:
window_started=current; count=0; last_write=0
else:
window_started,count,last_write=row
if last_write and current-last_write<VERSION_WRITE_COOLDOWN_MS:
raise VersionLimitError("儲存太頻繁,請稍候再試。")
if count>=limit:
raise VersionLimitError(f"已達本 Lesson 每小時{limit}次上限,稍後再試。")
db.execute("INSERT INTO classroom_write_windows VALUES(?,?,?,?,?,?,?) ON CONFLICT(class_code,student_id,week_id,kind) DO UPDATE SET window_started=excluded.window_started,write_count=excluded.write_count,last_write=excluded.last_write",(class_code,student_id,week_id,kind,window_started,count+1,current))
def record_version(state,kind):
created_at=state["checkpoint_at"] if kind=="checkpoint" else state["submitted_at"]
files=state["checkpoint_files"] if kind=="checkpoint" else state["final_files"]
version_id=f"{created_at}-{kind}-{time.time_ns()%1000000:06d}"
record={"class_code":state["class_code"],"student_id":state["student_id"],"display_name":state["display_name"],"week_id":state["week_id"],"version_id":version_id,"kind":kind,"project_name":state["project_name"],"created_at":created_at,"files":dict(files)}
encoded_files=json.dumps(record["files"],ensure_ascii=False)
draft_json=json.dumps(state["files"],ensure_ascii=False)
final_json=json.dumps(state["final_files"],ensure_ascii=False) if state["final_files"] else None
checkpoint_json=json.dumps(state["checkpoint_files"],ensure_ascii=False) if state["checkpoint_files"] else None
with lock,sqlite3.connect(DB) as db:
db.execute("BEGIN IMMEDIATE")
existing=db.execute("SELECT version_id,created_at,length(CAST(files AS BLOB)),kind FROM classroom_versions WHERE class_code=? AND student_id=? AND week_id=? AND kind=? ORDER BY created_at DESC,version_id DESC",(record["class_code"],record["student_id"],record["week_id"],kind)).fetchall()
pruned=existing[MAX_VERSIONS_PER_KIND-1:] if len(existing)>=MAX_VERSIONS_PER_KIND else []
pruned_bytes=sum(row[2] or 0 for row in pruned)
version_bytes=db.execute("SELECT COALESCE(SUM(length(CAST(files AS BLOB))),0) FROM classroom_versions WHERE class_code=?",(record["class_code"],)).fetchone()[0]
current_bytes=db.execute("SELECT COALESCE(SUM(length(CAST(draft_files AS BLOB))+COALESCE(length(CAST(final_files AS BLOB)),0)+COALESCE(length(CAST(checkpoint_files AS BLOB)),0)),0) FROM classroom_projects WHERE class_code=?",(record["class_code"],)).fetchone()[0]
prior=db.execute("SELECT COALESCE(length(CAST(draft_files AS BLOB)),0)+COALESCE(length(CAST(final_files AS BLOB)),0)+COALESCE(length(CAST(checkpoint_files AS BLOB)),0) FROM classroom_projects WHERE class_code=? AND student_id=? AND week_id=?",(record["class_code"],record["student_id"],record["week_id"])).fetchone()
prior_bytes=prior[0] if prior else 0
candidate_current=len(draft_json.encode())+(len(final_json.encode()) if final_json else 0)+(len(checkpoint_json.encode()) if checkpoint_json else 0)
projected=version_bytes-pruned_bytes+len(encoded_files.encode())+current_bytes-prior_bytes+candidate_current
if projected>COURSE_STORAGE_QUOTA_BYTES:
raise VersionLimitError(f"本課程已達 {COURSE_STORAGE_QUOTA_BYTES//(1024*1024)} MB 儲存上限,請聯絡老師整理舊版本。")
for old_version_id,_,_,_ in pruned:
db.execute("DELETE FROM classroom_versions WHERE class_code=? AND student_id=? AND week_id=? AND version_id=?",(record["class_code"],record["student_id"],record["week_id"],old_version_id))
db.execute("INSERT INTO classroom_versions VALUES(?,?,?,?,?,?,?,?,?)",(record["class_code"],record["student_id"],record["display_name"],record["week_id"],version_id,kind,record["project_name"],encoded_files,created_at))
if pruned:
record["_pruned"]=[{"week_id":record["week_id"],"version_id":row[0]} for row in pruned]
return record
def version_history(class_code,student_id):
with lock,sqlite3.connect(DB) as db:rows=db.execute("SELECT week_id,version_id,kind,project_name,files,created_at FROM classroom_versions WHERE class_code=? AND student_id=? ORDER BY created_at DESC,version_id DESC",(class_code,student_id)).fetchall()
return [{"week_id":week_id,"version_id":version_id,"source":kind,"project_name":project_name,"files":json.loads(files),"submitted_at":created_at} for week_id,version_id,kind,project_name,files,created_at in rows]
def archived_paths(class_code,student_id=""):
found=[]
for kind in ("submissions","checkpoints","versions"):
root=DATA/kind/clean(class_code,"")
target=root/clean(student_id,"") if student_id else root
if target.is_dir():found.extend(path.relative_to(ROOT).as_posix() for path in target.rglob("*.json"))
return found
def snapshot(c,w):
with lock,sqlite3.connect(DB) as db:rows=db.execute(SELECT+" WHERE class_code=? AND week_id=?",(c,w)).fetchall()
found={r[1]:from_row(r) for r in rows}
with lock,sqlite3.connect(DB) as db:
members=db.execute("SELECT student_id,display_name FROM classroom_members WHERE class_code=?",(c,)).fetchall()
version_rows=db.execute("SELECT student_id,kind,COUNT(*) FROM classroom_versions WHERE class_code=? AND week_id=? GROUP BY student_id,kind",(c,w)).fetchall()
counts={}
for student_id,kind,total in version_rows:counts.setdefault(student_id,{"checkpoint":0,"submit":0})[kind]=total
for student_id,name in members:found.setdefault(student_id,empty(c,student_id,name,w)|{"connected":False})
for (cc,s,ww),state in live.items():
if cc==c and ww==w:found[s]=state.copy()
for student_id,state in found.items():state["version_counts"]=counts.get(student_id,{"checkpoint":0,"submit":0})
return sorted(found.values(),key=lambda x:(not x["needs_help"],x["display_name"]))
async def broadcast(c,w):
payload={"type":"snapshot","students":snapshot(c,w)}
for ws in teachers.get((c,w),set()).copy():
try:await ws.send_json(payload)
except Exception:teachers.get((c,w),set()).discard(ws)
def template_files(w):
root=TEMPLATES/clean(w,"week01")
if not root.is_dir():raise HTTPException(404,"這個 Lesson 尚未發布套版")
files={p.relative_to(root).as_posix():p.read_text(encoding="utf-8") for p in root.rglob("*") if p.is_file() and p.suffix.lower() in ALLOWED}
if not files:raise HTTPException(404,"這個 Lesson 尚未發布套版")
return norm_files(files)
def demo_class_name(class_code):
with lock,sqlite3.connect(DB) as db:row=db.execute("SELECT course_name FROM classroom_courses WHERE class_code=?",(clean(class_code,""),)).fetchone()
name=clean_label(row[0],"") if row else ""
return name if name.startswith("2026Fall進階專題") else DEMO_CLASS
def published_progress(class_code,display_name):
student_root=PUBLISHED/demo_class_name(class_code)/clean(display_name,"student"); found=[]
if not student_root.is_dir():return found
for project_root in sorted(student_root.iterdir()):
match=re.match(r"^(week\d{2})_(.+)$",project_root.name)
if not match or not project_root.is_dir():continue
files=norm_files({p.relative_to(project_root).as_posix():p.read_text(encoding="utf-8") for p in project_root.rglob("*") if p.is_file() and p.suffix.lower() in ALLOWED})
found.append({"week_id":match.group(1),"project_name":match.group(2),"files":files,"submitted_at":int(project_root.stat().st_mtime*1000),"source":"published"})
return found
def check_password(p):
if not PASSWORD:raise HTTPException(503,"請先在 Space Secrets 設定 CLASSROOM_TEACHER_PASSWORD")
if not hmac.compare_digest(p,PASSWORD):raise HTTPException(403,"教師密碼不正確")
def hf_failure_message(exc):
"""Return an actionable message without ever exposing a token."""
status=getattr(getattr(exc,"response",None),"status_code",None)
detail=getattr(exc,"server_message",None) or str(exc) or type(exc).__name__
detail=re.sub(r"hf_[A-Za-z0-9]+","hf_***",re.sub(r"\s+"," ",detail)).strip()[:220]
if status==401:reason="HF_TOKEN 未被 Space 正確載入、已失效或貼入值不正確"
elif status==403:reason=f"HF_TOKEN 已被載入,但對 {REPO} 的 Space 寫入驗證被拒絕"
elif status==404:reason=f"找不到目標 Space {REPO},請檢查 CLASSROOM_DEMO_REPO 或 SPACE_ID"
else:reason=f"連線到 Hugging Face 時發生 {type(exc).__name__}"
suffix=f"HTTP {status}:{detail}" if status else detail
return f"課程已可使用,但尚未永久寫入 Space。{reason}。詳情:{suffix}"
def persist_catalog(message,deletions=None):
if not TOKEN:return "名單已可使用,但尚未永久寫入 Space;請設定 HF_TOKEN。"
try:
from huggingface_hub import CommitOperationAdd,CommitOperationDelete,HfApi
payload=[]
for course in catalog()["courses"]:payload.append({"code":course["class_code"],"name":course["course_name"],"students":course["students"]})
content=(json.dumps(payload,ensure_ascii=False,indent=2)+"\n").encode()
operations=[CommitOperationDelete(path_in_repo=path) for path in sorted(set(deletions or []))]
operations.append(CommitOperationAdd(path_in_repo="classroom_data/classes.json",path_or_fileobj=content))
with persist_lock:HfApi(token=TOKEN).create_commit(repo_id=REPO,repo_type="space",operations=operations,commit_message=message)
return ""
except Exception as exc:
logger.exception("Could not persist classroom roster to %s",REPO)
return hf_failure_message(exc)
def persist_version(record):
if not TOKEN:return "版本已保存在教室,但尚未永久寫入 Space;請設定 HF_TOKEN。"
try:
from huggingface_hub import CommitOperationAdd,CommitOperationDelete,HfApi
pruned=record.get("_pruned",[])
payload={key:value for key,value in record.items() if key!="_pruned"}
content=(json.dumps(payload,ensure_ascii=False,indent=2)+"\n").encode()
path=PurePosixPath("classroom_data","versions",clean(record["class_code"],""),clean(record["student_id"],""),clean(record["week_id"],"week"),f"{clean(record['version_id'],'version')}.json").as_posix()
label="Checkpoint" if record["kind"]=="checkpoint" else "Submission"
api=HfApi(token=TOKEN);operations=[]
if pruned:
existing=set(api.list_repo_files(repo_id=REPO,repo_type="space"))
for old in pruned:
old_path=PurePosixPath("classroom_data","versions",clean(record["class_code"],""),clean(record["student_id"],""),clean(old["week_id"],"week"),f"{clean(old['version_id'],'version')}.json").as_posix()
if old_path in existing:operations.append(CommitOperationDelete(path_in_repo=old_path))
operations.append(CommitOperationAdd(path_in_repo=path,path_or_fileobj=content))
with persist_lock:api.create_commit(repo_id=REPO,repo_type="space",operations=operations,commit_message=f"{label} {record['class_code']} {record['student_id']} {record['week_id']}")
return ""
except Exception as exc:
logger.exception("Could not persist student version to %s",REPO)
return hf_failure_message(exc)
def finals(c,w):return [x for x in snapshot(c,w) if x.get("final_files")]
def make_zip(c,w):
b=io.BytesIO()
with zipfile.ZipFile(b,"w",zipfile.ZIP_DEFLATED) as z:
for x in finals(c,w):
root=PurePosixPath(demo_class_name(c),clean(x["display_name"],"student"),f"{w}_{clean(x['project_name'],'project')}")
for path,text in x["final_files"].items():z.writestr((root/path).as_posix(),text)
return b.getvalue()
@router.get("/classroom")
def student_page():return FileResponse(STATIC/"student.html")
@router.get("/classroom/teacher")
def teacher_page():return FileResponse(STATIC/"teacher.html")
@router.get("/api/classroom/course")
def course():return JSONResponse(json.loads((DATA/"course.json").read_text(encoding="utf-8")))
@router.get("/api/classroom/classes")
def classes():return public_catalog()
@router.post("/api/classroom/student-login")
def student_login(req:StudentLogin):
member=find_member(clean(req.class_code,""),clean_label(req.display_name,""))
if not member:raise HTTPException(403,"姓名不在這個班級的名單中,請確認輸入或請老師新增名單。")
return member
@router.post("/api/classroom/classes")
def create_class(req:CourseCreate):
check_password(req.password); code=clean(req.class_code,""); name=clean_label(req.course_name,code)
if not code:raise HTTPException(400,"請輸入課程代碼")
with lock,sqlite3.connect(DB) as db:
existing=db.execute("SELECT course_name FROM classroom_courses WHERE class_code=?",(code,)).fetchone()
if existing:name=existing[0]
else:db.execute("INSERT INTO classroom_courses VALUES(?,?,?)",(code,name,now()))
warning=persist_catalog(f"Add classroom course {code}")
return {"ok":True,"class_code":code,"course_name":name,"already_exists":bool(existing),"warning":warning}
@router.put("/api/classroom/classes")
def update_class(req:CourseUpdate):
check_password(req.password);code=clean(req.class_code,"");name=clean_label(req.course_name,"")
if not code or not name:raise HTTPException(400,"請輸入班級名稱")
with lock,sqlite3.connect(DB) as db:
if not db.execute("SELECT 1 FROM classroom_courses WHERE class_code=?",(code,)).fetchone():raise HTTPException(404,"找不到這個班級")
db.execute("UPDATE classroom_courses SET course_name=? WHERE class_code=?",(name,code))
return {"ok":True,"warning":persist_catalog(f"Rename classroom course {code}"),"course_name":name}
@router.delete("/api/classroom/classes")
async def delete_class(req:CourseDelete):
check_password(req.password);code=clean(req.class_code,"");deletions=archived_paths(code)
with lock,sqlite3.connect(DB) as db:
if not db.execute("SELECT 1 FROM classroom_courses WHERE class_code=?",(code,)).fetchone():raise HTTPException(404,"找不到這個班級")
db.execute("DELETE FROM classroom_versions WHERE class_code=?",(code,));db.execute("DELETE FROM classroom_projects WHERE class_code=?",(code,));db.execute("DELETE FROM classroom_members WHERE class_code=?",(code,));db.execute("DELETE FROM classroom_help_cooldowns WHERE class_code=?",(code,));db.execute("DELETE FROM classroom_courses WHERE class_code=?",(code,))
for key,ws in list(students.items()):
if key[0]==code:
students.pop(key,None);live.pop(key,None)
try:await ws.close(code=4004,reason="class removed")
except Exception:pass
return {"ok":True,"warning":persist_catalog(f"Delete classroom course {code}",deletions)}
@router.post("/api/classroom/students")
async def create_students(req:StudentCreate):
check_password(req.password); code=clean(req.class_code,"")
with lock,sqlite3.connect(DB) as db:
if not db.execute("SELECT 1 FROM classroom_courses WHERE class_code=?",(code,)).fetchone():raise HTTPException(404,"找不到這個班級")
existing=db.execute("SELECT student_id,display_name FROM classroom_members WHERE class_code=?",(code,)).fetchall();used={row[0] for row in existing};names={row[1].casefold() for row in existing};created=[]
for raw_name in req.display_names[:40]:
name=clean_label(raw_name,"")
if not name or name.casefold() in names:continue
base=clean(name,"student");student_id=base;number=2
while student_id in used:student_id=f"{base[:42]}-{number}";number+=1
db.execute("INSERT INTO classroom_members VALUES(?,?,?,?)",(code,student_id,name,now()));used.add(student_id);names.add(name.casefold());created.append({"student_id":student_id,"display_name":name})
if not created:raise HTTPException(400,"請輸入尚未在名單中的學生姓名")
warning=persist_catalog(f"Add {len(created)} classroom students to {code}")
for class_code,week_id in list(teachers):
if class_code==code:await broadcast(code,week_id)
return {"ok":True,"created":created,"warning":warning}
@router.put("/api/classroom/students")
async def update_student(req:StudentUpdate):
check_password(req.password);code=clean(req.class_code,"");student_id=clean(req.student_id,"student");name=clean_label(req.display_name,"")
if not code or not student_id:raise HTTPException(400,"找不到班級或學生")
if not name:raise HTTPException(400,"請輸入學生姓名")
with lock,sqlite3.connect(DB) as db:
members=db.execute("SELECT student_id,display_name FROM classroom_members WHERE class_code=?",(code,)).fetchall()
current=next((member for member in members if member[0]==student_id),None)
if not current:raise HTTPException(404,"找不到這位學生")
if any(member_id!=student_id and display_name.casefold()==name.casefold() for member_id,display_name in members):
raise HTTPException(409,"這個班級已有相同姓名,請確認名單")
db.execute("UPDATE classroom_members SET display_name=? WHERE class_code=? AND student_id=?",(name,code,student_id))
db.execute("UPDATE classroom_projects SET display_name=? WHERE class_code=? AND student_id=?",(name,code,student_id))
db.execute("UPDATE classroom_versions SET display_name=? WHERE class_code=? AND student_id=?",(name,code,student_id))
for key,state in list(live.items()):
if key[0]!=code or key[1]!=student_id:continue
state["display_name"]=name
target=students.get(key)
if target:
try:await target.send_json({"type":"identity","display_name":name})
except Exception:pass
warning=persist_catalog(f"Rename classroom student {student_id} in {code}")
for class_code,week_id in list(teachers):
if class_code==code:await broadcast(code,week_id)
return {"ok":True,"student_id":student_id,"display_name":name,"warning":warning}
@router.delete("/api/classroom/students")
async def delete_student(req:StudentDelete):
check_password(req.password); code=clean(req.class_code,"L6A"); student_id=clean(req.student_id,"student");deletions=archived_paths(code,student_id)
targets=[(key,ws) for key,ws in students.items() if key[0]==code and key[1]==student_id]
for key,ws in targets:
students.pop(key,None);live.pop(key,None)
try:await ws.close(code=4004,reason="removed")
except Exception:pass
with lock,sqlite3.connect(DB) as db:
db.execute("DELETE FROM classroom_versions WHERE class_code=? AND student_id=?",(code,student_id))
db.execute("DELETE FROM classroom_projects WHERE class_code=? AND student_id=?",(code,student_id))
db.execute("DELETE FROM classroom_members WHERE class_code=? AND student_id=?",(code,student_id))
db.execute("DELETE FROM classroom_help_cooldowns WHERE class_code=? AND student_id=?",(code,student_id))
warning=persist_catalog(f"Remove classroom student {student_id} from {code}",deletions)
for class_code,week_id in list(teachers):
if class_code==code:await broadcast(code,week_id)
return {"ok":True,"warning":warning}
@router.get("/api/classroom/template/{week_id}")
def template(week_id):return {"week_id":week_id,"files":template_files(week_id)}
@router.get("/api/classroom/progress")
def progress(class_code:str,student_id:str,display_name:str=""):
class_code=clean(class_code,"L6A");student_id=clean(student_id,"student")
history=version_history(class_code,student_id)
published=published_progress(class_code,display_name)
return {"progress":sorted(history+published,key=lambda item:(item["submitted_at"] or 0),reverse=True)}
@router.post("/api/classroom/export")
def export(req:TeacherRequest):
check_password(req.password); payload=make_zip(clean(req.class_code,"L6A"),clean(req.week_id,"week01")); name=f"{clean(req.class_code)}_{clean(req.week_id)}_final.zip"
return StreamingResponse(io.BytesIO(payload),media_type="application/zip",headers={"Content-Disposition":f'attachment; filename="{name}"'})
@router.post("/api/classroom/publish")
def publish(req:TeacherRequest):
check_password(req.password)
if not TOKEN:raise HTTPException(503,"請先在 Space Secrets 設定 HF_TOKEN")
class_code=clean(req.class_code,"L6A"); week_id=clean(req.week_id,"week01")
states=finals(class_code,week_id)
if not states:raise HTTPException(400,"目前 Lesson 尚無正式提交")
from huggingface_hub import CommitOperationAdd,CommitOperationDelete,HfApi
api=HfApi(token=TOKEN); existing=set(api.list_repo_files(repo_id=REPO,repo_type="space")); desired={}; prefixes=[]
for x in states:
root=PurePosixPath("projects",demo_class_name(class_code),clean(x["display_name"],"student"),f"{week_id}_{clean(x['project_name'],'project')}"); prefix=root.as_posix()+"/"; prefixes.append(prefix)
for path,text in x["final_files"].items():desired[(root/path).as_posix()]=text
stale=sorted(path for path in existing if any(path.startswith(prefix) for prefix in prefixes) and path not in desired)
ops=[CommitOperationDelete(path_in_repo=path) for path in stale]
ops.extend(CommitOperationAdd(path_in_repo=path,path_or_fileobj=text.encode()) for path,text in sorted(desired.items()))
result=api.create_commit(repo_id=REPO,repo_type="space",operations=ops,commit_message=f"Publish {class_code} {week_id} final projects")
return {"ok":True,"count":len(states),"commit_url":str(result.commit_url)}
@router.websocket("/ws/classroom/student")
async def student_ws(ws:WebSocket):
remote_ip=ws.client.host if ws.client else "unknown"
if not allow_student_connection(remote_ip):await ws.close(code=4429,reason="connection rate limited");return
c=clean(ws.query_params.get("class_code"),"L6A");s=clean(ws.query_params.get("student_id"),"student");requested_name=clean_label(ws.query_params.get("display_name"),"");w=clean(ws.query_params.get("week_id"),"week02");await ws.accept()
member=find_member(c,requested_name,s)
if not member:await ws.close(code=4404,reason="not on roster");return
n=member["display_name"];key=(c,s,w)
old=students.get(key)
if old and old is not ws:
try:await old.close(code=4001)
except Exception:pass
students[key]=ws;state=load(c,s,n,w);state["connected"]=True;live[key]=state;save(state);await ws.send_json({"type":"restore","state":state});await broadcast(c,w)
try:
while True:
m=await ws.receive_json()
if students.get(key) is not ws:break
if not allow_student_message(key):
await ws.close(code=4429,reason="message rate limited");break
kind=m.get("type");version=None
if kind=="project":
state["files"]=norm_files(m.get("files"));state["project_name"]=clean_label(m.get("project_name"),"");active=safe_path(m.get("active_file","main.py"));state["active_file"]=active if active in state["files"] else "main.py";state["updated_at"]=now()
elif kind=="output":state["output"]=str(m.get("output",""))[-40000:]
elif kind=="status":state["status"]=m.get("status") if m.get("status") in {"idle","running","success","error"} else "idle"
elif kind=="help":
requested=bool(m.get("needs_help"))
if requested and not state["needs_help"]:
requested_at,cooldown_until=start_help_cooldown(c,s)
if requested_at is None:
state["help_cooldown_until"]=cooldown_until
remaining=max(1,(cooldown_until-now()+999)//1000)
await ws.send_json({"type":"help_rejected","needs_help":state["needs_help"],"help_cooldown_until":cooldown_until,"help_cooldown_ms":HELP_REQUEST_COOLDOWN_MS,"message":f"還要等 {remaining} 秒才能再次呼叫老師。"})
continue
state["needs_help"]=True;state["help_requested_at"]=requested_at;state["help_cooldown_until"]=cooldown_until
elif not requested:
state["needs_help"]=False;state["help_requested_at"]=None;state["help_cooldown_until"]=help_cooldown_until(c,s)
elif kind=="checkpoint":
try:
consume_version_write(c,s,w,"checkpoint")
state["files"]=norm_files(m.get("files",state["files"]));state["project_name"]=clean_label(m.get("project_name",state["project_name"]),"");state["checkpoint_files"]=dict(state["files"]);state["checkpoint_at"]=now();state["updated_at"]=state["checkpoint_at"];version=record_version(state,"checkpoint")
except VersionLimitError as exc:
await ws.send_json({"type":"write_rejected","kind":"checkpoint","message":str(exc)});continue
elif kind=="submit":
try:
consume_version_write(c,s,w,"submit")
state["files"]=norm_files(m.get("files",state["files"]));state["project_name"]=clean_label(m.get("project_name",state["project_name"]),"");state["final_files"]=dict(state["files"]);state["submitted_at"]=now();state["updated_at"]=state["submitted_at"];version=record_version(state,"submit")
except VersionLimitError as exc:
await ws.send_json({"type":"write_rejected","kind":"submit","message":str(exc)});continue
elif kind=="ping":state["help_cooldown_until"]=help_cooldown_until(c,s);await ws.send_json({"type":"pong","needs_help":state["needs_help"],"help_cooldown_until":state["help_cooldown_until"],"help_cooldown_ms":HELP_REQUEST_COOLDOWN_MS,"submitted_at":state["submitted_at"],"checkpoint_at":state.get("checkpoint_at")});continue
else:continue
save(state)
warning=await asyncio.to_thread(persist_version,version) if version else ""
await ws.send_json({"type":"ack","needs_help":state["needs_help"],"help_cooldown_until":state.get("help_cooldown_until"),"help_cooldown_ms":HELP_REQUEST_COOLDOWN_MS,"submitted_at":state["submitted_at"],"checkpoint_at":state.get("checkpoint_at"),"version_kind":version["kind"] if version else None,"version_at":version["created_at"] if version else None,"version_warning":warning});await broadcast(c,w)
except (WebSocketDisconnect,json.JSONDecodeError):pass
finally:
if students.get(key) is ws:students.pop(key,None);state["connected"]=False;state["status"]="idle";save(state);await broadcast(c,w)
@router.websocket("/ws/classroom/teacher")
async def teacher_ws(ws:WebSocket):
c=clean(ws.query_params.get("class_code"),"L6A");w=clean(ws.query_params.get("week_id"),"week02");await ws.accept()
try:auth=await asyncio.wait_for(ws.receive_json(),8)
except Exception:await ws.close(code=4403);return
if not PASSWORD or auth.get("type")!="auth" or not hmac.compare_digest(str(auth.get("password","")),PASSWORD):await ws.close(code=4403);return
key=(c,w);teachers.setdefault(key,set()).add(ws);await ws.send_json({"type":"snapshot","students":snapshot(c,w)})
try:
while True:
m=await ws.receive_json()
if m.get("type")=="ack_help":
s=clean(m.get("student_id"),"student");state=live.get((c,s,w)) or next((item for item in snapshot(c,w) if item["student_id"]==s),None)
if state:
state["needs_help"]=False;state["help_requested_at"]=None;state["help_cooldown_until"]=help_cooldown_until(c,s);save(state);target=students.get((c,s,w))
if target:
try:await target.send_json({"type":"help_ack","help_cooldown_until":state["help_cooldown_until"],"help_cooldown_ms":HELP_REQUEST_COOLDOWN_MS})
except Exception:pass
await broadcast(c,w)
elif m.get("type")=="ping":await ws.send_json({"type":"pong"})
except (WebSocketDisconnect,json.JSONDecodeError):pass
finally:teachers.get(key,set()).discard(ws)
init_db()
|