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 ""}