Project_Demo / classroom_service.py
steveyehtw's picture
Protect student drafts during reconnects and workspace changes
bfe0d21
Raw History Blame Contribute Delete
52.5 kB
"""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()