323 lines
13 KiB
Python
323 lines
13 KiB
Python
import json as _json
|
||
from typing import Annotated
|
||
from urllib.parse import quote
|
||
|
||
from fastapi import APIRouter, Depends, Form, Query, Request, UploadFile, File
|
||
from fastapi.responses import RedirectResponse
|
||
|
||
from .. import ctx
|
||
from ..client import ApiDown, ApiError
|
||
|
||
router = APIRouter(prefix="/t")
|
||
|
||
|
||
def _err_or_die(request, e, tpl=None, user=None, **ctx):
|
||
if isinstance(e, ApiDown):
|
||
return ctx.down_page(request, user or {})
|
||
return None
|
||
|
||
|
||
def _page_or_error(request, tpl, user, e):
|
||
if isinstance(e, ApiDown):
|
||
return ctx.render_page(request, tpl, user=user,
|
||
error="Бэкенд lms-api недоступен: запустите make dev")
|
||
return ctx.render_page(request, tpl, user=user, error=e.detail)
|
||
|
||
|
||
@router.get("/")
|
||
def dashboard(request: Request, user=Depends(ctx.need_teacher)):
|
||
return RedirectResponse("/t/labs", status_code=303)
|
||
|
||
|
||
# ------------------------------------------------------------ лабораторные
|
||
|
||
@router.get("/labs")
|
||
def labs_list(request: Request, user=Depends(ctx.need_teacher)):
|
||
c = ctx.api_client(request)
|
||
try:
|
||
data = c.get("/labs")
|
||
except ApiDown:
|
||
return ctx.down_page(request, user)
|
||
except ApiError as e:
|
||
return _page_or_error(request, "t_labs.html", user, e)
|
||
finally:
|
||
c.close()
|
||
return ctx.render_page(request, "t_labs.html", user=user, labs=data["labs"])
|
||
|
||
|
||
@router.get("/labs/new")
|
||
def lab_new_form(request: Request, user=Depends(ctx.need_teacher)):
|
||
return ctx.render_page(request, "t_lab_form.html", user=user, parsed=None)
|
||
|
||
|
||
@router.post("/labs/new/import")
|
||
def lab_new_import(request: Request, user=Depends(ctx.need_teacher),
|
||
file: Annotated[UploadFile | None, File()] = None):
|
||
if not file or not file.filename:
|
||
return RedirectResponse("/t/labs/new?err=Выберите%20JSON-файл%20условий", status_code=303)
|
||
raw_text = file.file.read().decode("utf-8", errors="replace")
|
||
c = ctx.api_client(request)
|
||
try:
|
||
try:
|
||
data = c.upload("/labs/parse-json", file.filename, raw_text.encode("utf-8"), "application/json")
|
||
except ApiDown:
|
||
return ctx.down_page(request, user)
|
||
except ApiError as e:
|
||
return ctx.render_page(request, "t_lab_form.html", user=user, parsed=None, error=e.detail)
|
||
finally:
|
||
c.close()
|
||
parsed = {"raw": raw_text, "lab": data.get("lab", {}), "variants": data.get("variants", [])}
|
||
return ctx.render_page(request, "t_lab_form.html", user=user, parsed=parsed)
|
||
|
||
|
||
@router.post("/labs/new")
|
||
def lab_create(request: Request, user=Depends(ctx.need_teacher),
|
||
variants_json: Annotated[str, Form()] = ""):
|
||
if not variants_json.strip():
|
||
return ctx.render_page(request, "t_lab_form.html", user=user, parsed=None,
|
||
error="Загрузите JSON-файл условий лабораторной")
|
||
obj = _json.loads(variants_json)
|
||
payload = {}
|
||
for key in ("number", "title", "semester", "deadline", "points_max"):
|
||
if key in obj and obj[key] is not None:
|
||
payload[key] = obj[key]
|
||
c = ctx.api_client(request)
|
||
try:
|
||
r = c.post_json("/labs", payload)
|
||
except ApiDown:
|
||
return ctx.down_page(request, user)
|
||
except ApiError as e:
|
||
return ctx.render_page(request, "t_lab_form.html", user=user, parsed=None, error=e.detail)
|
||
finally:
|
||
c.close()
|
||
msg = f"Лабораторная создана, вариантов: {len(obj.get('variants') or [])}"
|
||
c2 = ctx.api_client(request)
|
||
try:
|
||
up = c2.upload(f"/labs/{r['id']}/content", "content.json",
|
||
variants_json.encode("utf-8"), "application/json")
|
||
msg = f"Лабораторная создана, вариантов: {up.get('count', 0)}"
|
||
except (ApiDown, ApiError) as e:
|
||
msg += " (варианты не загрузились: " + getattr(e, "detail", "") + ")"
|
||
finally:
|
||
c2.close()
|
||
return RedirectResponse(f"/t/labs/{r['id']}/?msg=" + quote(msg), status_code=303)
|
||
|
||
|
||
@router.get("/labs/{lid}")
|
||
def lab_page(request: Request, lid: int, user=Depends(ctx.need_teacher)):
|
||
c = ctx.api_client(request)
|
||
try:
|
||
data = c.get(f"/labs/{lid}")
|
||
except ApiDown:
|
||
return ctx.down_page(request, user)
|
||
except ApiError as e:
|
||
return _page_or_error(request, "t_lab.html", user, e)
|
||
finally:
|
||
c.close()
|
||
return ctx.render_page(request, "t_lab.html", user=user, lab=data["lab"],
|
||
variants_main=data["variants_main"],
|
||
variants_advanced=data["variants_advanced"],
|
||
levels=data["levels"])
|
||
|
||
|
||
@router.post("/labs/{lid}/json")
|
||
def lab_json_upload(request: Request, lid: int, user=Depends(ctx.need_teacher),
|
||
file: Annotated[UploadFile | None, File()] = None):
|
||
if not file or not file.filename:
|
||
return RedirectResponse(f"/t/labs/{lid}?err=Выберите%20JSON-файл", status_code=303)
|
||
c = ctx.api_client(request)
|
||
try:
|
||
r = c.upload(f"/labs/{lid}/json", file.filename, file.file.read(), "application/json")
|
||
except ApiDown:
|
||
return ctx.down_page(request, user)
|
||
except ApiError as e:
|
||
return RedirectResponse(f"/t/labs/{lid}/?err=" + quote(e.detail), status_code=303)
|
||
finally:
|
||
c.close()
|
||
return RedirectResponse(f"/t/labs/{lid}/?msg=" + quote(
|
||
f"Вариантов загружено: {r.get('count', 0)} (осн. {r.get('main', 0)}, повыш. {r.get('advanced', 0)})"),
|
||
status_code=303)
|
||
|
||
|
||
@router.post("/labs/{lid}/delete")
|
||
def lab_delete(request: Request, lid: int, user=Depends(ctx.need_teacher)):
|
||
c = ctx.api_client(request)
|
||
try:
|
||
c.delete(f"/labs/{lid}")
|
||
except (ApiDown, ApiError):
|
||
pass
|
||
finally:
|
||
c.close()
|
||
return RedirectResponse("/t/labs/?msg=" + quote("Лабораторная удалена"), status_code=303)
|
||
|
||
|
||
# ------------------------------------------------------------ группы
|
||
|
||
@router.get("/groups")
|
||
def groups_list(request: Request, user=Depends(ctx.need_teacher)):
|
||
c = ctx.api_client(request)
|
||
try:
|
||
data = c.get("/groups")
|
||
except ApiDown:
|
||
return ctx.down_page(request, user)
|
||
except ApiError as e:
|
||
return _page_or_error(request, "t_groups.html", user, e)
|
||
finally:
|
||
c.close()
|
||
return ctx.render_page(request, "t_groups.html", user=user, groups=data["groups"])
|
||
|
||
|
||
@router.post("/groups")
|
||
def create_group(request: Request, name: Annotated[str, Form()], user=Depends(ctx.need_teacher)):
|
||
c = ctx.api_client(request)
|
||
try:
|
||
r = c.post_json("/groups", {"name": name})
|
||
except ApiDown:
|
||
return ctx.down_page(request, user)
|
||
except ApiError as e:
|
||
return RedirectResponse("/t/groups?err=" + quote(e.detail), status_code=303)
|
||
finally:
|
||
c.close()
|
||
return RedirectResponse(f"/t/groups/{r['id']}/?msg=" + quote(f"Группа {name} создана"), status_code=303)
|
||
|
||
|
||
@router.post("/groups/import")
|
||
def import_group(request: Request, user=Depends(ctx.need_teacher),
|
||
file: Annotated[UploadFile | None, File()] = None):
|
||
if not file or not file.filename:
|
||
return RedirectResponse("/t/groups?err=Выберите%20файл%20со%20списком%20группы", status_code=303)
|
||
c = ctx.api_client(request)
|
||
try:
|
||
r = c.upload("/groups/import", file.filename, file.file.read())
|
||
except ApiDown:
|
||
return ctx.down_page(request, user)
|
||
except ApiError as e:
|
||
return RedirectResponse("/t/groups/?err=" + quote(e.detail), status_code=303)
|
||
finally:
|
||
c.close()
|
||
msg = f"Группа {r['group']}: добавлено {r['added']}, обновлено {r['updated']}. Пароли — в выгрузке «Логины и пароли»"
|
||
return RedirectResponse(f"/t/groups/{r['gid']}/?msg=" + quote(msg), status_code=303)
|
||
|
||
|
||
@router.get("/groups/{gid}")
|
||
def group_page(request: Request, gid: int, lab: Annotated[str, Query()] = "0",
|
||
user=Depends(ctx.need_teacher)):
|
||
c = ctx.api_client(request)
|
||
try:
|
||
data = c.get(f"/groups/{gid}")
|
||
except ApiDown:
|
||
return ctx.down_page(request, user)
|
||
except ApiError as e:
|
||
return _page_or_error(request, "t_group.html", user, e)
|
||
finally:
|
||
c.close()
|
||
labs_list = data.get("labs", [])
|
||
try:
|
||
lab_id = int(lab)
|
||
except ValueError:
|
||
lab_id = 0
|
||
if lab_id and not any(l["id"] == lab_id for l in labs_list):
|
||
lab_id = 0
|
||
unassigned = None
|
||
if lab_id:
|
||
unassigned = sum(1 for s in data["students"]
|
||
if not (s.get("lab_numbers") or {}).get(lab_id, {}).get("number"))
|
||
return ctx.render_page(request, "t_group.html", user=user, group=data["group"],
|
||
students=data["students"], labs=labs_list, lab_id=lab_id,
|
||
unassigned=unassigned,
|
||
levels={"main": "Основная группа", "advanced": "Повышенная сложность"})
|
||
|
||
|
||
@router.post("/groups/{gid}/levels")
|
||
def set_level(request: Request, gid: int, student_id: Annotated[str, Form()],
|
||
level: Annotated[str, Form()], lab_id: Annotated[str, Form()] = "0",
|
||
user=Depends(ctx.need_teacher)):
|
||
c = ctx.api_client(request)
|
||
try:
|
||
c.post_json(f"/groups/{gid}/levels", {"student_id": student_id, "level": level})
|
||
except ApiDown:
|
||
return ctx.down_page(request, user)
|
||
except ApiError as e:
|
||
return RedirectResponse(f"/t/groups/{gid}/?err=" + quote(e.detail), status_code=303)
|
||
finally:
|
||
c.close()
|
||
q = f"?lab={lab_id}&" if lab_id and lab_id != "0" else "?"
|
||
return RedirectResponse(f"/t/groups/{gid}/{q}msg=" + quote("Уровень обновлён"), status_code=303)
|
||
|
||
|
||
@router.post("/groups/{gid}/assign")
|
||
def assign(request: Request, gid: int, lab_id: Annotated[str, Form()] = "0",
|
||
user=Depends(ctx.need_teacher)):
|
||
c = ctx.api_client(request)
|
||
try:
|
||
r = c.post_json(f"/groups/{gid}/assign", {"lab_id": int(lab_id or 0)})
|
||
except ApiDown:
|
||
return ctx.down_page(request, user)
|
||
except ApiError as e:
|
||
return RedirectResponse(f"/t/groups/{gid}/?err=" + quote(e.detail), status_code=303)
|
||
finally:
|
||
c.close()
|
||
return RedirectResponse(f"/t/groups/{gid}/?lab={lab_id or 0}&msg=" + quote(
|
||
f"Варианты распределены: {r.get('assigned', 0)}"), status_code=303)
|
||
|
||
|
||
@router.post("/groups/{gid}/manual")
|
||
def set_manual(request: Request, gid: int, student_id: Annotated[str, Form()],
|
||
lab_id: Annotated[str, Form()] = "0", number: Annotated[str, Form()] = "",
|
||
user=Depends(ctx.need_teacher)):
|
||
c = ctx.api_client(request)
|
||
try:
|
||
r = c.post_json(f"/groups/{gid}/manual",
|
||
{"student_id": student_id, "lab_id": int(lab_id or 0), "number": number})
|
||
except ApiDown:
|
||
return ctx.down_page(request, user)
|
||
except ApiError as e:
|
||
return RedirectResponse(f"/t/groups/{gid}/?lab={lab_id or 0}&err=" + quote(e.detail), status_code=303)
|
||
finally:
|
||
c.close()
|
||
msg = "Ручное назначение снято" if r.get("cleared") else (
|
||
f"Вариант назначен вручную: {r.get('number') or number}")
|
||
q = f"?lab={lab_id}&" if lab_id and lab_id != "0" else "?"
|
||
return RedirectResponse(f"/t/groups/{gid}/{q}msg=" + quote(msg), status_code=303)
|
||
|
||
|
||
@router.post("/students/{sid}/reset-password")
|
||
def reset_password(request: Request, sid: str, user=Depends(ctx.need_teacher)):
|
||
c = ctx.api_client(request)
|
||
try:
|
||
r = c.post_json(f"/students/{sid}/reset-password", {})
|
||
except ApiDown:
|
||
return ctx.down_page(request, user)
|
||
except ApiError as e:
|
||
return RedirectResponse("/t/groups?err=" + quote(e.detail), status_code=303)
|
||
finally:
|
||
c.close()
|
||
msg = f"Новый пароль для студента №{sid}: {r['password']}. Студент сменит его при входе."
|
||
return RedirectResponse("/t/groups/?msg=" + quote(msg), status_code=303)
|
||
|
||
|
||
@router.get("/groups/{gid}/credentials.xlsx")
|
||
def credentials_xlsx(request: Request, gid: int, user=Depends(ctx.need_teacher)):
|
||
return _credentials(request, gid, user, "xlsx")
|
||
|
||
|
||
@router.get("/groups/{gid}/credentials.csv")
|
||
def credentials_csv(request: Request, gid: int, user=Depends(ctx.need_teacher)):
|
||
return _credentials(request, gid, user, "csv")
|
||
|
||
|
||
def _credentials(request, gid, user, fmt):
|
||
from fastapi.responses import Response
|
||
|
||
c = ctx.api_client(request)
|
||
try:
|
||
data, name = c.download(f"/groups/{gid}/credentials", {"fmt": fmt})
|
||
except (ApiDown, ApiError) as e:
|
||
return _page_or_error(request, "t_group.html", user, e)
|
||
finally:
|
||
c.close()
|
||
media = "text/csv; charset=utf-8" if fmt == "csv" else (
|
||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||
return Response(content=data, media_type=media,
|
||
headers={"content-disposition": f"attachment; filename*=UTF-8''{quote(name or 'file')}"})
|