File size: 52,488 Bytes
21c192a
 
ebb4d1d
7547388
21c192a
 
 
 
 
 
23af0a7
21c192a
ebb4d1d
7547388
 
 
 
2484080
 
7547388
dcab2e7
7547388
 
21c192a
a2cbc5c
7547388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21c192a
 
6737361
b88dc75
 
a2cbc5c
7547388
a2cbc5c
6737361
7547388
21c192a
6737361
21c192a
 
 
 
 
 
 
 
 
 
 
 
 
7547388
6737361
ab6d17c
 
 
 
dcab2e7
6737361
 
23af0a7
7e8293f
7547388
dcab2e7
6737361
 
 
a2cbc5c
 
 
 
b88dc75
 
 
 
 
 
 
 
 
ab6d17c
23af0a7
ab6d17c
 
 
 
 
 
 
 
 
 
23af0a7
 
 
 
 
 
 
 
 
 
 
 
 
 
5cacf86
 
 
 
 
 
 
 
 
 
 
7e8293f
 
 
 
2484080
7e8293f
2484080
7e8293f
5cacf86
7e8293f
 
 
7547388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcab2e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bfe0d21
dcab2e7
 
21c192a
 
dcab2e7
21c192a
dcab2e7
6737361
 
 
dcab2e7
6737361
 
 
 
 
 
 
a2cbc5c
 
 
 
 
 
 
 
b88dc75
 
 
 
 
 
 
 
 
 
7547388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23af0a7
 
 
 
 
7547388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23af0a7
 
 
 
b88dc75
ab6d17c
23af0a7
ab6d17c
 
 
 
21c192a
 
 
23af0a7
 
 
 
 
6737361
21c192a
 
23af0a7
21c192a
 
 
 
 
 
 
 
60dfd5a
7e8293f
 
 
ab6d17c
 
 
 
 
 
21c192a
 
 
 
 
 
 
 
 
 
ebb4d1d
 
 
 
 
 
 
 
 
 
 
b88dc75
a2cbc5c
 
b88dc75
a2cbc5c
 
 
b88dc75
 
 
a2cbc5c
 
 
 
23af0a7
 
ab6d17c
7547388
 
 
 
23af0a7
 
7547388
 
 
 
 
 
 
 
ab6d17c
 
23af0a7
ab6d17c
21c192a
 
 
 
 
ab6d17c
21c192a
 
 
 
 
 
 
 
 
6737361
a2cbc5c
 
 
 
 
 
6737361
 
 
 
 
2ad50f4
 
 
a2cbc5c
2ad50f4
b88dc75
 
 
 
 
 
 
 
 
 
 
 
 
dcab2e7
b88dc75
 
 
 
 
 
a2cbc5c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7547388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6737361
 
b88dc75
6737361
 
 
 
 
 
23af0a7
6737361
 
dcab2e7
b88dc75
6737361
 
a2cbc5c
21c192a
 
 
 
ab6d17c
23af0a7
 
 
21c192a
 
 
 
 
 
 
 
 
 
60dfd5a
21c192a
 
 
ab6d17c
21c192a
 
 
 
 
 
 
 
 
7547388
 
a2cbc5c
 
 
 
21c192a
 
 
 
 
 
 
7547388
bfe0d21
7547388
 
 
21c192a
23af0a7
21c192a
 
dcab2e7
 
 
 
 
 
 
 
 
 
 
 
ab6d17c
7547388
 
 
 
dcab2e7
7547388
 
 
 
 
dcab2e7
 
21c192a
ab6d17c
23af0a7
dcab2e7
21c192a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcab2e7
21c192a
dcab2e7
21c192a
dcab2e7
21c192a
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
"""Browser-side multi-file Python classroom for the Telearn demo Space."""
from __future__ import annotations
import asyncio, hmac, io, json, logging, os, re, sqlite3, threading, time, zipfile
from collections import deque
from pathlib import Path, PurePosixPath
from typing import Any
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pydantic import BaseModel

ROOT=Path(__file__).resolve().parent; STATIC=ROOT/"classroom_static"; DATA=ROOT/"classroom_data"; TEMPLATES=DATA/"templates"; REPAIRS=DATA/"repairs"; CHECKPOINTS=DATA/"checkpoints"; VERSIONS=DATA/"versions"; PUBLISHED=ROOT/"projects"
RUNTIME=Path(os.getenv("CLASSROOM_DATA_DIR","/tmp/telearn_classroom")); DB=RUNTIME/"classroom.sqlite3"
PASSWORD=os.getenv("CLASSROOM_TEACHER_PASSWORD",""); REPO=os.getenv("CLASSROOM_DEMO_REPO",os.getenv("SPACE_ID","Telearn/Project_Demo")).strip(); DEMO_CLASS=os.getenv("CLASSROOM_DEMO_CLASS","2026Fall進階專題"); TOKEN=os.getenv("HF_TOKEN","").strip()
COURSE_STORAGE_QUOTA_BYTES=max(16, int(os.getenv("CLASSROOM_COURSE_QUOTA_MB", "1024"))) * 1024 * 1024
MAX_VERSIONS_PER_KIND=max(2, int(os.getenv("CLASSROOM_MAX_VERSIONS_PER_KIND", "100")))
CHECKPOINTS_PER_HOUR=max(1, int(os.getenv("CLASSROOM_CHECKPOINTS_PER_HOUR", "60")))
SUBMISSIONS_PER_HOUR=max(1, int(os.getenv("CLASSROOM_SUBMISSIONS_PER_HOUR", "20")))
# Bundled syntax repairs target work saved before 2026-10-04 00:00 Asia/Taipei.
LEGACY_REPAIR_CUTOFF_MS=1791043200000
VERSION_WRITE_COOLDOWN_MS=max(0, int(os.getenv("CLASSROOM_VERSION_COOLDOWN_MS", "1500")))
HELP_REQUEST_COOLDOWN_MS=max(0, int(os.getenv("CLASSROOM_HELP_COOLDOWN_MS", "300000")))
STUDENT_WS_CONNECTIONS_PER_MINUTE=max(10, int(os.getenv("CLASSROOM_WS_CONNECTIONS_PER_MINUTE", "300")))
STUDENT_WS_MESSAGES_PER_MINUTE=max(60, int(os.getenv("CLASSROOM_WS_MESSAGES_PER_MINUTE", "300")))
ALLOWED={".py",".json",".txt",".csv",".md",".toml",".yaml",".yml"}; ID_RE=re.compile(r"[^0-9A-Za-z_\-一-鿿]")
router=APIRouter(); lock=threading.Lock(); persist_lock=threading.Lock(); students={}; teachers={}; live={}; logger=logging.getLogger(__name__)
_student_connection_windows={}
_student_message_windows={}

def allow_student_connection(remote_ip):
    """Bound repeated WebSocket handshakes per client IP without persisting IPs."""
    current=time.monotonic(); window=_student_connection_windows.get(remote_ip)
    if window is None:
        window=deque(); _student_connection_windows[remote_ip]=window
    while window and current-window[0]>=60:window.popleft()
    if len(window)>=STUDENT_WS_CONNECTIONS_PER_MINUTE:return False
    window.append(current)
    if len(_student_connection_windows)>4096:
        for key,old_window in list(_student_connection_windows.items()):
            while old_window and current-old_window[0]>=60:old_window.popleft()
            if not old_window:_student_connection_windows.pop(key,None)
            if len(_student_connection_windows)<=3072:break
    return True

