315 lines
13 KiB
Python
315 lines
13 KiB
Python
import csv
|
||
import io
|
||
import json
|
||
from pathlib import Path
|
||
from typing import Annotated
|
||
from urllib.parse import quote
|
||
|
||
from fastapi import APIRouter, Depends, File, Request, UploadFile
|
||
from fastapi.responses import Response
|
||
|
||
from ..core import db as dbmod
|
||
from ..core import xls_import
|
||
from ..core.security import gen_password, make_credentials
|
||
from ..core.util import safe_name
|
||
from ..core.schemas import AssignIn, DurationIn, LevelIn, ManualIn, NameIn
|
||
from ..core.variants import (
|
||
LEVELS,
|
||
distribute_labs,
|
||
ensure_assignment,
|
||
lab_ids_with_variants,
|
||
on_student_level_changed,
|
||
student_labs_view,
|
||
validate_number,
|
||
)
|
||
from .deps import err, load_token_db, require_teacher
|
||
|
||
router = APIRouter(tags=["teacher"])
|
||
def _order_sql(alias: str = "s") -> str:
|
||
return (f"(CASE WHEN {alias}.id_student GLOB '[0-9]*' THEN CAST({alias}.id_student AS INTEGER) ELSE -1 END), "
|
||
f"{alias}.id_student")
|
||
def _upsert_group(db, data: dict) -> tuple[int, list, list]:
|
||
g = db.execute("SELECT id FROM groups WHERE name = ?", (data["group"],)).fetchone()
|
||
if g:
|
||
gid = g["id"]
|
||
else:
|
||
gid = db.execute("INSERT INTO groups(name) VALUES (?)", (data["group"],)).lastrowid
|
||
added: list[list] = []
|
||
updated: list[dict] = []
|
||
for s in data["students"]:
|
||
sid = s["id_student"]
|
||
ex = db.execute("SELECT 1 FROM students WHERE id_student = ?", (sid,)).fetchone()
|
||
if ex:
|
||
db.execute(
|
||
"UPDATE students SET surname=?, first_name=?, middle_name=?, basis=?, note=? WHERE id_student=?",
|
||
(s["surname"], s["first_name"], s["middle_name"], s["basis"], s["note"], sid),
|
||
)
|
||
updated.append(s)
|
||
else:
|
||
pw = gen_password()
|
||
ph, salt, it = make_credentials(pw)
|
||
db.execute(
|
||
"""INSERT INTO students(id_student, surname, first_name, middle_name, basis, note,
|
||
pass_hash, salt, iterations, must_change, initial_password)
|
||
VALUES (?,?,?,?,?,?,?,?,?, 1, ?)""",
|
||
(sid, s["surname"], s["first_name"], s["middle_name"], s["basis"], s["note"], ph, salt, it, pw),
|
||
)
|
||
added.append([s, pw])
|
||
db.execute("DELETE FROM group_students WHERE student_id = ?", (sid,))
|
||
db.execute("INSERT INTO group_students(group_id, student_id) VALUES (?, ?)", (gid, sid))
|
||
ensure_assignment(db, sid, gid)
|
||
return gid, added, updated
|
||
|
||
|
||
@router.get("/")
|
||
def dashboard(db=Depends(load_token_db), user=Depends(require_teacher)):
|
||
q = lambda sql, *a: db.execute(sql, a).fetchone()[0]
|
||
labs = db.execute(
|
||
"""SELECT l.id, l.number, l.title, l.semester,
|
||
(SELECT COUNT(DISTINCT number) FROM lab_variants lv
|
||
WHERE lv.lab_id = l.id AND lv.level = 'main') AS n_main,
|
||
(SELECT COUNT(DISTINCT number) FROM lab_variants lv
|
||
WHERE lv.lab_id = l.id AND lv.level = 'advanced') AS n_adv
|
||
FROM labs l ORDER BY l.number"""
|
||
).fetchall()
|
||
return {
|
||
"stats": {
|
||
"groups": q("SELECT COUNT(*) FROM groups"),
|
||
"students": q("SELECT COUNT(*) FROM students"),
|
||
"labs": q("SELECT COUNT(*) FROM labs"),
|
||
"submissions": q("SELECT COUNT(*) FROM submissions"),
|
||
"tasks_active": q("SELECT COUNT(*) FROM task_assignments WHERE submitted_at IS NULL"),
|
||
},
|
||
"labs": [dict(r) for r in labs],
|
||
}
|
||
|
||
|
||
@router.get("/groups")
|
||
def groups_list(db=Depends(load_token_db), user=Depends(require_teacher)):
|
||
rows = db.execute(
|
||
"""SELECT g.*,
|
||
(SELECT COUNT(*) FROM group_students gs WHERE gs.group_id = g.id) AS cnt
|
||
FROM groups g ORDER BY g.id"""
|
||
).fetchall()
|
||
labs = lab_ids_with_variants(db)
|
||
first_lids = labs[:1]
|
||
unassigned_by_group = {}
|
||
first_lab_number = None
|
||
if first_lids:
|
||
r0 = db.execute("SELECT number FROM labs WHERE id = ?", (first_lids[0],)).fetchone()
|
||
first_lab_number = r0["number"] if r0 else None
|
||
for r in db.execute(
|
||
"""SELECT gs.group_id, COUNT(*) AS c
|
||
FROM group_students gs
|
||
JOIN student_assignment sa ON sa.student_id = gs.student_id
|
||
LEFT JOIN lab_assignments la
|
||
ON la.student_id = sa.student_id AND la.lab_id = ?
|
||
WHERE la.number IS NULL
|
||
GROUP BY gs.group_id""",
|
||
(first_lids[0],),
|
||
).fetchall():
|
||
unassigned_by_group[r["group_id"]] = r["c"]
|
||
out = []
|
||
for r in rows:
|
||
d = dict(r)
|
||
d["unassigned_lid"] = first_lids[0] if first_lids else None
|
||
d["unassigned_lab_number"] = first_lab_number
|
||
d["unassigned"] = unassigned_by_group.get(r["id"], 0) if first_lids else None
|
||
out.append(d)
|
||
return {"groups": out}
|
||
|
||
|
||
@router.post("/groups")
|
||
def create_group(body: NameIn, db=Depends(load_token_db), user=Depends(require_teacher)):
|
||
name = (body.name or "").strip()
|
||
if not name:
|
||
err(400, "Укажите название группы")
|
||
if db.execute("SELECT 1 FROM groups WHERE name = ?", (name,)).fetchone():
|
||
err(400, "Группа уже есть")
|
||
cur = db.execute("INSERT INTO groups(name) VALUES (?)", (name,))
|
||
return {"id": cur.lastrowid, "name": name}
|
||
|
||
|
||
@router.post("/groups/import")
|
||
def import_group(file: Annotated[UploadFile, File()], request: Request, db=Depends(load_token_db), user=Depends(require_teacher)):
|
||
if not file.filename:
|
||
err(400, "Выберите файл со списком группы")
|
||
tmp = dbmod.DATA_DIR / "import_tmp.xls"
|
||
tmp.write_bytes(file.file.read())
|
||
try:
|
||
data = xls_import.parse_group_file(tmp)
|
||
except Exception as e:
|
||
err(400, str(e))
|
||
finally:
|
||
tmp.unlink(missing_ok=True)
|
||
gid, added, updated = _upsert_group(db, data)
|
||
return {"group": data["group"], "gid": gid, "added": len(added), "updated": len(updated),
|
||
"new": [[s["id_student"], pw] for s, pw in added]}
|
||
|
||
|
||
@router.get("/groups/{gid}")
|
||
def group_detail(gid: int, db=Depends(load_token_db), user=Depends(require_teacher)):
|
||
g = db.execute("SELECT * FROM groups WHERE id = ?", (gid,)).fetchone()
|
||
if not g:
|
||
err(404, "Группа не найдена")
|
||
students = db.execute(
|
||
f"""SELECT s.id_student, s.surname, s.first_name, s.middle_name, s.basis, s.must_change,
|
||
sa.level, sa.manual AS level_manual
|
||
FROM group_students gs
|
||
JOIN students s ON s.id_student = gs.student_id
|
||
LEFT JOIN student_assignment sa ON sa.student_id = s.id_student
|
||
WHERE gs.group_id = ? ORDER BY {_order_sql()}""",
|
||
(gid,),
|
||
).fetchall()
|
||
lab_numbers = {}
|
||
for r in db.execute(
|
||
"""SELECT la.student_id, la.lab_id, la.number, la.manual
|
||
FROM lab_assignments la
|
||
JOIN group_students gs ON gs.student_id = la.student_id
|
||
WHERE gs.group_id = ?""",
|
||
(gid,),
|
||
).fetchall():
|
||
lab_numbers.setdefault(r["student_id"], {})[r["lab_id"]] = {
|
||
"number": r["number"], "manual": bool(r["manual"])
|
||
}
|
||
out = []
|
||
for x in students:
|
||
d = dict(x)
|
||
d["lab_numbers"] = lab_numbers.get(d["id_student"], {})
|
||
out.append(d)
|
||
labs = db.execute(
|
||
"""SELECT l.id, l.number, l.title,
|
||
(SELECT COUNT(DISTINCT number) FROM lab_variants lv
|
||
WHERE lv.lab_id = l.id AND lv.level = 'main') AS n_main,
|
||
(SELECT COUNT(DISTINCT number) FROM lab_variants lv
|
||
WHERE lv.lab_id = l.id AND lv.level = 'advanced') AS n_adv
|
||
FROM labs l ORDER BY l.number"""
|
||
).fetchall()
|
||
return {"group": dict(g), "students": out, "labs": [dict(x) for x in labs]}
|
||
|
||
|
||
@router.post("/groups/{gid}/levels")
|
||
def set_level(gid: int, body: LevelIn, db=Depends(load_token_db), user=Depends(require_teacher)):
|
||
student_id, level = body.student_id, body.level
|
||
if level not in LEVELS:
|
||
err(400, "Неизвестный уровень")
|
||
db.execute(
|
||
"""INSERT INTO student_assignment(student_id, group_id, level) VALUES (?, ?, ?)
|
||
ON CONFLICT(student_id) DO UPDATE SET group_id = excluded.group_id,
|
||
level = excluded.level, manual = 0""",
|
||
(student_id, gid, level),
|
||
)
|
||
on_student_level_changed(db, student_id)
|
||
return {"ok": True}
|
||
|
||
|
||
@router.post("/groups/{gid}/assign")
|
||
def assign(gid: int, body: AssignIn, db=Depends(load_token_db), user=Depends(require_teacher)):
|
||
if body.lab_id:
|
||
if not db.execute("SELECT 1 FROM labs WHERE id = ?", (body.lab_id,)).fetchone():
|
||
err(404, "Лабораторная не найдена")
|
||
lab_ids = [body.lab_id]
|
||
else:
|
||
lab_ids = lab_ids_with_variants(db)
|
||
n = distribute_labs(db, lab_ids)
|
||
return {"ok": True, "assigned": n}
|
||
|
||
|
||
@router.post("/groups/{gid}/manual")
|
||
def set_manual(gid: int, body: ManualIn, db=Depends(load_token_db), user=Depends(require_teacher)):
|
||
student_id, lab_id, number = body.student_id, body.lab_id, body.number
|
||
row = db.execute("SELECT level FROM student_assignment WHERE student_id = ?", (student_id,)).fetchone()
|
||
if not row:
|
||
err(404, "Студент не найден")
|
||
if not number.strip():
|
||
db.execute(
|
||
"UPDATE lab_assignments SET number = NULL, manual = 0 WHERE student_id = ? AND lab_id = ?",
|
||
(student_id, lab_id),
|
||
)
|
||
return {"ok": True, "cleared": True}
|
||
try:
|
||
canon = validate_number(db, lab_id, row["level"], number)
|
||
except ValueError as e:
|
||
err(400, str(e))
|
||
db.execute(
|
||
"""INSERT INTO lab_assignments(student_id, lab_id, number, manual) VALUES (?, ?, ?, 1)
|
||
ON CONFLICT(student_id, lab_id) DO UPDATE SET number = excluded.number, manual = 1""",
|
||
(student_id, lab_id, canon),
|
||
)
|
||
return {"ok": True, "number": canon}
|
||
|
||
|
||
@router.post("/students/{sid}/reset-password")
|
||
def reset_password(sid: str, db=Depends(load_token_db), user=Depends(require_teacher)):
|
||
st = db.execute("SELECT * FROM students WHERE id_student = ?", (sid,)).fetchone()
|
||
if not st:
|
||
err(404, "Студент не найден")
|
||
pw = gen_password()
|
||
ph, salt, it = make_credentials(pw)
|
||
db.execute(
|
||
"UPDATE students SET pass_hash=?, salt=?, iterations=?, must_change=1, initial_password=? WHERE id_student=?",
|
||
(ph, salt, it, pw, sid),
|
||
)
|
||
return {"ok": True, "password": pw}
|
||
|
||
|
||
@router.post("/groups/{gid}/duration")
|
||
def set_duration(gid: int, body: DurationIn, db=Depends(load_token_db), user=Depends(require_teacher)):
|
||
minutes = body.minutes
|
||
db.execute("UPDATE groups SET task_duration_min = ? WHERE id = ?", (max(5, min(600, minutes)), gid))
|
||
return {"ok": True}
|
||
|
||
|
||
def _credentials(gid, db):
|
||
return db.execute(
|
||
f"""SELECT s.* FROM group_students gs JOIN students s ON s.id_student = gs.student_id
|
||
WHERE gs.group_id = ? ORDER BY {_order_sql()}""",
|
||
(gid,),
|
||
).fetchall()
|
||
|
||
|
||
@router.get("/groups/{gid}/credentials")
|
||
def credentials(gid: int, fmt: str = "xlsx", db=Depends(load_token_db), user=Depends(require_teacher)):
|
||
g = db.execute("SELECT name FROM groups WHERE id = ?", (gid,)).fetchone()
|
||
if not g:
|
||
err(404, "Группа не найдена")
|
||
rows = _credentials(gid, db)
|
||
if fmt == "csv":
|
||
buf = io.StringIO()
|
||
w = csv.writer(buf, delimiter=";")
|
||
w.writerow(["ФИО", "Логин", "Пароль", "Статус"])
|
||
for st in rows:
|
||
w.writerow([
|
||
" ".join(x for x in (st["surname"], st["first_name"], st["middle_name"]) if x),
|
||
st["id_student"],
|
||
st["initial_password"] if st["must_change"] else "—",
|
||
"ожидает первую смену пароля" if st["must_change"] else "пароль изменён",
|
||
])
|
||
return Response(
|
||
content=("\ufeff" + buf.getvalue()).encode("utf-8"),
|
||
media_type="text/csv; charset=utf-8",
|
||
headers={"content-disposition":
|
||
f"attachment; filename*=UTF-8''{quote('credentials_' + safe_name(g['name']) + '.csv')}"},
|
||
)
|
||
from openpyxl import Workbook
|
||
|
||
wb = Workbook()
|
||
ws = wb.active
|
||
ws.title = "Логины"
|
||
ws.append(["ФИО", "Логин", "Пароль", "Статус"])
|
||
for st in rows:
|
||
ws.append([
|
||
" ".join(x for x in (st["surname"], st["first_name"], st["middle_name"]) if x),
|
||
st["id_student"],
|
||
st["initial_password"] if st["must_change"] else "—",
|
||
"ожидает первую смену пароля" if st["must_change"] else "пароль изменён",
|
||
])
|
||
out = io.BytesIO()
|
||
wb.save(out)
|
||
return Response(
|
||
content=out.getvalue(),
|
||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
headers={"content-disposition":
|
||
f"attachment; filename*=UTF-8''{quote('credentials_' + safe_name(g['name']) + '.xlsx')}"},
|
||
)
|