Spaces:
Running
Running
File size: 13,259 Bytes
ed832a7 b88dc75 ed832a7 b88dc75 60dfd5a b88dc75 ed832a7 60dfd5a ed832a7 60dfd5a ed832a7 | 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 | """Teacher-only content administration for classroom plans and Demo projects."""
from __future__ import annotations
import json
import re
import shutil
from pathlib import Path, PurePosixPath
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
import classroom_service as classroom
ROOT = Path(__file__).resolve().parent
PROJECTS = ROOT / "projects"
STATIC = ROOT / "classroom_static"
TEXT_EXTENSIONS = {
".py", ".html", ".css", ".js", ".json", ".txt", ".csv", ".md",
".toml", ".yaml", ".yml",
}
router = APIRouter()
class AdminAuth(BaseModel):
password: str
class WeekRequest(AdminAuth):
week_id: str
class WeekSave(WeekRequest):
title: str
project: str
output: str
files: dict[str, str]
class ProjectRef(BaseModel):
class_name: str
student_name: str
project_name: str
class DemoRequest(AdminAuth):
project: ProjectRef
class DemoSave(AdminAuth):
original: ProjectRef | None = None
class_name: str
student_name: str
project_name: str
files: dict[str, str]
class ClassroomSubmissionRequest(AdminAuth):
class_code: str
student_id: str
week_id: str
class ClassroomBackupRequest(AdminAuth):
class_code: str
week_id: str
def segment(value: str, label: str) -> str:
text = re.sub(r"[\x00-\x1f\x7f]", "", str(value).strip())[:100]
if not text or text in {".", ".."} or "/" in text or "\\" in text:
raise HTTPException(400, f"{label}不合法")
return text
def text_path(value: str) -> str:
raw = str(value).replace("\\", "/").strip("/")
path = PurePosixPath(raw)
if (
not raw or path.is_absolute() or ".." in path.parts or len(raw) > 180
or path.suffix.lower() not in TEXT_EXTENSIONS
):
raise HTTPException(400, f"檔案路徑不合法:{value}")
return path.as_posix()
def project_root(ref: ProjectRef) -> Path:
return PROJECTS / segment(ref.class_name, "班級名稱") / segment(ref.student_name, "學生名稱") / segment(ref.project_name, "專題名稱")
def relative_repo_path(path: Path) -> str:
return path.relative_to(ROOT).as_posix()
def commit_changes(additions: dict[str, bytes | Path], deletions: list[str], message: str):
if not classroom.TOKEN:
raise HTTPException(503, "請先在 Space Secrets 設定 HF_TOKEN")
from huggingface_hub import CommitOperationAdd, CommitOperationDelete, HfApi
add_paths = set(additions)
operations = [
CommitOperationDelete(path_in_repo=path)
for path in sorted(set(deletions) - add_paths)
]
operations.extend(
CommitOperationAdd(path_in_repo=path, path_or_fileobj=value)
for path, value in sorted(additions.items())
)
if not operations:
return None
return HfApi(token=classroom.TOKEN).create_commit(
repo_id=classroom.REPO,
repo_type="space",
operations=operations,
commit_message=message,
)
def load_course() -> dict:
return json.loads((classroom.DATA / "course.json").read_text(encoding="utf-8"))
def scan_demo() -> list[dict]:
projects = []
if not PROJECTS.is_dir():
return projects
for class_dir in sorted(path for path in PROJECTS.iterdir() if path.is_dir() and not path.name.startswith(".")):
for student_dir in sorted(path for path in class_dir.iterdir() if path.is_dir() and not path.name.startswith(".")):
for item in sorted(path for path in student_dir.iterdir() if path.is_dir() and not path.name.startswith(".")):
kind = "python" if (item / "main.py").is_file() else "html" if (item / "index.html").is_file() else "external" if (item / "link.txt").is_file() else "asset"
projects.append({
"class_name": class_dir.name,
"student_name": student_dir.name,
"project_name": item.name,
"type": kind,
})
return projects
def read_editable_text(path: Path) -> str | None:
if path.suffix.lower() not in TEXT_EXTENSIONS or path.stat().st_size > 800_000:
return None
try:
return path.read_text(encoding="utf-8")
except UnicodeDecodeError:
return None
def editable_project(root: Path) -> dict:
if not root.is_dir():
raise HTTPException(404, "找不到這個 Demo 專題")
files: dict[str, str] = {}
assets: list[str] = []
for path in sorted(item for item in root.rglob("*") if item.is_file()):
rel = path.relative_to(root).as_posix()
content = read_editable_text(path)
if content is None:
assets.append(rel)
else:
files[rel] = content
return {"files": files, "assets": assets}
@router.get("/admin")
def admin_page():
return FileResponse(STATIC / "admin.html")
@router.post("/api/admin/state")
def admin_state(req: AdminAuth):
classroom.check_password(req.password)
return {"course": load_course(), "demo_projects": scan_demo(), "classrooms": {"courses": classroom.catalog()["courses"], "submissions": classroom.submission_catalog()}}
@router.post("/api/admin/classroom/submission")
def admin_submission(req: ClassroomSubmissionRequest):
classroom.check_password(req.password)
state = classroom.get_submission(req.class_code, req.student_id, req.week_id)
return {"submission": classroom.submission_payload(state)}
@router.post("/api/admin/classroom/backup")
def backup_submissions(req: ClassroomBackupRequest):
classroom.check_password(req.password)
class_code = classroom.clean(req.class_code, "")
week_id = classroom.clean(req.week_id, "")
states = classroom.finals(class_code, week_id)
if not states:
raise HTTPException(400, "這個 Lesson 還沒有學生提交")
additions = {}
for state in states:
path = classroom.submission_path(class_code, state["student_id"], week_id)
content = (json.dumps(classroom.submission_payload(state), ensure_ascii=False, indent=2) + "\n").encode()
additions[relative_repo_path(path)] = content
result = commit_changes(additions, [], f"Backup {class_code} {week_id} classroom submissions")
for repo_path, content in additions.items():
path = ROOT / repo_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
return {"ok": True, "count": len(states), "commit_url": str(result.commit_url) if result else ""}
@router.post("/api/admin/classroom/submission/delete")
def delete_submission(req: ClassroomSubmissionRequest):
classroom.check_password(req.password)
state = classroom.get_submission(req.class_code, req.student_id, req.week_id)
path = classroom.submission_path(state["class_code"], state["student_id"], state["week_id"])
result = commit_changes({}, [relative_repo_path(path)] if path.is_file() else [], f"Delete {state['class_code']} {state['week_id']} submission for {state['display_name']}")
with classroom.lock, classroom.sqlite3.connect(classroom.DB) as db:
db.execute("UPDATE classroom_projects SET final_files=NULL,submitted_at=NULL WHERE class_code=? AND student_id=? AND week_id=?",(state["class_code"],state["student_id"],state["week_id"]))
path.unlink(missing_ok=True)
return {"ok": True, "commit_url": str(result.commit_url) if result else ""}
@router.post("/api/admin/week")
def admin_week(req: WeekRequest):
classroom.check_password(req.password)
week_id = classroom.clean(req.week_id, "")
course = load_course()
week = next((item for item in course.get("weeks", []) if item.get("id") == week_id), None)
if not week:
raise HTTPException(404, "找不到這個 Lesson")
root = classroom.TEMPLATES / week_id
files = {}
if root.is_dir():
for path in sorted(item for item in root.rglob("*") if item.is_file()):
rel = path.relative_to(root).as_posix()
if path.suffix.lower() in TEXT_EXTENSIONS:
files[rel] = path.read_text(encoding="utf-8")
return {"week": week, "files": files}
@router.post("/api/admin/week/save")
def save_week(req: WeekSave):
classroom.check_password(req.password)
week_id = classroom.clean(req.week_id, "")
files = {text_path(path): str(content) for path, content in req.files.items()}
course = load_course()
week = next((item for item in course.get("weeks", []) if item.get("id") == week_id), None)
if not week:
raise HTTPException(404, "找不到這個 Lesson")
week.update({
"title": classroom.clean_label(req.title, week.get("title", "")),
"project": classroom.clean_label(req.project, week.get("project", "")),
"output": classroom.clean_label(req.output, week.get("output", ""), 160),
"template": bool(files),
})
course_bytes = (json.dumps(course, ensure_ascii=False, indent=2) + "\n").encode()
root = classroom.TEMPLATES / week_id
old = [path for path in root.rglob("*") if path.is_file()] if root.is_dir() else []
additions = {"classroom_data/course.json": course_bytes}
additions.update({f"classroom_data/templates/{week_id}/{path}": content.encode() for path, content in files.items()})
deletions = [relative_repo_path(path) for path in old if path.relative_to(root).as_posix() not in files]
result = commit_changes(additions, deletions, f"Update classroom {week_id}")
(classroom.DATA / "course.json").write_bytes(course_bytes)
root.mkdir(parents=True, exist_ok=True)
for path in old:
if path.relative_to(root).as_posix() not in files:
path.unlink(missing_ok=True)
for rel, content in files.items():
target = root / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return {"ok": True, "commit_url": str(result.commit_url) if result else ""}
@router.post("/api/admin/demo/project")
def admin_demo_project(req: DemoRequest):
classroom.check_password(req.password)
return editable_project(project_root(req.project))
@router.post("/api/admin/demo/save")
def save_demo(req: DemoSave):
classroom.check_password(req.password)
target_ref = ProjectRef(
class_name=segment(req.class_name, "班級名稱"),
student_name=segment(req.student_name, "學生名稱"),
project_name=segment(req.project_name, "專題名稱"),
)
target = project_root(target_ref)
original = project_root(req.original) if req.original else None
if original and not original.is_dir():
raise HTTPException(404, "原始 Demo 專題不存在")
if target != original and target.exists():
raise HTTPException(409, "目標位置已有同名專題")
files = {text_path(path): str(content) for path, content in req.files.items()}
if not any(name in files for name in ("main.py", "index.html", "link.txt")):
raise HTTPException(400, "專題至少需要 main.py、index.html 或 link.txt 其中一個入口")
existing = [path for path in original.rglob("*") if path.is_file()] if original else []
additions: dict[str, bytes | Path] = {}
target_prefix = relative_repo_path(target)
if target != original:
for path in existing:
rel = path.relative_to(original).as_posix()
if read_editable_text(path) is None:
additions[f"{target_prefix}/{rel}"] = path
additions.update({f"{target_prefix}/{rel}": content.encode() for rel, content in files.items()})
if target == original:
deletions = [relative_repo_path(path) for path in existing if read_editable_text(path) is not None and path.relative_to(original).as_posix() not in files]
else:
deletions = [relative_repo_path(path) for path in existing]
result = commit_changes(additions, deletions, f"Update Demo project {target_ref.project_name}")
if original and target != original:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(original, target)
shutil.rmtree(original)
else:
target.mkdir(parents=True, exist_ok=True)
for path in list(target.rglob("*")):
if path.is_file() and read_editable_text(path) is not None and path.relative_to(target).as_posix() not in files:
path.unlink()
for rel, content in files.items():
path = target / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
project_data = target_ref.model_dump() if hasattr(target_ref, "model_dump") else target_ref.dict()
return {"ok": True, "project": project_data, "commit_url": str(result.commit_url) if result else ""}
@router.post("/api/admin/demo/delete")
def delete_demo(req: DemoRequest):
classroom.check_password(req.password)
root = project_root(req.project)
if not root.is_dir():
raise HTTPException(404, "找不到這個 Demo 專題")
paths = [path for path in root.rglob("*") if path.is_file()]
result = commit_changes({}, [relative_repo_path(path) for path in paths], f"Delete Demo project {root.name}")
shutil.rmtree(root)
return {"ok": True, "commit_url": str(result.commit_url) if result else ""}
|