Распределение вариантов

This commit is contained in:
2026-09-10 01:03:12 +03:00
commit bbbff6180f
50 changed files with 3537 additions and 0 deletions

View File

@@ -0,0 +1,322 @@
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')}"})