File size: 14,609 Bytes
afd13cb 0818184 afd13cb 7badcff 0ba5932 0818184 afd13cb 0818184 afd13cb 0818184 afd13cb 7badcff afd13cb 0818184 7badcff 8fb90d0 0ba5932 8fb90d0 0ba5932 8fb90d0 afd13cb 7badcff afd13cb 0818184 7badcff 8fb90d0 7badcff afd13cb 7badcff afd13cb 0818184 7badcff 8fb90d0 0ba5932 8fb90d0 7badcff 0ba5932 | 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 | """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
@app.get("/api/classes/{class_id}/report")
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)."""
@app.put("/api/classes/{class_id}/exams/topics")
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}")
@app.post("/api/classes/{class_id}/exams")
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"})
@app.delete("/api/classes/{class_id}/exams")
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"})
|