Spaces:
Running
Running
Download classroom_service.py from Telearn/Project_Demo: direct link, hf CLI and curl.
- Browser
- Download file 52.5 kB
-
https://huggingface.co/spaces/Telearn/Project_Demo/resolve/main/classroom_service.py
- Command line
-
hf download hf://spaces/Telearn/Project_Demo/classroom_service.py
-
curl -L -o classroom_service.py https://huggingface.co/spaces/Telearn/Project_Demo/resolve/main/classroom_service.py
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() | |
| def student_page():return FileResponse(STATIC/"student.html") | |
| def teacher_page():return FileResponse(STATIC/"teacher.html") | |
| def course():return JSONResponse(json.loads((DATA/"course.json").read_text(encoding="utf-8"))) | |
| def classes():return public_catalog() | |
| 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 | |
| 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} | |
| 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} | |
| 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)} | |
| 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} | |
| 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} | |
| 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} | |
| def template(week_id):return {"week_id":week_id,"files":template_files(week_id)} | |
| 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)} | |
| 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}"'}) | |
| 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)} | |
| 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) | |
| 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() | |