File size: 11,616 Bytes
a7ffb22 66fd359 0815e01 66fd359 0815e01 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 0815e01 a7ffb22 b6f5bc4 66fd359 a7ffb22 b6f5bc4 a7ffb22 0815e01 a7ffb22 b6f5bc4 a7ffb22 b6f5bc4 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 b6f5bc4 a7ffb22 66fd359 a7ffb22 b6f5bc4 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 66fd359 a7ffb22 b6f5bc4 a7ffb22 0815e01 b6f5bc4 0815e01 | 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 | """Classes. A manager (or a developer) makes, changes and deletes them; a class's teacher, or a manager, adds and
removes its students; a teacher sees the classes they teach; a manager sees every student."""
from __future__ import annotations
import re
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from . import reads
from .access import allowed, need
from .auth import EMAIL
from .store import ClassExists, MemberExists, TeacherIsStudent
MAX_EMAILS = 200
MAX_NAME = 80
router = APIRouter(prefix="/api/classes")
teaching = APIRouter(prefix="/api")
class ClassBody(BaseModel):
name: str = Field(max_length=200)
lessons: list[str] = Field(default_factory=list, max_length=200) # ignored: kept so older clients still work
teacher_email: str | None = Field(default=None, max_length=300)
students: str | None = Field(default=None, max_length=40000) # pasted text: the whole roster of the class
ai_enabled: bool = False
ai_daily_checks: int = 150
subjects: list[str] | None = Field(default=None, max_length=50) # None: math for a new class, unchanged for an edited one
class NewStudents(BaseModel):
emails: str = Field(max_length=20000) # pasted text: addresses separated by spaces, commas, semicolons or new lines
def parse_emails(text: str) -> tuple[list[str], list[str]]:
"""(valid, invalid) addresses from pasted text: lower-case, without repeats, in the order given."""
valid: list[str] = []
invalid: list[str] = []
for part in re.split(r"[\s,;]+", text.strip().lower()):
if not part:
continue
bucket = valid if EMAIL.match(part) and len(part) <= 254 else invalid
if part not in bucket:
bucket.append(part)
return valid, invalid
def shown(request: Request, row: dict, with_students: bool = True) -> dict:
out = {k: row[k] for k in ("id", "name", "lessons", "teacher_email", "ai_enabled", "ai_daily_checks", "students")}
out["subjects"] = list(row.get("subjects") or ["math"])
if with_students:
out["student_emails"] = [m["email"] for m in request.app.state.store.members(row["id"])]
return out
def checked(request: Request, body: ClassBody) -> dict:
"""The class fields from a request, or an HTTP error that says what is wrong."""
name = " ".join(body.name.split())
if not 1 <= len(name) <= MAX_NAME:
raise HTTPException(422, f"砖诐 讛讻讬转讛 爪专讬讱 诇讛讬讜转 讘讬谉 转讜 讗讞讚 诇志{MAX_NAME} 转讜讜讬诐")
lessons = [lesson["id"] for lesson in request.app.state.bank.lessons] # every class studies the whole course
teacher = (body.teacher_email or "").strip().lower() or None
if teacher and not EMAIL.match(teacher):
raise HTTPException(422, "讻转讜讘转 讛诪讜专讛 诇讗 谞专讗讬转 讻诪讜 诪讬讬诇")
if not 0 <= body.ai_daily_checks <= 10000:
raise HTTPException(422, "诪住驻专 讘讚讬拽讜转 讛志AI 讘讬讜诐 爪专讬讱 诇讛讬讜转 讘讬谉 0 诇志10000")
roster, invalid = parse_emails(body.students or "")
if len(roster) > 2 * MAX_EMAILS:
raise HTTPException(422, "讬讜转专 诪讚讬 转诇诪讬讚讬诐 讘讘转 讗讞转")
if teacher and teacher in roster:
raise HTTPException(422, "诪讜专讛 讛讻讬转讛 诇讗 讬讻讜诇 诇讛讬讜转 讙诐 转诇诪讬讚 讘讛")
subjects = None
if body.subjects is not None:
known = {s["id"] for s in request.app.state.bank.subjects}
subjects = list(dict.fromkeys(body.subjects))
if not subjects:
raise HTTPException(422, "讻讬转讛 诇讜诪讚转 诇驻讞讜转 诪拽爪讜注 讗讞讚")
if any(s not in known for s in subjects):
raise HTTPException(422, "讗讞讚 讛诪拽爪讜注讜转 砖谞讘讞专讜 诇讗 拽讬讬诐")
return {"name": name, "lessons": lessons, "teacher_email": teacher, "ai_enabled": body.ai_enabled,
"ai_daily_checks": body.ai_daily_checks, "roster": roster, "invalid": invalid, "subjects": subjects}
def sync_roster(store, class_id: int, roster: list[str]) -> None:
"""Make the class's students exactly `roster`. Removing a student never deletes their progress."""
current = {m["email"] for m in store.members(class_id)}
for email in roster:
if email not in current:
store.add_member(class_id, email)
for email in current - set(roster):
store.remove_member(class_id, email)
def own_class_for_roster(request: Request, class_id: int, user: dict) -> dict:
"""A manager (or developer) reaches any class; a teacher only the classes they teach."""
row = request.app.state.store.get_class(class_id)
if not row:
raise HTTPException(404, "讛讻讬转讛 诇讗 谞诪爪讗讛")
if not allowed(user["roles"], "classes.manage") and row["teacher_email"] != user["email"]:
raise HTTPException(404, "讛讻讬转讛 诇讗 谞诪爪讗讛")
return row
# ---- the manager ----
@router.get("")
def list_classes(request: Request, user: dict = Depends(need("classes.manage"))) -> list[dict]:
return [shown(request, c) for c in request.app.state.store.classes()]
@router.post("", status_code=201)
def create_class(body: ClassBody, request: Request, user: dict = Depends(need("classes.manage"))) -> dict:
fields = checked(request, body)
store = request.app.state.store
try:
row = store.create_class(user["email"], fields["name"], lessons=fields["lessons"], teacher_email=fields["teacher_email"],
ai_enabled=fields["ai_enabled"], ai_daily_checks=fields["ai_daily_checks"],
subjects=fields["subjects"] or ["math"])
except ClassExists:
raise HTTPException(409, "讻讘专 讬砖 讻讬转讛 讘砖诐 讛讝讛") from None
sync_roster(store, row["id"], fields["roster"])
request.app.state.roles.forget()
return {**shown(request, store.get_class(row["id"])), "invalid": fields["invalid"]}
@router.put("/{class_id}")
def change_class(class_id: int, body: ClassBody, request: Request, user: dict = Depends(need("classes.manage"))) -> dict:
fields = checked(request, body)
store = request.app.state.store
try:
store.update_class(class_id, name=fields["name"], lessons=fields["lessons"], teacher_email=fields["teacher_email"],
ai_enabled=fields["ai_enabled"], ai_daily_checks=fields["ai_daily_checks"],
**({"subjects": fields["subjects"]} if fields["subjects"] is not None else {}))
except KeyError:
raise HTTPException(404, "讛讻讬转讛 诇讗 谞诪爪讗讛") from None
except ClassExists:
raise HTTPException(409, "讻讘专 讬砖 讻讬转讛 讘砖诐 讛讝讛") from None
except TeacherIsStudent:
raise HTTPException(422, "诪讜专讛 讛讻讬转讛 诇讗 讬讻讜诇 诇讛讬讜转 讙诐 转诇诪讬讚 讘讛") from None
if body.students is not None:
sync_roster(store, class_id, fields["roster"])
request.app.state.roles.forget()
return {**shown(request, store.get_class(class_id)), "invalid": fields["invalid"]}
@router.delete("/{class_id}", status_code=204)
def delete_class(class_id: int, request: Request, user: dict = Depends(need("classes.manage"))) -> None:
if not request.app.state.store.delete_class(class_id):
raise HTTPException(404, "讛讻讬转讛 诇讗 谞诪爪讗讛")
request.app.state.roles.forget()
@router.get("/{class_id}")
def show_class(class_id: int, request: Request, user: dict = Depends(need("classes.manage"))) -> dict:
row = request.app.state.store.get_class(class_id)
if not row:
raise HTTPException(404, "讛讻讬转讛 诇讗 谞诪爪讗讛")
return shown(request, row)
# ---- the roster: the class's teacher, or a manager ----
@router.post("/{class_id}/students")
def add_students(class_id: int, body: NewStudents, request: Request, user: dict = Depends(need("class.roster"))) -> dict:
row = own_class_for_roster(request, class_id, user)
store = request.app.state.store
valid, invalid = parse_emails(body.emails)
if len(valid) > MAX_EMAILS:
raise HTTPException(422, f"讗驻砖专 诇讛讜住讬祝 注讚 {MAX_EMAILS} 转诇诪讬讚讬诐 讘讻诇 驻注诐")
result: dict = {"added": [], "already_here": [], "teacher": [], "invalid": invalid}
for email in valid:
try:
store.add_member(row["id"], email)
result["added"].append(email)
except MemberExists:
result["already_here"].append(email)
except TeacherIsStudent:
result["teacher"].append(email)
request.app.state.roles.forget()
return result
@router.delete("/{class_id}/students/{email:path}", status_code=204)
def remove_student(class_id: int, email: str, request: Request, user: dict = Depends(need("class.roster"))) -> None:
own_class_for_roster(request, class_id, user)
if not request.app.state.store.remove_member(class_id, email.strip().lower()):
raise HTTPException(404, "讛转诇诪讬讚 诇讗 谞诪爪讗 讘讻讬转讛")
request.app.state.roles.forget(email)
# ---- the teacher's and the manager's lists ----
@teaching.get("/teaching")
def my_classes(request: Request, user: dict = Depends(need("class.teach"))) -> list[dict]:
"""The classes I teach, each with its students. A developer sees every class."""
store = request.app.state.store
rows = store.classes() if allowed(user["roles"], "*") else store.classes_taught(user["email"])
return [{"id": c["id"], "name": c["name"], "lessons": c["lessons"], "ai_enabled": c["ai_enabled"], "subjects": list(c.get("subjects") or ["math"]),
"students": [{"email": m["email"], "signed_in": bool(m["user_id"])} for m in store.members(c["id"])]} for c in rows]
@teaching.get("/students")
def all_students(request: Request, user: dict = Depends(need("students.list"))) -> list[dict]:
return request.app.state.store.all_students()
# ---- the curriculum: the date by which each lesson should be completed (one for the whole school) ----
curriculum = APIRouter(prefix="/api/curriculum")
class Dates(BaseModel):
dates: dict[str, str | None] = Field(max_length=200) # lesson id -> "YYYY-MM-DD", or null to clear
@curriculum.get("")
def read_curriculum(request: Request, user: dict = Depends(need("classes.manage"))) -> list[dict]:
"""Every published lesson, grouped by subject in the subjects' order and each subject's lessons in order, with its date (or null)
and the bank's month as a hint."""
dates, bank = reads.of(request).curriculum(), request.app.state.bank
return [{"id": lesson["id"], "name": lesson["name"], "subject": {"id": s["id"], "name": s["name"]}, "month": lesson.get("when", ""),
"due_date": dates[lesson["id"]].isoformat() if lesson["id"] in dates else None}
for s in bank.subjects for lesson in bank.lessons_of_subject(s["id"])]
@curriculum.put("")
def change_curriculum(body: Dates, request: Request, user: dict = Depends(need("classes.manage"))) -> list[dict]:
known = {lesson["id"] for lesson in request.app.state.bank.lessons}
parsed: dict[str, date | None] = {}
for lesson, text in body.dates.items():
if lesson not in known:
raise HTTPException(422, "砖讬注讜专 诇讗 诪讜讻专")
try:
parsed[lesson] = date.fromisoformat(text) if text else None
except ValueError:
raise HTTPException(422, "转讗专讬讱 诇讗 转拽讬谉") from None
request.app.state.store.set_lesson_dates(parsed)
request.state.reads = None # what this request read is out of date now
return read_curriculum(request, user)
|