Download app/class_report.py from wzender/mathly: direct link, hf CLI and curl.
- Browser
- Download file 14.6 kB
-
https://huggingface.co/spaces/wzender/mathly/resolve/main/app/class_report.py
- Command line
-
hf download hf://spaces/wzender/mathly/app/class_report.py
-
curl -L -o class_report.py https://huggingface.co/spaces/wzender/mathly/resolve/main/app/class_report.py
14.6 kB
| """The class teacher's view of the class: each student's path progress, difficulties and grades. | |
| A class has one teacher, chosen from its students (`classes.teachers`). That account, and any manager, can read | |
| GET /api/classes/{id}/report: for each other student of the class, the parts of their saved progress | |
| (app/progress.py) the teacher's page shows. The page works out the rest (stars, mistake patterns) the same way the | |
| student's own page does. | |
| Progress is kept by the student's device, so everything in it is only what the device reports. The unit quizzes' | |
| grades are the exception: they come from the server's own record of each sitting (app/quiz_grades.py), and a score | |
| in the uploaded progress is never passed on. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| from fastapi import Depends, HTTPException | |
| from fastapi.responses import JSONResponse | |
| from .access import has_permission | |
| from .assignments import AssignmentsUnavailable, public as assignment_public | |
| from .worksheets import WorksheetsUnavailable, current, submission_public, worksheet_public | |
| from .answer_scans import ScanError | |
| from .auth import AuthUnavailable | |
| from .quiz_grades import sitting_view | |
| log = logging.getLogger("mathly.class_report") | |
| TABLE = "/rest/v1/math_progress" | |
| SLIPS_SENT = 300 | |
| def report_part(data: dict) -> dict: | |
| """What the teacher's page reads from a student's progress: finished skills, mistakes, and both kinds of tests.""" | |
| def obj(key): | |
| value = data.get(key) | |
| return value if isinstance(value, dict) else {} | |
| def items(key): | |
| value = data.get(key) | |
| return [x for x in value if isinstance(x, dict)] if isinstance(value, list) else [] | |
| quizzes = [{k: q.get(k) for k in ("at", "n", "right", "ms", "units")} for q in items("quizzes")] | |
| unit_quizzes = {} | |
| for unit, state in obj("uq").items(): | |
| history = state.get("history") if isinstance(state, dict) else None | |
| sitting = state.get("sitting") if isinstance(state, dict) else None | |
| unit_quizzes[unit] = { | |
| # no score: the device's copy of a grade is not evidence. `sid` ties a submission to the server's sitting. | |
| "history": [{k: h.get(k) for k in ("at", "timeUp", "sid")} for h in history or [] if isinstance(h, dict)], | |
| "going": bool(isinstance(sitting, dict) and sitting.get("started") and not sitting.get("submitted"))} | |
| return {"done": obj("done"), "gold": obj("gold"), "days": obj("days"), "missed": obj("missed"), | |
| "seen": obj("seen"), "tries": obj("tries"), "examples": obj("examples"), "slips": items("slips")[-SLIPS_SENT:], "quizzes": quizzes, "uq": unit_quizzes, | |
| "updated": data.get("updated")} | |
| def server_sittings(app, ids: dict[str, str]): | |
| """The students' unit-quiz sittings with the server's grades, by email. None when they cannot be read: the report | |
| then shows no quiz grades, rather than the device's.""" | |
| try: | |
| raw = app.state.answers.quiz(None, "report", users=list(ids.values())) if ids else [] | |
| except ScanError as exc: | |
| log.warning("quiz sittings not read: %s", exc.code) | |
| return None | |
| email_of = {user_id: email for email, user_id in ids.items()} | |
| out: dict[str, list] = {email: [] for email in ids} | |
| for sitting in raw: | |
| view = sitting_view(sitting) | |
| if view and sitting.get("user_id") in email_of: | |
| out[email_of[sitting["user_id"]]].append(view) | |
| return out | |
| def server_requests(app, ids: dict[str, str]): | |
| """The students' open requests to look again at a checked answer (app/teacher_review.py), by email. None when | |
| they cannot be read.""" | |
| try: | |
| raw = app.state.answers.review(None, "requests", users=list(ids.values())) if ids else [] | |
| except ScanError as exc: | |
| log.warning("review requests not read: %s", exc.code) | |
| return None | |
| email_of = {user_id: email for email, user_id in ids.items()} | |
| out: dict[str, list] = {email: [] for email in ids} | |
| for row in raw: | |
| if row.get("user_id") in email_of: | |
| out[email_of[row["user_id"]]].append({k: row.get(k) for k in ("attempt_id", "message", "question", "created_at")}) | |
| return out | |
| def server_assignments(app, class_id: int): | |
| """The class's assignments (the teacher's view of them), or None when they cannot be read.""" | |
| store = getattr(app.state, "assignments", None) | |
| if store is None: | |
| return None | |
| try: | |
| return [assignment_public(r, True) for r in store.for_classes([class_id])] | |
| except AssignmentsUnavailable: | |
| return None | |
| def server_worksheets(app, class_id: int, ids: dict[str, str]): | |
| """The class's worksheets and each student's (email) submissions, without storage paths; (None, None) when they cannot | |
| be read.""" | |
| store = getattr(app.state, "worksheets", None) | |
| if store is None: | |
| return None, None | |
| try: | |
| rows = store.of_class(class_id) | |
| subs = current(store.submissions([r["id"] for r in rows])) # each with its earlier versions | |
| except WorksheetsUnavailable: | |
| return None, None | |
| email_of = {uid: email for email, uid in ids.items()} | |
| out: dict[str, list] = {email: [] for email in ids} | |
| for sub in subs: | |
| email = email_of.get(sub.get("student_id")) | |
| if email is not None: | |
| out[email].append(submission_public(sub, teacher=True)) | |
| return [worksheet_public(r) for r in rows], out | |
| def install_class_report_routes(app, signed_in, units_of): | |
| def class_of(class_id: int, user) -> dict: | |
| try: | |
| row = next((r for r in app.state.auth.classes() if r.get("id") == class_id), None) | |
| except AuthUnavailable as exc: | |
| raise HTTPException(503, str(exc)) from exc | |
| if row is None: | |
| raise HTTPException(404, "class not found") | |
| email = str((user or {}).get("email", "")).lower() | |
| if email not in row.get("teachers", ()) and not has_permission(user, "management.access"): | |
| raise HTTPException(403, "only the class's teacher can see its report") | |
| return row | |
| def class_report(class_id: int, user=Depends(signed_in)): | |
| if app.state.auth is None: | |
| raise HTTPException(404, "classes need sign-in") | |
| row = class_of(class_id, user) | |
| teachers = set(row.get("teachers", ())) | |
| students = [email for email in row.get("emails", ()) if email not in teachers] | |
| auth = app.state.auth | |
| try: | |
| accounts = auth.user_accounts(students) if students else {} | |
| ids = {email: account["id"] for email, account in accounts.items()} | |
| rows = auth._admin(f"{TABLE}?select=user_id,data,updated_at&user_id=in.({','.join(ids.values())})") \ | |
| if ids else [] | |
| except AuthUnavailable as exc: | |
| log.error("class report failed: %s", exc) | |
| raise HTTPException(503, "report_unavailable") from exc | |
| by_id = {r["user_id"]: r for r in rows or []} | |
| sittings = server_sittings(app, ids) | |
| requests = server_requests(app, ids) | |
| worksheets, handed = server_worksheets(app, row["id"], ids) | |
| nicknames = {str(k).lower(): str(v).strip() for k, v in (row.get("nicknames") or {}).items()} | |
| out = [] | |
| for email in students: | |
| saved = by_id.get(ids.get(email, "")) | |
| data = saved.get("data") if saved else None | |
| name = nicknames.get(email) or accounts.get(email, {}).get("name") or None | |
| out.append({"email": email, "name": name, "signed_in": email in ids, | |
| "progress": report_part(data) if isinstance(data, dict) else None, | |
| "saved_at": saved.get("updated_at") if saved else None, | |
| "quiz_sittings": None if sittings is None else sittings.get(email, []), | |
| "review_requests": [] if requests is None else requests.get(email, []), | |
| "worksheet_submissions": None if handed is None else handed.get(email, [])}) | |
| return JSONResponse({"class": {"id": row["id"], "name": row["name"], "lessons": units_of(row), | |
| "exams": [e for e in row.get("exams") or [] if isinstance(e, dict)]}, | |
| "students": out, "assignments": server_assignments(app, row["id"]), | |
| "worksheets": worksheets}, | |
| headers={"Cache-Control": "no-store"}) | |
| MAX_EXAM_NOTE = 300 | |
| def install_exam_topic_routes(app, signed_in, units_of): | |
| """The class teacher says what an exam on the class's board covers: its units (of the class) and a short note. The | |
| students' preparation view and the class's readiness are built on them (static/math.js).""" | |
| def exam_topics(class_id: int, body: dict, user=Depends(signed_in)): | |
| try: | |
| row = next((r for r in app.state.auth.classes() if r.get("id") == class_id), None) | |
| except AuthUnavailable as exc: | |
| raise HTTPException(503, str(exc)) from exc | |
| if row is None: | |
| raise HTTPException(404, "class not found") | |
| email = str((user or {}).get("email", "")).lower() | |
| if email not in row.get("teachers", ()) and not has_permission(user, "management.access"): | |
| raise HTTPException(403, "only the class's teacher can say what an exam covers") | |
| exams = [dict(e) for e in row.get("exams") or [] if isinstance(e, dict)] | |
| found = next((e for e in exams if e.get("date") == body.get("date") and e.get("subject") == body.get("subject")), None) | |
| if found is None: | |
| raise HTTPException(404, "exam not found") | |
| units = body.get("units") or [] | |
| mine = units_of(row) | |
| if not isinstance(units, list) or len(set(units)) != len(units) or any(u not in mine for u in units): | |
| raise HTTPException(400, "a unit is not one of this class's units") | |
| note = " ".join(str(body.get("note") or "").split()) | |
| if len(note) > MAX_EXAM_NOTE: | |
| raise HTTPException(400, f"the note is up to {MAX_EXAM_NOTE} characters") | |
| found["units"] = [u for u in mine if u in units] # in the class's order | |
| found["note"] = note | |
| for i, e in enumerate(exams): | |
| if e.get("date") == found["date"] and e.get("subject") == found["subject"]: | |
| exams[i] = found | |
| try: | |
| app.state.auth.save_class_exams(class_id, exams) | |
| except AuthUnavailable as exc: | |
| raise HTTPException(503, str(exc)) from exc | |
| except KeyError as exc: | |
| raise HTTPException(404, "class not found") from exc | |
| actions = getattr(app.state, "actions", None) | |
| if actions is not None: | |
| actions.record(user, "exam_topics_saved", f"{row.get('name', '')}: {found['subject']} {found['date']}") | |
| return JSONResponse(found, headers={"Cache-Control": "no-store"}) | |
| # ---- the class's exam table (what students see on the subjects screen): the class teacher adds and removes exams | |
| MAX_SUBJECT = 40 | |
| def teacher_class(class_id: int, user) -> dict: | |
| try: | |
| row = next((r for r in app.state.auth.classes() if r.get("id") == class_id), None) | |
| except AuthUnavailable as exc: | |
| raise HTTPException(503, str(exc)) from exc | |
| if row is None: | |
| raise HTTPException(404, "class not found") | |
| email = str((user or {}).get("email", "")).lower() | |
| if email not in row.get("teachers", ()) and not has_permission(user, "management.access"): | |
| raise HTTPException(403, "only the class's teacher can change its exams") | |
| return row | |
| def save(row: dict, exams: list[dict], user, label: str) -> None: | |
| exams = sorted(exams, key=lambda e: (str(e.get("date")), str(e.get("subject")))) | |
| try: | |
| app.state.auth.save_class_exams(row["id"], exams) | |
| except AuthUnavailable as exc: | |
| raise HTTPException(503, str(exc)) from exc | |
| except KeyError as exc: | |
| raise HTTPException(404, "class not found") from exc | |
| actions = getattr(app.state, "actions", None) | |
| if actions is not None: | |
| actions.record(user, "exam_saved", f"{row.get('name', '')}: {label}") | |
| def add_exam(class_id: int, body: dict, user=Depends(signed_in)): | |
| """A new exam on the class's table: a subject, a date, and optionally the units it covers and a note.""" | |
| from datetime import date as _date | |
| row = teacher_class(class_id, user) | |
| subject = " ".join(str(body.get("subject") or "").split()) | |
| if not subject or len(subject) > MAX_SUBJECT: | |
| raise HTTPException(400, f"the subject is required (up to {MAX_SUBJECT} characters)") | |
| try: | |
| when = _date.fromisoformat(str(body.get("date") or "")).isoformat() | |
| except ValueError as exc: | |
| raise HTTPException(400, "the date must be a date (YYYY-MM-DD)") from exc | |
| units = body.get("units") or [] | |
| mine = units_of(row) | |
| if not isinstance(units, list) or any(u not in mine for u in units): | |
| raise HTTPException(400, "a unit is not one of this class's units") | |
| note = " ".join(str(body.get("note") or "").split()) | |
| if len(note) > MAX_EXAM_NOTE: | |
| raise HTTPException(400, f"the note is up to {MAX_EXAM_NOTE} characters") | |
| exams = [dict(e) for e in row.get("exams") or [] if isinstance(e, dict)] | |
| if any(e.get("date") == when and e.get("subject") == subject for e in exams): | |
| raise HTTPException(409, "exam_exists") | |
| exam = {"subject": subject, "date": when, **({"units": [u for u in mine if u in units]} if units else {}), | |
| **({"note": note} if note else {})} | |
| save(row, exams + [exam], user, f"{subject} {when}") | |
| return JSONResponse(exam, status_code=201, headers={"Cache-Control": "no-store"}) | |
| def remove_exam(class_id: int, subject: str, date: str, user=Depends(signed_in)): | |
| row = teacher_class(class_id, user) | |
| exams = [dict(e) for e in row.get("exams") or [] if isinstance(e, dict)] | |
| kept = [e for e in exams if not (e.get("date") == date and e.get("subject") == subject)] | |
| if len(kept) == len(exams): | |
| raise HTTPException(404, "exam not found") | |
| save(row, kept, user, f"נמחק: {subject} {date}") | |
| return JSONResponse({"ok": True}, headers={"Cache-Control": "no-store"}) | |