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