def allow_student_message(student_key):
    """Apply a rolling message budget across reconnects for the same student/workspace."""
    current=time.monotonic(); window=_student_message_windows.get(student_key)
    if window is None:
        window=deque(); _student_message_windows[student_key]=window
    while window and current-window[0]>=60:window.popleft()
    if len(window)>=STUDENT_WS_MESSAGES_PER_MINUTE:return False
    window.append(current)
    if len(_student_message_windows)>4096:
        for key,old_window in list(_student_message_windows.items()):
            while old_window and current-old_window[0]>=60:old_window.popleft()
            if not old_window:_student_message_windows.pop(key,None)
            if len(_student_message_windows)<=3072:break
    return True

class TeacherRequest(BaseModel): class_code:str; week_id:str; password:str
class CourseCreate(BaseModel): class_code:str; course_name:str=""; password:str
class CourseUpdate(BaseModel): class_code:str; course_name:str; password:str
class CourseDelete(BaseModel): class_code:str; password:str
class StudentCreate(BaseModel): class_code:str; display_names:list[str]; password:str
class StudentUpdate(BaseModel): class_code:str; student_id:str; display_name:str; password:str
class StudentLogin(BaseModel): class_code:str; display_name:str
class StudentDelete(BaseModel): class_code:str; student_id:str; password:str
class VersionLimitError(Exception): pass
def clean(v,fallback="item",limit=48): return ID_RE.sub("",str(v).strip())[:limit] or fallback
def clean_label(v,fallback="",limit=80): return re.sub(r"[\x00-\x1f\x7f]", "", str(v).strip())[:limit] or fallback
def safe_path(v):
    v=str(v).replace("\\","/").strip("/"); p=PurePosixPath(v)
    return p.as_posix() if v and not p.is_absolute() and ".." not in p.parts and p.suffix.lower() in ALLOWED and len(v)<161 else None
def norm_files(value):
    out={}; total=0
    if isinstance(value,dict):
        for raw,content in list(value.items())[:40]:
            path=safe_path(raw); text=str(content); size=len(text.encode())
            if path and total+size<=800000: out[path]=text; total+=size
    out.setdefault("main.py","# 專案入口\n"); return out
