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