Files
LMS/src/backend/lms_api/routers/groups.py

315 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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