def now(): return int(time.time()*1000)
def init_db():
    RUNTIME.mkdir(parents=True,exist_ok=True)
    os.chmod(RUNTIME,0o700)
    with lock,sqlite3.connect(DB) as c:
        c.execute("""CREATE TABLE IF NOT EXISTS classroom_projects(class_code TEXT,student_id TEXT,display_name TEXT,week_id TEXT,project_name TEXT,draft_files TEXT,final_files TEXT,active_file TEXT,output TEXT,status TEXT,needs_help INTEGER,updated_at INTEGER,submitted_at INTEGER,checkpoint_files TEXT,checkpoint_at INTEGER,PRIMARY KEY(class_code,student_id,week_id))""")
        columns={row[1] for row in c.execute("PRAGMA table_info(classroom_projects)")}
        if "checkpoint_files" not in columns:c.execute("ALTER TABLE classroom_projects ADD COLUMN checkpoint_files TEXT")
        if "checkpoint_at" not in columns:c.execute("ALTER TABLE classroom_projects ADD COLUMN checkpoint_at INTEGER")
        if "help_requested_at" not in columns:c.execute("ALTER TABLE classroom_projects ADD COLUMN help_requested_at INTEGER")
        c.execute("""CREATE TABLE IF NOT EXISTS classroom_courses(class_code TEXT PRIMARY KEY,course_name TEXT,created_at INTEGER)""")
        c.execute("""CREATE TABLE IF NOT EXISTS classroom_members(class_code TEXT,student_id TEXT,display_name TEXT,created_at INTEGER,PRIMARY KEY(class_code,student_id))""")
        c.execute("""CREATE TABLE IF NOT EXISTS classroom_versions(class_code TEXT,student_id TEXT,display_name TEXT,week_id TEXT,version_id TEXT,kind TEXT,project_name TEXT,files TEXT,created_at INTEGER,PRIMARY KEY(class_code,student_id,week_id,version_id))""")
        c.execute("""CREATE TABLE IF NOT EXISTS classroom_migrations(migration_id TEXT PRIMARY KEY,applied_at INTEGER)""")
        c.execute("""CREATE TABLE IF NOT EXISTS classroom_write_windows(class_code TEXT,student_id TEXT,week_id TEXT,kind TEXT,window_started INTEGER,write_count INTEGER,last_write INTEGER,PRIMARY KEY(class_code,student_id,week_id,kind))""")
        c.execute("""CREATE TABLE IF NOT EXISTS classroom_help_cooldowns(class_code TEXT,student_id TEXT,last_requested_at INTEGER,cooldown_until INTEGER,PRIMARY KEY(class_code,student_id))""")
        for item in json.loads((DATA/"classes.json").read_text(encoding="utf-8")):
            code=clean(item.get("code"),"L6A")
            c.execute("""INSERT INTO classroom_courses VALUES(?,?,?) ON CONFLICT(class_code) DO UPDATE SET course_name=excluded.course_name""",(code,clean_label(item.get("name"),code),now()))
            for student in item.get("students",[]):
                name=clean_label(student.get("display_name") or student.get("name"),"")
                student_id=clean(student.get("student_id") or name,"")
                if name and student_id:c.execute("INSERT OR IGNORE INTO classroom_members VALUES(?,?,?,?)",(code,student_id,name,now()))
        submissions=DATA/"submissions"
        if submissions.is_dir():
            for path in submissions.glob("*/*/*.json"):
                try:record=json.loads(path.read_text(encoding="utf-8"));files=norm_files(record.get("files",{}));code=clean(record.get("class_code"),"");student_id=clean(record.get("student_id"),"");name=clean_label(record.get("display_name"),"");week_id=clean(record.get("week_id"),"")
                except (OSError,ValueError,TypeError):continue
                if not all((code,student_id,name,week_id)):continue
                submitted_at=int(record.get("submitted_at") or int(path.stat().st_mtime*1000));project_name=clean_label(record.get("project_name"),"")
                c.execute("INSERT OR IGNORE INTO classroom_courses VALUES(?,?,?)",(code,code,now()))
                c.execute("INSERT OR IGNORE INTO classroom_members VALUES(?,?,?,?)",(code,student_id,name,now()))
                c.execute("""INSERT OR IGNORE INTO classroom_projects(class_code,student_id,display_name,week_id,project_name,draft_files,final_files,active_file,output,status,needs_help,updated_at,submitted_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",(code,student_id,name,week_id,project_name,json.dumps(files,ensure_ascii=False),json.dumps(files,ensure_ascii=False),"main.py","","idle",0,submitted_at,submitted_at))
                c.execute("INSERT OR IGNORE INTO classroom_versions VALUES(?,?,?,?,?,?,?,?,?)",(code,student_id,name,week_id,f"legacy-submit-{submitted_at}","submit",project_name,json.dumps(files,ensure_ascii=False),submitted_at))
        if CHECKPOINTS.is_dir():
            for path in CHECKPOINTS.glob("*/*/*.json"):
                try:record=json.loads(path.read_text(encoding="utf-8"));files=norm_files(record.get("files",{}));code=clean(record.get("class_code"),"");student_id=clean(record.get("student_id"),"");name=clean_label(record.get("display_name"),"");week_id=clean(record.get("week_id"),"")
                except (OSError,ValueError,TypeError):continue
                if not all((code,student_id,name,week_id)):continue
                checkpoint_at=int(record.get("checkpoint_at") or int(path.stat().st_mtime*1000));project_name=clean_label(record.get("project_name"),"")
                encoded=json.dumps(files,ensure_ascii=False)
                c.execute("INSERT OR IGNORE INTO classroom_courses VALUES(?,?,?)",(code,code,now()))
                c.execute("INSERT OR IGNORE INTO classroom_members VALUES(?,?,?,?)",(code,student_id,name,now()))
                c.execute("""INSERT INTO classroom_projects(class_code,student_id,display_name,week_id,project_name,draft_files,final_files,active_file,output,status,needs_help,updated_at,submitted_at,checkpoint_files,checkpoint_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(class_code,student_id,week_id) DO UPDATE SET checkpoint_files=excluded.checkpoint_files,checkpoint_at=excluded.checkpoint_at,draft_files=CASE WHEN excluded.checkpoint_at>=classroom_projects.updated_at THEN excluded.draft_files ELSE classroom_projects.draft_files END,project_name=CASE WHEN excluded.checkpoint_at>=classroom_projects.updated_at THEN excluded.project_name ELSE classroom_projects.project_name END,updated_at=MAX(classroom_projects.updated_at,excluded.updated_at)""",(code,student_id,name,week_id,project_name,encoded,None,"main.py","","idle",0,checkpoint_at,None,encoded,checkpoint_at))
                c.execute("INSERT OR IGNORE INTO classroom_versions VALUES(?,?,?,?,?,?,?,?,?)",(code,student_id,name,week_id,f"legacy-checkpoint-{checkpoint_at}","checkpoint",project_name,encoded,checkpoint_at))
        if VERSIONS.is_dir():
            for path in VERSIONS.glob("*/*/*/*.json"):
                try:
                    record=json.loads(path.read_text(encoding="utf-8"));files=norm_files(record.get("files",{}));code=clean(record.get("class_code"),"");student_id=clean(record.get("student_id"),"");name=clean_label(record.get("display_name"),"");week_id=clean(record.get("week_id"),"");kind=record.get("kind");created_at=int(record.get("created_at") or int(path.stat().st_mtime*1000));version_id=clean(record.get("version_id") or path.stem,"");project_name=clean_label(record.get("project_name"),"")
                except (OSError,ValueError,TypeError):continue
                if not all((code,student_id,name,week_id,version_id)) or kind not in {"checkpoint","submit"}:continue
                encoded=json.dumps(files,ensure_ascii=False)
                c.execute("INSERT OR IGNORE INTO classroom_courses VALUES(?,?,?)",(code,code,now()))
                c.execute("INSERT OR IGNORE INTO classroom_members VALUES(?,?,?,?)",(code,student_id,name,now()))
                c.execute("INSERT OR IGNORE INTO classroom_versions VALUES(?,?,?,?,?,?,?,?,?)",(code,student_id,name,week_id,version_id,kind,project_name,encoded,created_at))
                c.execute("""INSERT OR IGNORE INTO classroom_projects(class_code,student_id,display_name,week_id,project_name,draft_files,final_files,active_file,output,status,needs_help,updated_at,submitted_at,checkpoint_files,checkpoint_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",(code,student_id,name,week_id,project_name,encoded,encoded if kind=="submit" else None,"main.py","","idle",0,created_at,created_at if kind=="submit" else None,encoded if kind=="checkpoint" else None,created_at if kind=="checkpoint" else None))
                time_column="checkpoint_at" if kind=="checkpoint" else "submitted_at";files_column="checkpoint_files" if kind=="checkpoint" else "final_files"
                c.execute(f"UPDATE classroom_projects SET {files_column}=CASE WHEN ? >= COALESCE({time_column},0) THEN ? ELSE {files_column} END,{time_column}=MAX(COALESCE({time_column},0),?),draft_files=CASE WHEN ?>=updated_at THEN ? ELSE draft_files END,project_name=CASE WHEN ?>=updated_at THEN ? ELSE project_name END,updated_at=MAX(updated_at,?) WHERE class_code=? AND student_id=? AND week_id=?",(created_at,encoded,created_at,created_at,encoded,created_at,project_name,created_at,code,student_id,week_id))
            c.execute("""DELETE FROM classroom_versions AS legacy
                WHERE legacy.version_id LIKE 'legacy-%'
                AND EXISTS (
                    SELECT 1 FROM classroom_versions AS saved
                    WHERE saved.class_code=legacy.class_code
                    AND saved.student_id=legacy.student_id
                    AND saved.week_id=legacy.week_id
                    AND saved.kind=legacy.kind
                    AND saved.created_at=legacy.created_at
                    AND saved.version_id NOT LIKE 'legacy-%'
                )""")
        migration_id="lesson2_syntax_repair_20260916"
        if not c.execute("SELECT 1 FROM classroom_migrations WHERE migration_id=?",(migration_id,)).fetchone():
            for repair in REPAIRS.glob("*/*/*.py") if REPAIRS.is_dir() else []:
                class_code,week_id=repair.parts[-3],repair.parts[-2];student_id=repair.stem
                row=c.execute("SELECT draft_files,final_files,checkpoint_at,submitted_at,updated_at FROM classroom_projects WHERE class_code=? AND student_id=? AND week_id=?",(class_code,student_id,week_id)).fetchone()
                if not row:continue
                if max(int(row[2] or 0),int(row[3] or 0),int(row[4] or 0))>=LEGACY_REPAIR_CUTOFF_MS:continue
                draft=json.loads(row[0]);final=json.loads(row[1]) if row[1] else None;fixed=repair.read_text(encoding="utf-8")
                if row[2] is None or row[2]<int(row[3] or 0):draft["main.py"]=fixed
                if final is not None:final["main.py"]=fixed
                c.execute("UPDATE classroom_projects SET draft_files=?,final_files=?,updated_at=? WHERE class_code=? AND student_id=? AND week_id=?",(json.dumps(draft,ensure_ascii=False),json.dumps(final,ensure_ascii=False) if final is not None else None,now(),class_code,student_id,week_id))
            c.execute("INSERT INTO classroom_migrations VALUES(?,?)",(migration_id,now()))
        c.execute("""UPDATE classroom_projects SET display_name=(
            SELECT display_name FROM classroom_members
            WHERE classroom_members.class_code=classroom_projects.class_code
            AND classroom_members.student_id=classroom_projects.student_id
        ) WHERE EXISTS (
            SELECT 1 FROM classroom_members
            WHERE classroom_members.class_code=classroom_projects.class_code
            AND classroom_members.student_id=classroom_projects.student_id
        )""")
        c.execute("""UPDATE classroom_versions SET display_name=(
            SELECT display_name FROM classroom_members
            WHERE classroom_members.class_code=classroom_versions.class_code
            AND classroom_members.student_id=classroom_versions.student_id
        ) WHERE EXISTS (
            SELECT 1 FROM classroom_members
            WHERE classroom_members.class_code=classroom_versions.class_code
            AND classroom_members.student_id=classroom_versions.student_id
        )""")
def help_cooldown_until(class_code,student_id):
    if HELP_REQUEST_COOLDOWN_MS<=0:return None
    with lock,sqlite3.connect(DB) as db:row=db.execute("SELECT cooldown_until FROM classroom_help_cooldowns WHERE class_code=? AND student_id=?",(class_code,student_id)).fetchone()
    until=int(row[0]) if row and row[0] else None
    return until if until and until>now() else None
def start_help_cooldown(class_code,student_id):
    current=now()
    if HELP_REQUEST_COOLDOWN_MS<=0:return current,None
    with lock,sqlite3.connect(DB) as db:
        db.execute("BEGIN IMMEDIATE")
        row=db.execute("SELECT cooldown_until FROM classroom_help_cooldowns WHERE class_code=? AND student_id=?",(class_code,student_id)).fetchone()
        until=int(row[0]) if row and row[0] else 0
        if until>current:return None,until
        cooldown_until=current+HELP_REQUEST_COOLDOWN_MS
        db.execute("INSERT INTO classroom_help_cooldowns VALUES(?,?,?,?) ON CONFLICT(class_code,student_id) DO UPDATE SET last_requested_at=excluded.last_requested_at,cooldown_until=excluded.cooldown_until",(class_code,student_id,current,cooldown_until))
    return current,cooldown_until
def empty(c,s,n,w): return {"class_code":c,"student_id":s,"display_name":n,"week_id":w,"project_name":"","files":{"main.py":"# 在這裡寫 Python\n"},"final_files":None,"checkpoint_files":None,"active_file":"main.py","output":"","status":"idle","needs_help":False,"updated_at":0,"submitted_at":None,"checkpoint_at":None,"help_requested_at":None,"help_cooldown_until":help_cooldown_until(c,s),"help_cooldown_ms":HELP_REQUEST_COOLDOWN_MS,"connected":True}
def from_row(r,connected=False): return {"class_code":r[0],"student_id":r[1],"display_name":r[2],"week_id":r[3],"project_name":r[4],"files":json.loads(r[5]),"final_files":json.loads(r[6]) if r[6] else None,"active_file":r[7],"output":r[8],"status":r[9],"needs_help":bool(r[10]),"updated_at":r[11],"submitted_at":r[12],"checkpoint_files":json.loads(r[13]) if r[13] else None,"checkpoint_at":r[14],"help_requested_at":r[15],"help_cooldown_ms":HELP_REQUEST_COOLDOWN_MS,"connected":connected}
SELECT="SELECT class_code,student_id,display_name,week_id,project_name,draft_files,final_files,active_file,output,status,needs_help,updated_at,submitted_at,checkpoint_files,checkpoint_at,help_requested_at FROM classroom_projects"
def load(c,s,n,w):
    with lock,sqlite3.connect(DB) as db:r=db.execute(SELECT+" WHERE class_code=? AND student_id=? AND week_id=?",(c,s,w)).fetchone()
    state=from_row(r,True) if r else empty(c,s,n,w); state["display_name"]=n; state["help_cooldown_until"]=help_cooldown_until(c,s); state["help_cooldown_ms"]=HELP_REQUEST_COOLDOWN_MS; return state
def save(x):
    values=(x["class_code"],x["student_id"],x["display_name"],x["week_id"],x["project_name"],json.dumps(x["files"],ensure_ascii=False),json.dumps(x["final_files"],ensure_ascii=False) if x["final_files"] else None,x["active_file"],x["output"][-40000:],x["status"],int(x["needs_help"]),x["updated_at"],x["submitted_at"],json.dumps(x["checkpoint_files"],ensure_ascii=False) if x.get("checkpoint_files") else None,x.get("checkpoint_at"),x.get("help_requested_at"))
    with lock,sqlite3.connect(DB) as db:
        db.execute("INSERT OR IGNORE INTO classroom_courses VALUES(?,?,?)",(x["class_code"],x["class_code"],now()))
        db.execute("""INSERT INTO classroom_members VALUES(?,?,?,?) ON CONFLICT(class_code,student_id) DO UPDATE SET display_name=excluded.display_name""",(x["class_code"],x["student_id"],x["display_name"],now()))
        db.execute("""INSERT INTO classroom_projects(class_code,student_id,display_name,week_id,project_name,draft_files,final_files,active_file,output,status,needs_help,updated_at,submitted_at,checkpoint_files,checkpoint_at,help_requested_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(class_code,student_id,week_id) DO UPDATE SET display_name=excluded.display_name,project_name=excluded.project_name,draft_files=excluded.draft_files,final_files=excluded.final_files,active_file=excluded.active_file,output=excluded.output,status=excluded.status,needs_help=excluded.needs_help,updated_at=excluded.updated_at,submitted_at=excluded.submitted_at,checkpoint_files=excluded.checkpoint_files,checkpoint_at=excluded.checkpoint_at,help_requested_at=excluded.help_requested_at""",values)
def catalog():
    with lock,sqlite3.connect(DB) as db:
        courses=db.execute("SELECT class_code,course_name FROM classroom_courses ORDER BY course_name,class_code").fetchall()
        members=db.execute("SELECT class_code,student_id,display_name FROM classroom_members ORDER BY display_name").fetchall()
    by_class={code:[] for code,_ in courses}
    for code,student_id,name in members:by_class.setdefault(code,[]).append({"student_id":student_id,"display_name":name})
    return {"courses":[{"class_code":code,"course_name":name,"students":by_class.get(code,[])} for code,name in courses]}
def public_catalog():return {"courses":[{"class_code":x["class_code"],"course_name":x["course_name"]} for x in catalog()["courses"]]}
def find_member(class_code,display_name="",student_id=""):
    with lock,sqlite3.connect(DB) as db:rows=db.execute("SELECT student_id,display_name FROM classroom_members WHERE class_code=?",(class_code,)).fetchall()
    wanted=clean_label(display_name,"").casefold()
    for member_id,name in rows:
        if student_id and member_id==student_id and (not wanted or name.casefold()==wanted):return {"student_id":member_id,"display_name":name}
        if not student_id and wanted and name.casefold()==wanted:return {"student_id":member_id,"display_name":name}
    return None
def submission_path(class_code,student_id,week_id):return DATA/"submissions"/clean(class_code,"")/clean(student_id,"")/f"{clean(week_id,'')}.json"
def submission_payload(state):
    return {key:state[key] for key in ("class_code","student_id","display_name","week_id","project_name","submitted_at")}|{"files":state["final_files"]}
def submission_catalog():
    with lock,sqlite3.connect(DB) as db:rows=db.execute(SELECT+" WHERE final_files IS NOT NULL ORDER BY class_code,display_name,week_id").fetchall()
    return [{"class_code":x["class_code"],"student_id":x["student_id"],"display_name":x["display_name"],"week_id":x["week_id"],"project_name":x["project_name"],"submitted_at":x["submitted_at"],"file_count":len(x["final_files"] or {})} for x in (from_row(row) for row in rows)]
def get_submission(class_code,student_id,week_id):
    with lock,sqlite3.connect(DB) as db:row=db.execute(SELECT+" WHERE class_code=? AND student_id=? AND week_id=? AND final_files IS NOT NULL",(clean(class_code,""),clean(student_id,""),clean(week_id,""))).fetchone()
    if not row:raise HTTPException(404,"找不到這份提交")
    return from_row(row)
def consume_version_write(class_code,student_id,week_id,kind):
    """Persist a per-student/workspace hourly write budget in SQLite."""
    current=now(); window_ms=60*60*1000
    limit=CHECKPOINTS_PER_HOUR if kind=="checkpoint" else SUBMISSIONS_PER_HOUR
    with lock,sqlite3.connect(DB) as db:
        row=db.execute("SELECT window_started,write_count,last_write FROM classroom_write_windows WHERE class_code=? AND student_id=? AND week_id=? AND kind=?",(class_code,student_id,week_id,kind)).fetchone()
        if row is None or current-row[0]>=window_ms:
            window_started=current; count=0; last_write=0
        else:
            window_started,count,last_write=row
        if last_write and current-last_write<VERSION_WRITE_COOLDOWN_MS:
            raise VersionLimitError("儲存太頻繁,請稍候再試。")
        if count>=limit:
            raise VersionLimitError(f"已達本 Lesson 每小時{limit}次上限,稍後再試。")
        db.execute("INSERT INTO classroom_write_windows VALUES(?,?,?,?,?,?,?) ON CONFLICT(class_code,student_id,week_id,kind) DO UPDATE SET window_started=excluded.window_started,write_count=excluded.write_count,last_write=excluded.last_write",(class_code,student_id,week_id,kind,window_started,count+1,current))

def record_version(state,kind):
    created_at=state["checkpoint_at"] if kind=="checkpoint" else state["submitted_at"]
    files=state["checkpoint_files"] if kind=="checkpoint" else state["final_files"]
    version_id=f"{created_at}-{kind}-{time.time_ns()%1000000:06d}"
    record={"class_code":state["class_code"],"student_id":state["student_id"],"display_name":state["display_name"],"week_id":state["week_id"],"version_id":version_id,"kind":kind,"project_name":state["project_name"],"created_at":created_at,"files":dict(files)}
    encoded_files=json.dumps(record["files"],ensure_ascii=False)
    draft_json=json.dumps(state["files"],ensure_ascii=False)
    final_json=json.dumps(state["final_files"],ensure_ascii=False) if state["final_files"] else None
    checkpoint_json=json.dumps(state["checkpoint_files"],ensure_ascii=False) if state["checkpoint_files"] else None
    with lock,sqlite3.connect(DB) as db:
        db.execute("BEGIN IMMEDIATE")
        existing=db.execute("SELECT version_id,created_at,length(CAST(files AS BLOB)),kind FROM classroom_versions WHERE class_code=? AND student_id=? AND week_id=? AND kind=? ORDER BY created_at DESC,version_id DESC",(record["class_code"],record["student_id"],record["week_id"],kind)).fetchall()
        pruned=existing[MAX_VERSIONS_PER_KIND-1:] if len(existing)>=MAX_VERSIONS_PER_KIND else []
        pruned_bytes=sum(row[2] or 0 for row in pruned)
        version_bytes=db.execute("SELECT COALESCE(SUM(length(CAST(files AS BLOB))),0) FROM classroom_versions WHERE class_code=?",(record["class_code"],)).fetchone()[0]
        current_bytes=db.execute("SELECT COALESCE(SUM(length(CAST(draft_files AS BLOB))+COALESCE(length(CAST(final_files AS BLOB)),0)+COALESCE(length(CAST(checkpoint_files AS BLOB)),0)),0) FROM classroom_projects WHERE class_code=?",(record["class_code"],)).fetchone()[0]
        prior=db.execute("SELECT COALESCE(length(CAST(draft_files AS BLOB)),0)+COALESCE(length(CAST(final_files AS BLOB)),0)+COALESCE(length(CAST(checkpoint_files AS BLOB)),0) FROM classroom_projects WHERE class_code=? AND student_id=? AND week_id=?",(record["class_code"],record["student_id"],record["week_id"])).fetchone()
        prior_bytes=prior[0] if prior else 0
        candidate_current=len(draft_json.encode())+(len(final_json.encode()) if final_json else 0)+(len(checkpoint_json.encode()) if checkpoint_json else 0)
        projected=version_bytes-pruned_bytes+len(encoded_files.encode())+current_bytes-prior_bytes+candidate_current
        if projected>COURSE_STORAGE_QUOTA_BYTES:
            raise VersionLimitError(f"本課程已達 {COURSE_STORAGE_QUOTA_BYTES//(1024*1024)} MB 儲存上限,請聯絡老師整理舊版本。")
        for old_version_id,_,_,_ in pruned:
            db.execute("DELETE FROM classroom_versions WHERE class_code=? AND student_id=? AND week_id=? AND version_id=?",(record["class_code"],record["student_id"],record["week_id"],old_version_id))
        db.execute("INSERT INTO classroom_versions VALUES(?,?,?,?,?,?,?,?,?)",(record["class_code"],record["student_id"],record["display_name"],record["week_id"],version_id,kind,record["project_name"],encoded_files,created_at))
    if pruned:
        record["_pruned"]=[{"week_id":record["week_id"],"version_id":row[0]} for row in pruned]
    return record
def version_history(class_code,student_id):
    with lock,sqlite3.connect(DB) as db:rows=db.execute("SELECT week_id,version_id,kind,project_name,files,created_at FROM classroom_versions WHERE class_code=? AND student_id=? ORDER BY created_at DESC,version_id DESC",(class_code,student_id)).fetchall()
    return [{"week_id":week_id,"version_id":version_id,"source":kind,"project_name":project_name,"files":json.loads(files),"submitted_at":created_at} for week_id,version_id,kind,project_name,files,created_at in rows]
def archived_paths(class_code,student_id=""):
    found=[]
    for kind in ("submissions","checkpoints","versions"):
        root=DATA/kind/clean(class_code,"")
        target=root/clean(student_id,"") if student_id else root
        if target.is_dir():found.extend(path.relative_to(ROOT).as_posix() for path in target.rglob("*.json"))
    return found
def snapshot(c,w):
    with lock,sqlite3.connect(DB) as db:rows=db.execute(SELECT+" WHERE class_code=? AND week_id=?",(c,w)).fetchall()
    found={r[1]:from_row(r) for r in rows}
    with lock,sqlite3.connect(DB) as db:
        members=db.execute("SELECT student_id,display_name FROM classroom_members WHERE class_code=?",(c,)).fetchall()
        version_rows=db.execute("SELECT student_id,kind,COUNT(*) FROM classroom_versions WHERE class_code=? AND week_id=? GROUP BY student_id,kind",(c,w)).fetchall()
    counts={}
    for student_id,kind,total in version_rows:counts.setdefault(student_id,{"checkpoint":0,"submit":0})[kind]=total
    for student_id,name in members:found.setdefault(student_id,empty(c,student_id,name,w)|{"connected":False})
    for (cc,s,ww),state in live.items():
        if cc==c and ww==w:found[s]=state.copy()
    for student_id,state in found.items():state["version_counts"]=counts.get(student_id,{"checkpoint":0,"submit":0})
    return sorted(found.values(),key=lambda x:(not x["needs_help"],x["display_name"]))
async def broadcast(c,w):
    payload={"type":"snapshot","students":snapshot(c,w)}
    for ws in teachers.get((c,w),set()).copy():
        try:await ws.send_json(payload)
        except Exception:teachers.get((c,w),set()).discard(ws)
def template_files(w):
    root=TEMPLATES/clean(w,"week01")
    if not root.is_dir():raise HTTPException(404,"這個 Lesson 尚未發布套版")
    files={p.relative_to(root).as_posix():p.read_text(encoding="utf-8") for p in root.rglob("*") if p.is_file() and p.suffix.lower() in ALLOWED}
    if not files:raise HTTPException(404,"這個 Lesson 尚未發布套版")
    return norm_files(files)
def demo_class_name(class_code):
    with lock,sqlite3.connect(DB) as db:row=db.execute("SELECT course_name FROM classroom_courses WHERE class_code=?",(clean(class_code,""),)).fetchone()
    name=clean_label(row[0],"") if row else ""
    return name if name.startswith("2026Fall進階專題") else DEMO_CLASS
def published_progress(class_code,display_name):
    student_root=PUBLISHED/demo_class_name(class_code)/clean(display_name,"student"); found=[]
    if not student_root.is_dir():return found
    for project_root in sorted(student_root.iterdir()):
        match=re.match(r"^(week\d{2})_(.+)$",project_root.name)
        if not match or not project_root.is_dir():continue
        files=norm_files({p.relative_to(project_root).as_posix():p.read_text(encoding="utf-8") for p in project_root.rglob("*") if p.is_file() and p.suffix.lower() in ALLOWED})
        found.append({"week_id":match.group(1),"project_name":match.group(2),"files":files,"submitted_at":int(project_root.stat().st_mtime*1000),"source":"published"})
    return found
def check_password(p):
    if not PASSWORD:raise HTTPException(503,"請先在 Space Secrets 設定 CLASSROOM_TEACHER_PASSWORD")
    if not hmac.compare_digest(p,PASSWORD):raise HTTPException(403,"教師密碼不正確")
def hf_failure_message(exc):
    """Return an actionable message without ever exposing a token."""
    status=getattr(getattr(exc,"response",None),"status_code",None)
    detail=getattr(exc,"server_message",None) or str(exc) or type(exc).__name__
    detail=re.sub(r"hf_[A-Za-z0-9]+","hf_***",re.sub(r"\s+"," ",detail)).strip()[:220]
    if status==401:reason="HF_TOKEN 未被 Space 正確載入、已失效或貼入值不正確"
    elif status==403:reason=f"HF_TOKEN 已被載入,但對 {REPO} 的 Space 寫入驗證被拒絕"
    elif status==404:reason=f"找不到目標 Space {REPO},請檢查 CLASSROOM_DEMO_REPO 或 SPACE_ID"
    else:reason=f"連線到 Hugging Face 時發生 {type(exc).__name__}"
    suffix=f"HTTP {status}:{detail}" if status else detail
    return f"課程已可使用,但尚未永久寫入 Space。{reason}。詳情:{suffix}"
def persist_catalog(message,deletions=None):
    if not TOKEN:return "名單已可使用,但尚未永久寫入 Space;請設定 HF_TOKEN。"
    try:
        from huggingface_hub import CommitOperationAdd,CommitOperationDelete,HfApi
        payload=[]
        for course in catalog()["courses"]:payload.append({"code":course["class_code"],"name":course["course_name"],"students":course["students"]})
        content=(json.dumps(payload,ensure_ascii=False,indent=2)+"\n").encode()
        operations=[CommitOperationDelete(path_in_repo=path) for path in sorted(set(deletions or []))]
        operations.append(CommitOperationAdd(path_in_repo="classroom_data/classes.json",path_or_fileobj=content))
        with persist_lock:HfApi(token=TOKEN).create_commit(repo_id=REPO,repo_type="space",operations=operations,commit_message=message)
        return ""
    except Exception as exc:
        logger.exception("Could not persist classroom roster to %s",REPO)
        return hf_failure_message(exc)
def persist_version(record):
    if not TOKEN:return "版本已保存在教室,但尚未永久寫入 Space;請設定 HF_TOKEN。"
    try:
        from huggingface_hub import CommitOperationAdd,CommitOperationDelete,HfApi
        pruned=record.get("_pruned",[])
        payload={key:value for key,value in record.items() if key!="_pruned"}
        content=(json.dumps(payload,ensure_ascii=False,indent=2)+"\n").encode()
        path=PurePosixPath("classroom_data","versions",clean(record["class_code"],""),clean(record["student_id"],""),clean(record["week_id"],"week"),f"{clean(record['version_id'],'version')}.json").as_posix()
        label="Checkpoint" if record["kind"]=="checkpoint" else "Submission"
        api=HfApi(token=TOKEN);operations=[]
        if pruned:
            existing=set(api.list_repo_files(repo_id=REPO,repo_type="space"))
            for old in pruned:
                old_path=PurePosixPath("classroom_data","versions",clean(record["class_code"],""),clean(record["student_id"],""),clean(old["week_id"],"week"),f"{clean(old['version_id'],'version')}.json").as_posix()
                if old_path in existing:operations.append(CommitOperationDelete(path_in_repo=old_path))
        operations.append(CommitOperationAdd(path_in_repo=path,path_or_fileobj=content))
        with persist_lock:api.create_commit(repo_id=REPO,repo_type="space",operations=operations,commit_message=f"{label} {record['class_code']} {record['student_id']} {record['week_id']}")
        return ""
    except Exception as exc:
        logger.exception("Could not persist student version to %s",REPO)
        return hf_failure_message(exc)
def finals(c,w):return [x for x in snapshot(c,w) if x.get("final_files")]
def make_zip(c,w):
    b=io.BytesIO()
    with zipfile.ZipFile(b,"w",zipfile.ZIP_DEFLATED) as z:
        for x in finals(c,w):
            root=PurePosixPath(demo_class_name(c),clean(x["display_name"],"student"),f"{w}_{clean(x['project_name'],'project')}")
            for path,text in x["final_files"].items():z.writestr((root/path).as_posix(),text)
    return b.getvalue()

@router.get("/classroom")
def student_page():return FileResponse(STATIC/"student.html")
@router.get("/classroom/teacher")
def teacher_page():return FileResponse(STATIC/"teacher.html")
@router.get("/api/classroom/course")
def course():return JSONResponse(json.loads((DATA/"course.json").read_text(encoding="utf-8")))
@router.get("/api/classroom/classes")
def classes():return public_catalog()
@router.post("/api/classroom/student-login")
def student_login(req:StudentLogin):
    member=find_member(clean(req.class_code,""),clean_label(req.display_name,""))
    if not member:raise HTTPException(403,"姓名不在這個班級的名單中,請確認輸入或請老師新增名單。")
    return member
@router.post("/api/classroom/classes")
def create_class(req:CourseCreate):
    check_password(req.password); code=clean(req.class_code,""); name=clean_label(req.course_name,code)
    if not code:raise HTTPException(400,"請輸入課程代碼")
    with lock,sqlite3.connect(DB) as db:
        existing=db.execute("SELECT course_name FROM classroom_courses WHERE class_code=?",(code,)).fetchone()
        if existing:name=existing[0]
        else:db.execute("INSERT INTO classroom_courses VALUES(?,?,?)",(code,name,now()))
    warning=persist_catalog(f"Add classroom course {code}")
    return {"ok":True,"class_code":code,"course_name":name,"already_exists":bool(existing),"warning":warning}
@router.put("/api/classroom/classes")
def update_class(req:CourseUpdate):
    check_password(req.password);code=clean(req.class_code,"");name=clean_label(req.course_name,"")
    if not code or not name:raise HTTPException(400,"請輸入班級名稱")
    with lock,sqlite3.connect(DB) as db:
        if not db.execute("SELECT 1 FROM classroom_courses WHERE class_code=?",(code,)).fetchone():raise HTTPException(404,"找不到這個班級")
        db.execute("UPDATE classroom_courses SET course_name=? WHERE class_code=?",(name,code))
    return {"ok":True,"warning":persist_catalog(f"Rename classroom course {code}"),"course_name":name}
@router.delete("/api/classroom/classes")
async def delete_class(req:CourseDelete):
    check_password(req.password);code=clean(req.class_code,"");deletions=archived_paths(code)
    with lock,sqlite3.connect(DB) as db:
        if not db.execute("SELECT 1 FROM classroom_courses WHERE class_code=?",(code,)).fetchone():raise HTTPException(404,"找不到這個班級")
        db.execute("DELETE FROM classroom_versions WHERE class_code=?",(code,));db.execute("DELETE FROM classroom_projects WHERE class_code=?",(code,));db.execute("DELETE FROM classroom_members WHERE class_code=?",(code,));db.execute("DELETE FROM classroom_help_cooldowns WHERE class_code=?",(code,));db.execute("DELETE FROM classroom_courses WHERE class_code=?",(code,))
    for key,ws in list(students.items()):
        if key[0]==code:
            students.pop(key,None);live.pop(key,None)
            try:await ws.close(code=4004,reason="class removed")
            except Exception:pass
    return {"ok":True,"warning":persist_catalog(f"Delete classroom course {code}",deletions)}
@router.post("/api/classroom/students")
async def create_students(req:StudentCreate):
    check_password(req.password); code=clean(req.class_code,"")
    with lock,sqlite3.connect(DB) as db:
        if not db.execute("SELECT 1 FROM classroom_courses WHERE class_code=?",(code,)).fetchone():raise HTTPException(404,"找不到這個班級")
        existing=db.execute("SELECT student_id,display_name FROM classroom_members WHERE class_code=?",(code,)).fetchall();used={row[0] for row in existing};names={row[1].casefold() for row in existing};created=[]
        for raw_name in req.display_names[:40]:
            name=clean_label(raw_name,"")
            if not name or name.casefold() in names:continue
            base=clean(name,"student");student_id=base;number=2
            while student_id in used:student_id=f"{base[:42]}-{number}";number+=1
            db.execute("INSERT INTO classroom_members VALUES(?,?,?,?)",(code,student_id,name,now()));used.add(student_id);names.add(name.casefold());created.append({"student_id":student_id,"display_name":name})
    if not created:raise HTTPException(400,"請輸入尚未在名單中的學生姓名")
    warning=persist_catalog(f"Add {len(created)} classroom students to {code}")
    for class_code,week_id in list(teachers):
        if class_code==code:await broadcast(code,week_id)
    return {"ok":True,"created":created,"warning":warning}
@router.put("/api/classroom/students")
async def update_student(req:StudentUpdate):
    check_password(req.password);code=clean(req.class_code,"");student_id=clean(req.student_id,"student");name=clean_label(req.display_name,"")
    if not code or not student_id:raise HTTPException(400,"找不到班級或學生")
    if not name:raise HTTPException(400,"請輸入學生姓名")
    with lock,sqlite3.connect(DB) as db:
        members=db.execute("SELECT student_id,display_name FROM classroom_members WHERE class_code=?",(code,)).fetchall()
        current=next((member for member in members if member[0]==student_id),None)
        if not current:raise HTTPException(404,"找不到這位學生")
        if any(member_id!=student_id and display_name.casefold()==name.casefold() for member_id,display_name in members):
            raise HTTPException(409,"這個班級已有相同姓名,請確認名單")
        db.execute("UPDATE classroom_members SET display_name=? WHERE class_code=? AND student_id=?",(name,code,student_id))
        db.execute("UPDATE classroom_projects SET display_name=? WHERE class_code=? AND student_id=?",(name,code,student_id))
        db.execute("UPDATE classroom_versions SET display_name=? WHERE class_code=? AND student_id=?",(name,code,student_id))
    for key,state in list(live.items()):
        if key[0]!=code or key[1]!=student_id:continue
        state["display_name"]=name
        target=students.get(key)
        if target:
            try:await target.send_json({"type":"identity","display_name":name})
            except Exception:pass
    warning=persist_catalog(f"Rename classroom student {student_id} in {code}")
    for class_code,week_id in list(teachers):
        if class_code==code:await broadcast(code,week_id)
    return {"ok":True,"student_id":student_id,"display_name":name,"warning":warning}
@router.delete("/api/classroom/students")
async def delete_student(req:StudentDelete):
    check_password(req.password); code=clean(req.class_code,"L6A"); student_id=clean(req.student_id,"student");deletions=archived_paths(code,student_id)
    targets=[(key,ws) for key,ws in students.items() if key[0]==code and key[1]==student_id]
    for key,ws in targets:
        students.pop(key,None);live.pop(key,None)
        try:await ws.close(code=4004,reason="removed")
        except Exception:pass
    with lock,sqlite3.connect(DB) as db:
        db.execute("DELETE FROM classroom_versions WHERE class_code=? AND student_id=?",(code,student_id))
        db.execute("DELETE FROM classroom_projects WHERE class_code=? AND student_id=?",(code,student_id))
        db.execute("DELETE FROM classroom_members WHERE class_code=? AND student_id=?",(code,student_id))
        db.execute("DELETE FROM classroom_help_cooldowns WHERE class_code=? AND student_id=?",(code,student_id))
    warning=persist_catalog(f"Remove classroom student {student_id} from {code}",deletions)
    for class_code,week_id in list(teachers):
        if class_code==code:await broadcast(code,week_id)
    return {"ok":True,"warning":warning}
@router.get("/api/classroom/template/{week_id}")
def template(week_id):return {"week_id":week_id,"files":template_files(week_id)}
@router.get("/api/classroom/progress")
def progress(class_code:str,student_id:str,display_name:str=""):
    class_code=clean(class_code,"L6A");student_id=clean(student_id,"student")
    history=version_history(class_code,student_id)
    published=published_progress(class_code,display_name)
    return {"progress":sorted(history+published,key=lambda item:(item["submitted_at"] or 0),reverse=True)}
@router.post("/api/classroom/export")
def export(req:TeacherRequest):
    check_password(req.password); payload=make_zip(clean(req.class_code,"L6A"),clean(req.week_id,"week01")); name=f"{clean(req.class_code)}_{clean(req.week_id)}_final.zip"
    return StreamingResponse(io.BytesIO(payload),media_type="application/zip",headers={"Content-Disposition":f'attachment; filename="{name}"'})
@router.post("/api/classroom/publish")
def publish(req:TeacherRequest):
    check_password(req.password)
    if not TOKEN:raise HTTPException(503,"請先在 Space Secrets 設定 HF_TOKEN")
    class_code=clean(req.class_code,"L6A"); week_id=clean(req.week_id,"week01")
    states=finals(class_code,week_id)
    if not states:raise HTTPException(400,"目前 Lesson 尚無正式提交")
    from huggingface_hub import CommitOperationAdd,CommitOperationDelete,HfApi
    api=HfApi(token=TOKEN); existing=set(api.list_repo_files(repo_id=REPO,repo_type="space")); desired={}; prefixes=[]
    for x in states:
        root=PurePosixPath("projects",demo_class_name(class_code),clean(x["display_name"],"student"),f"{week_id}_{clean(x['project_name'],'project')}"); prefix=root.as_posix()+"/"; prefixes.append(prefix)
        for path,text in x["final_files"].items():desired[(root/path).as_posix()]=text
    stale=sorted(path for path in existing if any(path.startswith(prefix) for prefix in prefixes) and path not in desired)
    ops=[CommitOperationDelete(path_in_repo=path) for path in stale]
    ops.extend(CommitOperationAdd(path_in_repo=path,path_or_fileobj=text.encode()) for path,text in sorted(desired.items()))
    result=api.create_commit(repo_id=REPO,repo_type="space",operations=ops,commit_message=f"Publish {class_code} {week_id} final projects")
    return {"ok":True,"count":len(states),"commit_url":str(result.commit_url)}

@router.websocket("/ws/classroom/student")
async def student_ws(ws:WebSocket):
    remote_ip=ws.client.host if ws.client else "unknown"
    if not allow_student_connection(remote_ip):await ws.close(code=4429,reason="connection rate limited");return
    c=clean(ws.query_params.get("class_code"),"L6A");s=clean(ws.query_params.get("student_id"),"student");requested_name=clean_label(ws.query_params.get("display_name"),"");w=clean(ws.query_params.get("week_id"),"week02");await ws.accept()
    member=find_member(c,requested_name,s)
    if not member:await ws.close(code=4404,reason="not on roster");return
    n=member["display_name"];key=(c,s,w)
    old=students.get(key)
    if old and old is not ws:
        try:await old.close(code=4001)
        except Exception:pass
    students[key]=ws;state=load(c,s,n,w);state["connected"]=True;live[key]=state;save(state);await ws.send_json({"type":"restore","state":state});await broadcast(c,w)
    try:
        while True:
            m=await ws.receive_json()
            if students.get(key) is not ws:break
            if not allow_student_message(key):
                await ws.close(code=4429,reason="message rate limited");break
            kind=m.get("type");version=None
            if kind=="project":
                state["files"]=norm_files(m.get("files"));state["project_name"]=clean_label(m.get("project_name"),"");active=safe_path(m.get("active_file","main.py"));state["active_file"]=active if active in state["files"] else "main.py";state["updated_at"]=now()
            elif kind=="output":state["output"]=str(m.get("output",""))[-40000:]
            elif kind=="status":state["status"]=m.get("status") if m.get("status") in {"idle","running","success","error"} else "idle"
            elif kind=="help":
                requested=bool(m.get("needs_help"))
                if requested and not state["needs_help"]:
                    requested_at,cooldown_until=start_help_cooldown(c,s)
                    if requested_at is None:
                        state["help_cooldown_until"]=cooldown_until
                        remaining=max(1,(cooldown_until-now()+999)//1000)
                        await ws.send_json({"type":"help_rejected","needs_help":state["needs_help"],"help_cooldown_until":cooldown_until,"help_cooldown_ms":HELP_REQUEST_COOLDOWN_MS,"message":f"還要等 {remaining} 秒才能再次呼叫老師。"})
                        continue
                    state["needs_help"]=True;state["help_requested_at"]=requested_at;state["help_cooldown_until"]=cooldown_until
                elif not requested:
                    state["needs_help"]=False;state["help_requested_at"]=None;state["help_cooldown_until"]=help_cooldown_until(c,s)
            elif kind=="checkpoint":
                try:
                    consume_version_write(c,s,w,"checkpoint")
                    state["files"]=norm_files(m.get("files",state["files"]));state["project_name"]=clean_label(m.get("project_name",state["project_name"]),"");state["checkpoint_files"]=dict(state["files"]);state["checkpoint_at"]=now();state["updated_at"]=state["checkpoint_at"];version=record_version(state,"checkpoint")
                except VersionLimitError as exc:
                    await ws.send_json({"type":"write_rejected","kind":"checkpoint","message":str(exc)});continue
            elif kind=="submit":
                try:
                    consume_version_write(c,s,w,"submit")
                    state["files"]=norm_files(m.get("files",state["files"]));state["project_name"]=clean_label(m.get("project_name",state["project_name"]),"");state["final_files"]=dict(state["files"]);state["submitted_at"]=now();state["updated_at"]=state["submitted_at"];version=record_version(state,"submit")
                except VersionLimitError as exc:
                    await ws.send_json({"type":"write_rejected","kind":"submit","message":str(exc)});continue
            elif kind=="ping":state["help_cooldown_until"]=help_cooldown_until(c,s);await ws.send_json({"type":"pong","needs_help":state["needs_help"],"help_cooldown_until":state["help_cooldown_until"],"help_cooldown_ms":HELP_REQUEST_COOLDOWN_MS,"submitted_at":state["submitted_at"],"checkpoint_at":state.get("checkpoint_at")});continue
            else:continue
            save(state)
            warning=await asyncio.to_thread(persist_version,version) if version else ""
            await ws.send_json({"type":"ack","needs_help":state["needs_help"],"help_cooldown_until":state.get("help_cooldown_until"),"help_cooldown_ms":HELP_REQUEST_COOLDOWN_MS,"submitted_at":state["submitted_at"],"checkpoint_at":state.get("checkpoint_at"),"version_kind":version["kind"] if version else None,"version_at":version["created_at"] if version else None,"version_warning":warning});await broadcast(c,w)
    except (WebSocketDisconnect,json.JSONDecodeError):pass
    finally:
        if students.get(key) is ws:students.pop(key,None);state["connected"]=False;state["status"]="idle";save(state);await broadcast(c,w)

@router.websocket("/ws/classroom/teacher")
async def teacher_ws(ws:WebSocket):
    c=clean(ws.query_params.get("class_code"),"L6A");w=clean(ws.query_params.get("week_id"),"week02");await ws.accept()
    try:auth=await asyncio.wait_for(ws.receive_json(),8)
    except Exception:await ws.close(code=4403);return
    if not PASSWORD or auth.get("type")!="auth" or not hmac.compare_digest(str(auth.get("password","")),PASSWORD):await ws.close(code=4403);return
    key=(c,w);teachers.setdefault(key,set()).add(ws);await ws.send_json({"type":"snapshot","students":snapshot(c,w)})
    try:
        while True:
            m=await ws.receive_json()
            if m.get("type")=="ack_help":
                s=clean(m.get("student_id"),"student");state=live.get((c,s,w)) or next((item for item in snapshot(c,w) if item["student_id"]==s),None)
                if state:
                    state["needs_help"]=False;state["help_requested_at"]=None;state["help_cooldown_until"]=help_cooldown_until(c,s);save(state);target=students.get((c,s,w))
                    if target:
                        try:await target.send_json({"type":"help_ack","help_cooldown_until":state["help_cooldown_until"],"help_cooldown_ms":HELP_REQUEST_COOLDOWN_MS})
                        except Exception:pass
                    await broadcast(c,w)
            elif m.get("type")=="ping":await ws.send_json({"type":"pong"})
    except (WebSocketDisconnect,json.JSONDecodeError):pass
    finally:teachers.get(key,set()).discard(ws)

init_db()