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

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,32 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .core.db import init_db
from .routers import auth, groups, labs, me
VERSION = "1.0"
def create_app() -> FastAPI:
init_db()
app = FastAPI(title="lms-api", version=VERSION)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api/v1")
app.include_router(groups.router, prefix="/api/v1")
app.include_router(labs.router, prefix="/api/v1")
app.include_router(me.router, prefix="/api/v1")
@app.get("/healthz")
def healthz():
return {"ok": True, "service": "lms-api", "version": VERSION}
@app.get("/api/v1")
def root():
return {"service": "lms-api", "version": VERSION, "docs": "/docs"}
return app

View File

View File

@@ -0,0 +1,188 @@
import os
import sqlite3
from datetime import datetime
from pathlib import Path
DATA_DIR = Path(os.environ.get("LMS_DATA_DIR", str(Path(__file__).resolve().parents[4] / "data")))
UPLOADS_DIR = DATA_DIR / "uploads"
DB_PATH = DATA_DIR / "lms.db"
SCHEMA = """
CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS teacher(
id INTEGER PRIMARY KEY AUTOINCREMENT,
login TEXT NOT NULL UNIQUE,
pass_hash TEXT NOT NULL,
salt TEXT NOT NULL,
iterations INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS groups(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
task_duration_min INTEGER NOT NULL DEFAULT 30
);
CREATE TABLE IF NOT EXISTS students(
id_student TEXT PRIMARY KEY,
surname TEXT NOT NULL DEFAULT '',
first_name TEXT NOT NULL DEFAULT '',
middle_name TEXT NOT NULL DEFAULT '',
basis TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
pass_hash TEXT NOT NULL,
salt TEXT NOT NULL,
iterations INTEGER NOT NULL,
must_change INTEGER NOT NULL DEFAULT 1,
initial_password TEXT
);
CREATE TABLE IF NOT EXISTS group_students(
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
student_id TEXT NOT NULL REFERENCES students(id_student) ON DELETE CASCADE,
enrolled_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now')),
PRIMARY KEY(group_id, student_id)
);
CREATE TABLE IF NOT EXISTS student_assignment(
student_id TEXT PRIMARY KEY REFERENCES students(id_student) ON DELETE CASCADE,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
level TEXT NOT NULL DEFAULT 'main',
manual INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS lab_assignments(
student_id TEXT NOT NULL REFERENCES students(id_student) ON DELETE CASCADE,
lab_id INTEGER NOT NULL REFERENCES labs(id) ON DELETE CASCADE,
number TEXT,
manual INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(student_id, lab_id)
);
CREATE TABLE IF NOT EXISTS labs(
id INTEGER PRIMARY KEY AUTOINCREMENT,
number INTEGER NOT NULL,
title TEXT NOT NULL,
semester TEXT NOT NULL DEFAULT 'осень',
deadline TEXT,
points_max REAL NOT NULL DEFAULT 5,
goal TEXT,
common_requirements TEXT,
code_requirements TEXT,
grading_criteria TEXT,
submission_instructions TEXT,
UNIQUE(number, semester)
);
CREATE TABLE IF NOT EXISTS lab_variants(
id INTEGER PRIMARY KEY AUTOINCREMENT,
lab_id INTEGER NOT NULL REFERENCES labs(id) ON DELETE CASCADE,
level TEXT NOT NULL,
number TEXT NOT NULL,
statement TEXT NOT NULL,
examples TEXT NOT NULL DEFAULT '[]',
notes TEXT,
UNIQUE(lab_id, level, number)
);
CREATE TABLE IF NOT EXISTS submissions(
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_id TEXT NOT NULL REFERENCES students(id_student) ON DELETE CASCADE,
lab_id INTEGER NOT NULL REFERENCES labs(id) ON DELETE CASCADE,
seq INTEGER NOT NULL,
content TEXT NOT NULL DEFAULT '',
submitted_at TEXT NOT NULL,
UNIQUE(student_id, lab_id, seq)
);
CREATE TABLE IF NOT EXISTS submission_files(
id INTEGER PRIMARY KEY AUTOINCREMENT,
submission_id INTEGER NOT NULL REFERENCES submissions(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
stored_path TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS tasks(
id INTEGER PRIMARY KEY AUTOINCREMENT,
lab_id INTEGER NOT NULL REFERENCES labs(id) ON DELETE CASCADE,
kind TEXT NOT NULL DEFAULT 'makeup',
code TEXT NOT NULL DEFAULT '',
points REAL NOT NULL DEFAULT 0,
duration_min INTEGER,
statement TEXT NOT NULL,
examples TEXT NOT NULL DEFAULT '[]',
solution TEXT,
notes TEXT
);
CREATE TABLE IF NOT EXISTS task_assignments(
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL,
lab_id INTEGER NOT NULL REFERENCES labs(id) ON DELETE CASCADE,
student_id TEXT NOT NULL REFERENCES students(id_student) ON DELETE CASCADE,
kind TEXT NOT NULL DEFAULT 'makeup',
code TEXT NOT NULL DEFAULT '',
points REAL NOT NULL DEFAULT 0,
duration_min INTEGER NOT NULL DEFAULT 30,
statement TEXT NOT NULL,
examples TEXT NOT NULL DEFAULT '[]',
comment TEXT,
issued_at TEXT NOT NULL,
visible_at TEXT,
confirmed_at TEXT,
content TEXT,
submitted_at TEXT,
points_awarded REAL,
teacher_comment TEXT
);
CREATE TABLE IF NOT EXISTS lab_grades(
student_id TEXT NOT NULL REFERENCES students(id_student) ON DELETE CASCADE,
lab_id INTEGER NOT NULL REFERENCES labs(id) ON DELETE CASCADE,
score REAL,
graded_at TEXT,
note TEXT,
PRIMARY KEY(student_id, lab_id)
);
CREATE TABLE IF NOT EXISTS task_files(
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_assignment_id INTEGER NOT NULL REFERENCES task_assignments(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
stored_path TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0
);
"""
def now_str() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def fmt_dt(s: str | None) -> str:
if not s:
return ""
try:
d = datetime.strptime(s[:19], "%Y-%m-%d %H:%M:%S")
return d.strftime("%d.%m.%Y %H:%M")
except ValueError:
return s
def init_db() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
db = sqlite3.connect(DB_PATH)
try:
db.executescript(SCHEMA)
db.commit()
finally:
db.close()
def get_db() -> sqlite3.Connection:
db = sqlite3.connect(DB_PATH)
db.row_factory = sqlite3.Row
db.execute("PRAGMA foreign_keys = ON")
return db
def setting(db: sqlite3.Connection, key: str, default: str | None = None) -> str | None:
row = db.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
return row["value"] if row else default
def set_setting(db: sqlite3.Connection, key: str, value: str) -> None:
db.execute(
"INSERT INTO settings(key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, value),
)

View File

@@ -0,0 +1,91 @@
from pydantic import BaseModel
class SetupIn(BaseModel):
login: str
password: str
password2: str
class LoginIn(BaseModel):
login: str
password: str
class ChangePwIn(BaseModel):
old: str
new: str
new2: str
class NameIn(BaseModel):
name: str
class AssignIn(BaseModel):
lab_id: int = 0
class LevelIn(BaseModel):
student_id: str
level: str
class ManualIn(BaseModel):
student_id: str
lab_id: int
number: str = ""
class DurationIn(BaseModel):
minutes: int
class ScoreIn(BaseModel):
score: str = ""
class IssueIn(BaseModel):
task_id: int
student_id: str
comment: str = ""
class PointsIn(BaseModel):
points: str = ""
comment: str = ""
class LabIn(BaseModel):
number: int
title: str
semester: str = "осень"
deadline: str = ""
points_max: float = 5
goal: str = ""
common_requirements: str = ""
code_requirements: str = ""
grading_criteria: str = ""
submission_instructions: str = ""
class TaskIn(BaseModel):
lab_number: str = ""
code: str = ""
kind: str = "makeup"
points: str = "1"
duration: str = ""
statement: str = ""
solution: str = ""
notes: str = ""
class IndividualIn(BaseModel):
student_id: str
statement: str = ""
comment: str = ""
duration: int = 30
class ConfirmIn(BaseModel):
confirm: str = ""

View File

@@ -0,0 +1,27 @@
import hashlib
import hmac
import secrets
ITERATIONS = 600_000
_ALPHABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"
def hash_password(password: str, salt: bytes, iterations: int) -> str:
return hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations).hex()
def make_credentials(password: str) -> tuple[str, str, int]:
salt = secrets.token_bytes(16)
return hash_password(password, salt, ITERATIONS), salt.hex(), ITERATIONS
def verify_password(password: str, salt_hex: str, iterations: int, stored_hash: str) -> bool:
try:
calc = hash_password(password, bytes.fromhex(salt_hex), iterations)
except (ValueError, TypeError):
return False
return hmac.compare_digest(calc, stored_hash)
def gen_password(length: int = 10) -> str:
return "".join(secrets.choice(_ALPHABET) for _ in range(length))

View File

@@ -0,0 +1,32 @@
import os
import secrets
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
TOKEN_TTL = 43200
def get_secret(db) -> str:
from .db import setting, set_setting
env_secret = os.environ.get("LMS_TOKEN_SECRET", "").strip()
if env_secret:
return env_secret
s = setting(db, "session_secret")
if not s:
s = secrets.token_hex(32)
set_setting(db, "session_secret", s)
return s
def make_token(secret: str, payload: dict) -> str:
return URLSafeTimedSerializer(secret).dumps(payload)
def read_token(secret: str, token: str | None, max_age: int = TOKEN_TTL) -> dict | None:
if not token:
return None
try:
return URLSafeTimedSerializer(secret).loads(token, max_age=max_age)
except (BadSignature, SignatureExpired):
return None

View File

@@ -0,0 +1,17 @@
import re
from datetime import datetime, timedelta
from urllib.parse import quote
def safe_name(s: str) -> str:
s = re.sub(r'[\\/:*?"<>|]+', "_", s).strip(" ._")
return s or "file"
def dt_iso(s: str, delta_min: int) -> str:
d = datetime.strptime(s[:19], "%Y-%m-%d %H:%M:%S")
return (d + timedelta(minutes=delta_min)).isoformat()
def disp(name: str) -> str:
return quote(name)

View File

@@ -0,0 +1,196 @@
"""Пулы вариантов и распределение.
Модель: у каждой лабораторной свой набор вариантов (загружен в её JSON,
раздельно по уровням). Студенту в группе назначается уровень
(student_assignment.level), а номер варианта — отдельно для каждой
лаборараторной (lab_assignments): уникален внутри группы, по возможности —
между группами; если вариантов меньше, чем студентов, повторение допускается.
"""
from collections import defaultdict
import re
import uuid
from . import db as dbmod
LEVELS = ("main", "advanced")
LEVEL_NAMES = {"main": "Основная группа", "advanced": "Повышенная сложность"}
def _num_key(number: str):
s = str(number).strip()
if s.isdigit():
return int(s)
m = s.lstrip("ПП").strip() # совместимость со старым форматом «П3»
return int(m) if m.isdigit() else 10**9
def lab_pool(db, lab_id: int, level: str) -> list[str]:
"""Номера вариантов данной лабы уровня, в порядке возрастания."""
rows = db.execute(
"SELECT number FROM lab_variants WHERE lab_id = ? AND level = ?", (lab_id, level)
).fetchall()
seen: dict[str, None] = {}
for r in rows:
n = str(r["number"]).strip()
if n and n not in seen:
seen[n] = None
return sorted(seen.keys(), key=_num_key)
def lab_ids_with_variants(db) -> list[int]:
rows = db.execute("SELECT DISTINCT lab_id FROM lab_variants ORDER BY lab_id").fetchall()
return [r["lab_id"] for r in rows]
def _all_groups_ids(db) -> list[int]:
return [r["id"] for r in db.execute("SELECT id FROM groups ORDER BY id").fetchall()]
def ensure_assignment(db, student_id: str, group_id: int, level: str | None = None) -> None:
"""Строка уровня студента (создаётся при импорте списка)."""
if level is None:
db.execute(
"INSERT INTO student_assignment(student_id, group_id) VALUES (?, ?) "
"ON CONFLICT(student_id) DO UPDATE SET group_id = excluded.group_id",
(student_id, group_id),
)
else:
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",
(student_id, group_id, level),
)
def _student_row_key(sid_str: str):
s = str(sid_str)
return (0, int(s)) if s.isdigit() else (1, s)
def distribute_lab(db, lab_id: int) -> int:
"""Распределяет варианты одной лабораторной по всем группам.
Ручные номера (manual=1) не трогаются. Возвращает количество назначений.
"""
group_ids = _all_groups_ids(db)
assigned = 0
for level in LEVELS:
pool = lab_pool(db, lab_id, level)
if not pool:
continue
rows = db.execute(
"""SELECT sa.student_id, sa.group_id, la.number, la.manual
FROM student_assignment sa
LEFT JOIN lab_assignments la
ON la.student_id = sa.student_id AND la.lab_id = ?
WHERE sa.level = ?""",
(lab_id, level),
).fetchall()
by_group: dict[int, list] = defaultdict(list)
for r in rows:
by_group[r["group_id"]].append(dict(r))
for g in group_ids:
by_group[g].sort(key=lambda x: _student_row_key(x["student_id"]))
global_used = {r["number"] for r in rows if r["number"]}
group_used: dict[int, set] = {
g: {x["number"] for x in by_group.get(g, []) if x["number"]} for g in group_ids
}
need = [(g, x) for g in group_ids for x in by_group.get(g, [])
if not x["number"] and not x["manual"]]
while need:
for i in range(len(need)):
gid, x = need[i]
cands = [n for n in pool if n not in group_used[gid]]
if not cands:
cands = list(pool) # группа больше пула — повторение допустимо
pick = next((n for n in cands if n not in global_used), cands[0])
db.execute(
"""INSERT INTO lab_assignments(student_id, lab_id, number, manual)
VALUES (?, ?, ?, 0)
ON CONFLICT(student_id, lab_id) DO UPDATE SET number = excluded.number""",
(x["student_id"], lab_id, pick),
)
group_used[gid].add(pick)
global_used.add(pick)
need.pop(i)
assigned += 1
break # round-robin: по одному студенту на группу за прогон
return assigned
def distribute_labs(db, lab_ids: list[int]) -> int:
return sum(distribute_lab(db, lid) for lid in lab_ids)
def validate_number(db, lab_id: int, level: str, s: str) -> str:
"""Проверяет номер для ручной выдачи, возвращает каноническую запись."""
s = str(s).strip()
pool = lab_pool(db, lab_id, level)
for n in pool:
if n == s or (s.isdigit() and n.isdigit() and int(n) == int(s)):
return n
valid = ", ".join(pool) if pool else "—"
raise ValueError(f"В этой лабораторной для выбранного уровня нет номера «{s}». Доступно: {valid}")
def on_student_level_changed(db, student_id: str) -> None:
"""Смена уровня: снимает автомато-номера в лабах, где их больше нет в пуле."""
level_row = db.execute(
"SELECT level FROM student_assignment WHERE student_id = ?", (student_id,)
).fetchone()
if not level_row:
return
for lid in lab_ids_with_variants(db):
row = db.execute(
"SELECT number FROM lab_assignments WHERE student_id = ? AND lab_id = ? AND manual = 0",
(student_id, lid),
).fetchone()
if not row or not row["number"]:
continue
if row["number"] not in lab_pool(db, lid, level_row["level"]):
db.execute(
"UPDATE lab_assignments SET number = NULL WHERE student_id = ? AND lab_id = ?",
(student_id, lid),
)
def on_lab_variants_reloaded(db, lab_id: int) -> int:
"""После повторной загрузки JSON лабы: убирает автомато-номера, которых нет в новых пулах."""
cleared = 0
rows = db.execute(
"""SELECT la.student_id, sa.level, la.number
FROM lab_assignments la
JOIN student_assignment sa ON sa.student_id = la.student_id
WHERE la.lab_id = ? AND la.manual = 0 AND la.number IS NOT NULL""",
(lab_id,),
).fetchall()
pools = {lvl: set(lab_pool(db, lab_id, lvl)) for lvl in LEVELS}
for r in rows:
if r["number"] not in pools.get(r["level"], set()):
db.execute(
"UPDATE lab_assignments SET number = NULL WHERE student_id = ? AND lab_id = ?",
(r["student_id"], lab_id),
)
cleared += 1
return cleared
def student_labs_view(db, student_id: str) -> dict[int, dict]:
"""{lab_id: {"number": str|None, "manual": bool}} по всем лабам студента."""
rows = db.execute(
"SELECT lab_id, number, manual FROM lab_assignments WHERE student_id = ?", (student_id,)
).fetchall()
return {r["lab_id"]: {"number": r["number"], "manual": bool(r["manual"])} for r in rows}
def save_upload(f, prefix: str) -> tuple[str, int]:
"""Сохраняет UploadFile на диск, возвращает (имя файла, размер)."""
name = re.sub(r'[\\/:*?"<>|]', "_", f.filename or "file").strip() or "file"
name = uuid.uuid4().hex[:8] + "_" + name
dest = dbmod.UPLOADS_DIR / name
dest.parent.mkdir(parents=True, exist_ok=True)
data = f.file.read()
dest.write_bytes(data)
return name, len(data)

View File

@@ -0,0 +1,90 @@
import re
import xlrd
def _norm(v) -> str:
return str(v).strip().lower().replace("\xa0", " ")
ALIASES = {
"student_id": ("студенч", "студ.бил", "студбил"),
"surname": ("фамили",),
"first_name": ("имя",),
"middle_name": ("отчеств",),
"basis": ("основ",),
"note": ("примеч",),
}
def find_header(sh) -> tuple[int, list[str]]:
for r in range(min(25, sh.nrows)):
rowvals = [_norm(sh.cell_value(r, c)) for c in range(sh.ncols)]
if any("студен" in v for v in rowvals):
return r, rowvals
raise ValueError("Таблица не распознана: не найден столбец «Студенческий номер»")
def group_name_from_sheet(sh, fallback: str) -> str:
from pathlib import Path
for r in range(min(12, sh.nrows)):
for c in range(sh.ncols):
v = sh.cell_value(r, c)
if isinstance(v, str):
s = v.strip()
if 2 <= len(s) <= 30 and re.fullmatch(r"[А-ЯA-Z][А-ЯA-Za-z]*[- ]?\d{1,4}[А-ЯA-Za-z]*", s):
return s
return Path(fallback).stem
def _sid_str(v) -> str:
if isinstance(v, float) and v.is_integer():
return str(int(v))
if isinstance(v, int):
return str(v)
return str(v).strip()
def parse_group_file(path) -> dict:
wb = xlrd.open_workbook(str(path))
sh = wb.sheet_by_index(0)
hrow, headers = find_header(sh)
colmap: dict[str, int] = {}
for c, h in enumerate(headers):
for key, prefixes in ALIASES.items():
if key in colmap:
continue
if h.startswith(prefixes):
colmap[key] = c
if "student_id" not in colmap:
raise ValueError("Не найден столбец «Студенческий номер»")
students = []
for r in range(hrow + 1, sh.nrows):
def cell(key):
c = colmap.get(key)
if c is None or c >= sh.ncols:
return ""
v = sh.cell_value(r, c)
return "" if v is None else v
sid = _sid_str(cell("student_id"))
surname = str(cell("surname")).strip()
if not sid and not surname:
continue
if not sid:
raise ValueError(f"Строка {r + 1}: отсутствует студенческий номер")
students.append(
{
"id_student": sid,
"surname": surname,
"first_name": str(cell("first_name")).strip(),
"middle_name": str(cell("middle_name")).strip(),
"basis": str(cell("basis")).strip(),
"note": str(cell("note")).strip(),
}
)
if not students:
raise ValueError("В таблице не найдено ни одного студента")
return {"group": group_name_from_sheet(sh, str(path)), "students": students}

View File

@@ -0,0 +1,64 @@
import io
import zipfile
from . import db as dbmod
from .util import safe_name
def _student_folder(st) -> str:
d = dict(st)
return safe_name(f"{d['surname']} {d['first_name']} {d['middle_name']} (#{d['id_student']})")
def _students(db, group_id: int | None, student_id: str | None):
if student_id:
return db.execute(
"SELECT s.id_student, s.surname, s.first_name, s.middle_name FROM students s "
"WHERE s.id_student = ?",
(student_id,),
).fetchall()
if group_id:
return db.execute(
"""SELECT s.id_student, s.surname, s.first_name, s.middle_name
FROM group_students gs JOIN students s ON s.id_student = gs.student_id
WHERE gs.group_id = ? ORDER BY s.surname""",
(group_id,),
).fetchall()
return db.execute(
"SELECT id_student, surname, first_name, middle_name FROM students ORDER BY surname"
).fetchall()
def build_labs_zip(db, lab, group_id: int | None = None, student_id: str | None = None) -> tuple[bytes, str]:
students = _students(db, group_id, student_id)
buf = io.BytesIO()
count = 0
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for st in students:
subs = db.execute(
"SELECT * FROM submissions WHERE student_id = ? AND lab_id = ? ORDER BY seq",
(st["id_student"], lab["id"]),
).fetchall()
if not subs:
continue
count += 1
folder = _student_folder(st)
for sub in subs:
stamp = (sub["submitted_at"][:16] or "").replace(" ", "_").replace(":", "-")
sdir = f"{folder}/{stamp}_отправка_{sub['seq']}"
if (sub["content"] or "").strip():
z.writestr(f"{sdir}/код.txt", sub["content"])
for f in db.execute(
"SELECT * FROM submission_files WHERE submission_id = ? ORDER BY id", (sub["id"],)
).fetchall():
z.write(dbmod.UPLOADS_DIR / f["stored_path"], f"{sdir}/{safe_name(f['filename'])}")
if count == 0:
z.writestr("_info.txt", "Отправок пока нет.")
if student_id:
fn = f"ЛР{lab['number']}_работы_№{safe_name(student_id)}.zip"
elif group_id:
g = db.execute("SELECT name FROM groups WHERE id = ?", (group_id,)).fetchone()
fn = f"ЛР{lab['number']}_{safe_name(g['name'] if g else 'группа')}_работы.zip"
else:
fn = f"ЛР{lab['number']}_все_работы.zip"
return buf.getvalue(), fn

View File

@@ -0,0 +1,11 @@
import uvicorn
from . import create_app
app = create_app()
if __name__ == "__main__":
import os
uvicorn.run("lms_api.main:app", host=os.environ.get("LMS_API_HOST", "127.0.0.1"),
port=int(os.environ.get("API_PORT", "8000")))

View File

@@ -0,0 +1,114 @@
from fastapi import APIRouter, Depends
from ..core import db as dbmod
from ..core.security import make_credentials, verify_password
from ..core.tokens import get_secret, make_token
from ..core.schemas import ChangePwIn, LoginIn, SetupIn
from .deps import load_token_db, require_user
router = APIRouter(prefix="/auth", tags=["auth"])
def _login_payload(user: dict, db) -> dict:
token = make_token(get_secret(db), {"role": user["role"], "id": user["id"]})
out = {k: user.get(k) for k in ("role", "id", "name", "login", "group", "must_change")}
out["token"] = token
return out
@router.get("/health")
def health(db=Depends(load_token_db)):
teacher = db.execute("SELECT 1 FROM teacher LIMIT 1").fetchone() is not None
return {"ok": True, "service": "lms-api", "version": "1.0", "teacher_exists": teacher}
@router.post("/setup")
def setup(body: SetupIn, db=Depends(load_token_db)):
login = (body.login or "").strip()
if len(login) < 3:
return {"error": "Логин: минимум 3 символа"}
if len(body.password or "") < 8:
return {"error": "Пароль: минимум 8 символов"}
if body.password != body.password2:
return {"error": "Пароли не совпадают"}
ph, salt, it = make_credentials(body.password)
try:
cur = db.execute(
"INSERT INTO teacher(login, pass_hash, salt, iterations) VALUES (?, ?, ?, ?)",
(login, ph, salt, it),
)
except dbmod.sqlite3.IntegrityError:
return {"error": "Аккаунт преподавателя уже существует"}
row = db.execute("SELECT * FROM teacher WHERE id = ?", (cur.lastrowid,)).fetchone()
return _login_payload(
{
"role": "teacher",
"id": row["id"],
"name": row["login"],
"login": row["login"],
"group": "",
"must_change": False,
},
db,
)
@router.post("/login")
def login(body: LoginIn, db=Depends(load_token_db)):
login = (body.login or "").strip()
password = body.password
t = db.execute("SELECT * FROM teacher WHERE login = ?", (login,)).fetchone()
if t and verify_password(password, t["salt"], t["iterations"], t["pass_hash"]):
return _login_payload(
{
"role": "teacher",
"id": t["id"],
"name": t["login"],
"login": t["login"],
"group": "",
"must_change": False,
},
db,
)
s = db.execute("SELECT * FROM students WHERE id_student = ?", (login,)).fetchone()
if s and verify_password(password, s["salt"], s["iterations"], s["pass_hash"]):
return _login_payload(
{
"role": "student",
"id": s["id_student"],
"name": " ".join(x for x in (s["surname"], s["first_name"], s["middle_name"]) if x),
"login": s["id_student"],
"group": "",
"must_change": bool(s["must_change"]),
},
db,
)
return {"error": "Неверный логин или пароль"}
@router.get("/me")
def me(user=Depends(require_user), db=Depends(load_token_db)):
return user
@router.post("/change-password")
def change_password(body: ChangePwIn, user=Depends(require_user), db=Depends(load_token_db)):
old, new, new2 = body.old, body.new, body.new2
if len(new or "") < 8:
return {"error": "Новый пароль: минимум 8 символов"}
if new != new2:
return {"error": "Новые пароли не совпадают"}
if user["role"] == "teacher":
row = db.execute("SELECT * FROM teacher WHERE id = ?", (user["id"],)).fetchone()
if not row or not verify_password(old, row["salt"], row["iterations"], row["pass_hash"]):
return {"error": "Старый пароль неверный"}
ph, salt, it = make_credentials(new)
db.execute("UPDATE teacher SET pass_hash=?, salt=?, iterations=? WHERE id=?", (ph, salt, it, user["id"]))
else:
row = db.execute("SELECT * FROM students WHERE id_student = ?", (user["id"],)).fetchone()
if not row or not verify_password(old, row["salt"], row["iterations"], row["pass_hash"]):
return {"error": "Старый пароль неверный"}
ph, salt, it = make_credentials(new)
db.execute(
"UPDATE students SET pass_hash=?, salt=?, iterations=?, must_change=0 WHERE id_student=?",
(ph, salt, it, user["id"]),
)
return {"ok": True}

View File

@@ -0,0 +1,82 @@
from fastapi import Depends, HTTPException, Request
from ..core import db as dbmod
from ..core.tokens import get_secret, read_token
def load_token_db():
db = dbmod.get_db()
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
TokenDb = Depends(load_token_db)
def _extract_token(request: Request) -> str | None:
auth = request.headers.get("authorization", "")
if auth.startswith("Bearer "):
return auth[len("Bearer "):].strip()
return request.cookies.get("lms_session")
def require_user(request: Request, db=Depends(load_token_db)) -> dict:
payload = read_token(get_secret(db), _extract_token(request))
if not payload:
raise HTTPException(status_code=401, detail="Требуется вход")
if payload.get("role") == "teacher":
row = db.execute("SELECT * FROM teacher WHERE id = ?", (payload.get("id"),)).fetchone()
if not row:
raise HTTPException(status_code=401, detail="Пользователь не найден")
return {
"role": "teacher",
"id": row["id"],
"name": row["login"],
"login": row["login"],
"surname": row["login"],
"first_name": "",
"middle_name": "",
"group": "",
"must_change": False,
}
row = db.execute(
"""SELECT s.*, g.name AS group_name FROM students s
LEFT JOIN group_students gs ON gs.student_id = s.id_student
LEFT JOIN groups g ON g.id = gs.group_id
WHERE s.id_student = ?""",
(str(payload.get("id")),),
).fetchone()
if not row:
raise HTTPException(status_code=401, detail="Пользователь не найден")
return {
"role": "student",
"id": row["id_student"],
"surname": row["surname"],
"first_name": row["first_name"],
"middle_name": row["middle_name"],
"name": " ".join(x for x in (row["surname"], row["first_name"], row["middle_name"]) if x),
"group": row["group_name"] or "",
"must_change": bool(row["must_change"]),
}
def require_teacher(request: Request, user=Depends(require_user)) -> dict:
if user["role"] != "teacher":
raise HTTPException(status_code=403, detail="Только преподаватель")
return user
def require_student(request: Request, user=Depends(require_user)) -> dict:
if user["role"] != "student":
raise HTTPException(status_code=403, detail="Студент только для студенских операций")
if user["must_change"] and request.url.path != "/api/v1/auth/change-password":
raise HTTPException(status_code=403, detail="Необходима смена пароля при первом входе")
return user
def err(status: int, msg: str) -> None:
from fastapi import HTTPException
raise HTTPException(status_code=status, detail=msg)

View File

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

View File

@@ -0,0 +1,192 @@
from datetime import datetime, timedelta
import json
from typing import Annotated
from urllib.parse import quote
from fastapi import APIRouter, Depends, File, UploadFile
from fastapi.responses import Response
from ..core import db as dbmod
from ..core.schemas import LabIn
from ..core.variants import LEVEL_NAMES, on_lab_variants_reloaded
from .deps import err, load_token_db, require_teacher
router = APIRouter(tags=["teacher"])
def _lab_updates_from(raw: dict) -> dict:
up = {}
if raw.get("number") is not None:
up["number"] = int(raw["number"])
if raw.get("title") is not None:
up["title"] = str(raw["title"]).strip()
if raw.get("semester") is not None:
up["semester"] = str(raw["semester"]).strip() or "осень"
if raw.get("deadline"):
dl = str(raw["deadline"]).strip()
for f in ("%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
try:
from datetime import datetime
up["deadline"] = datetime.strptime(dl, f).strftime("%Y-%m-%d %H:%M:%S")
break
except ValueError:
continue
else:
err(400, f"Неверный формат срока: {dl}")
if raw.get("points_max") is not None:
up["points_max"] = float(raw["points_max"])
return up
def _parse_variants(raw: dict) -> list[dict]:
if not isinstance(raw, dict):
err(400, "JSON-файл должен содержать объект")
variants = raw.get("variants")
if not isinstance(variants, list) or not variants:
err(400, "В JSON должен быть непустой массив «variants»")
out = []
for v in variants:
if not isinstance(v, dict):
err(400, "Каждый вариант должен быть объектом")
level = str(v.get("level", "main")).strip().lower()
if level in ("advanced", "повышенная", "повышенной", "hard"):
level = "advanced"
elif level in ("main", "основная", "основной", "обычная", "обычный"):
level = "main"
else:
err(400, f"Неизвестный уровень варианта: {v.get('level')}")
number = str(v.get("number", "")).strip()
if not number:
err(400, "У варианта должен быть номер")
statement = str(v.get("statement") or v.get("condition") or "").strip()
if not statement:
err(400, f"Вариант {number}: не указано условие")
examples = v.get("examples") or []
if not isinstance(examples, list):
err(400, f"Вариант {number}: «examples» должен быть массивом")
out.append(
{
"level": level,
"number": number,
"statement": statement,
"examples": json.dumps(examples, ensure_ascii=False),
"notes": str(v.get("notes")).strip() if v.get("notes") else None,
}
)
return out
def _apply_variants(db, lid: int, raw: dict, fields: bool) -> list[dict]:
out = _parse_variants(raw)
db.execute("DELETE FROM lab_variants WHERE lab_id = ?", (lid,))
for v in out:
db.execute(
"INSERT INTO lab_variants(lab_id, level, number, statement, examples, notes) VALUES (?,?,?,?,?,?)",
(lid, v["level"], v["number"], v["statement"], v["examples"], v["notes"]),
)
if fields:
up = _lab_updates_from(raw)
if up:
sets = ", ".join(f"{k} = ?" for k in up)
db.execute(f"UPDATE labs SET {sets} WHERE id = ?", (*up.values(), lid))
on_lab_variants_reloaded(db, lid)
return out
@router.post("/labs/parse-json")
def parse_json(file: Annotated[UploadFile, File()], db=Depends(load_token_db), user=Depends(require_teacher)):
try:
raw = json.loads(file.file.read().decode("utf-8"))
except (ValueError, UnicodeDecodeError) as e:
err(400, "Ошибка разбора JSON: " + str(e))
variants = _parse_variants(raw)
return {"lab": _lab_updates_from(raw), "variants": variants}
@router.get("/labs")
def labs_list(db=Depends(load_token_db), user=Depends(require_teacher)):
rows = db.execute(
"""SELECT l.*,
(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, l.id"""
).fetchall()
return {"labs": [dict(r) for r in rows]}
@router.post("/labs")
def lab_create(body: LabIn, db=Depends(load_token_db), user=Depends(require_teacher)):
title = (body.title or "").strip()
if not title:
err(400, "Укажите название работы")
try:
cur = db.execute(
"""INSERT INTO labs(number, title, semester) VALUES (?, ?, ?)""",
(int(body.number), title, (body.semester or "осень").strip() or "осень"),
)
except dbmod.sqlite3.IntegrityError:
err(400, "Такая лабораторная уже существует (номер + семестр)")
lid = cur.lastrowid
if body.deadline and str(body.deadline).strip():
deadline = str(body.deadline).strip()
else:
# срок по умолчанию: через 3 недели и 4 дня (25 дней) от создания
deadline = (datetime.now() + timedelta(days=25)).strftime("%Y-%m-%d %H:%M:%S")
db.execute("UPDATE labs SET deadline = ? WHERE id = ?", (deadline, lid))
if body.points_max:
db.execute("UPDATE labs SET points_max = ? WHERE id = ?", (float(body.points_max), lid))
return {"id": lid}
@router.get("/labs/{lid}")
def lab_detail(lid: int, db=Depends(load_token_db), user=Depends(require_teacher)):
lab = db.execute("SELECT * FROM labs WHERE id = ?", (lid,)).fetchone()
if not lab:
err(404, "Лабораторная не найдена")
lab = dict(lab)
variants = db.execute(
"""SELECT * FROM lab_variants WHERE lab_id = ? ORDER BY level DESC,
(CASE WHEN number GLOB '[0-9]*' THEN CAST(number AS INTEGER) ELSE -1 END), number""",
(lid,),
).fetchall()
vm = [dict(v) for v in variants if v["level"] == "main"]
va = [dict(v) for v in variants if v["level"] == "advanced"]
for v in vm + va:
v["examples"] = json.loads(v["examples"] or "[]")
return {"lab": lab, "variants_main": vm, "variants_advanced": va,
"levels": dict(LEVEL_NAMES)}
@router.post("/labs/{lid}/json")
def lab_json_upload(lid: int, file: Annotated[UploadFile, File()], db=Depends(load_token_db), user=Depends(require_teacher)):
if not db.execute("SELECT 1 FROM labs WHERE id = ?", (lid,)).fetchone():
err(404, "Лабораторная не найдена")
try:
raw = json.loads(file.file.read().decode("utf-8"))
except (ValueError, UnicodeDecodeError) as e:
err(400, "Ошибка разбора JSON: " + str(e))
out = _apply_variants(db, lid, raw, fields=True)
n_main = sum(1 for v in out if v["level"] == "main")
return {"ok": True, "count": len(out), "main": n_main, "advanced": len(out) - n_main}
@router.post("/labs/{lid}/content")
def lab_content_upload(lid: int, file: Annotated[UploadFile, File()], db=Depends(load_token_db), user=Depends(require_teacher)):
if not db.execute("SELECT 1 FROM labs WHERE id = ?", (lid,)).fetchone():
err(404, "Лабораторная не найдена")
try:
raw = json.loads(file.file.read().decode("utf-8"))
except (ValueError, UnicodeDecodeError) as e:
err(400, "Ошибка разбора JSON: " + str(e))
out = _apply_variants(db, lid, raw, fields=False)
n_main = sum(1 for v in out if v["level"] == "main")
return {"ok": True, "count": len(out), "main": n_main, "advanced": len(out) - n_main}
@router.delete("/labs/{lid}")
def lab_delete(lid: int, db=Depends(load_token_db), user=Depends(require_teacher)):
db.execute("DELETE FROM labs WHERE id = ?", (lid,))
return {"ok": True}

View File

@@ -0,0 +1,68 @@
import json
from typing import Annotated
from fastapi import APIRouter, Depends
from ..core import db as dbmod
from ..core.db import fmt_dt
from ..core.variants import LEVEL_NAMES
from .deps import load_token_db, require_student
router = APIRouter(prefix="/me", tags=["student"])
def _lab_view(db, user_id: str, lid: int) -> dict:
lab = db.execute("SELECT * FROM labs WHERE id = ?", (lid,)).fetchone()
if not lab:
from .deps import err
err(404, "Лабораторная не найдена")
lab = dict(lab)
lab["deadline_fmt"] = fmt_dt(lab.get("deadline"))
a = db.execute("SELECT level FROM student_assignment WHERE student_id = ?", (user_id,)).fetchone()
la = db.execute(
"SELECT number, manual FROM lab_assignments WHERE student_id = ? AND lab_id = ?",
(user_id, lid),
).fetchone()
level = a["level"] if a else "main"
lab["variant"] = None
lab["assigned"] = bool(la and la["number"])
if la and la["number"]:
v = db.execute(
"SELECT * FROM lab_variants WHERE lab_id = ? AND level = ? AND number = ?",
(lab["id"], level, la["number"]),
).fetchone()
if v:
v = dict(v)
v["examples"] = json.loads(v.get("examples") or "[]")
lab["variant"] = v
return lab
@router.get("/dashboard")
def dashboard(user=Depends(require_student), db=Depends(load_token_db)):
now = dbmod.now_str()
labs = db.execute(
"""SELECT l.*, sa.level,
(SELECT la.number FROM lab_assignments la
WHERE la.student_id = ? AND la.lab_id = l.id) AS variant_number
FROM labs l
LEFT JOIN student_assignment sa ON sa.student_id = ?
ORDER BY l.number, l.id""",
(user["id"], user["id"]),
).fetchall()
out = []
for lab in labs:
d = dict(lab)
d["deadline_passed"] = bool(d["deadline"] and now > d["deadline"])
d["variant_label"] = (
f"{LEVEL_NAMES[lab['level']]}, вариант {lab['variant_number']}"
if lab["variant_number"] else None
)
out.append(d)
return {"labs": out}
@router.get("/labs/{lid}")
def lab_page(lid: int, user=Depends(require_student), db=Depends(load_token_db)):
return _lab_view(db, user["id"], lid)

View File

@@ -0,0 +1,58 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader
from pathlib import Path
_env: Environment | None = None
def init_templates() -> Environment:
global _env
from .util import fmt_dt
from .util import deadline_view
_env = Environment(loader=FileSystemLoader(str(Path(__file__).parent / "templates")), autoescape=True)
_env.filters["dt"] = fmt_dt
_env.globals["deadline_view"] = deadline_view
return _env
def create_app() -> FastAPI:
init_templates()
from .routers import auth_pages, student_pages, teacher_pages
app = FastAPI(title="lms-web")
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
app.include_router(auth_pages.router)
app.include_router(teacher_pages.router)
app.include_router(student_pages.router)
@app.get("/healthz")
def healthz():
from .client import Client
try:
c = Client(timeout=3)
try:
return {"ok": True, "api": c.get("/auth/health").get("ok") is True}
finally:
c.close()
except Exception:
return {"ok": False, "api": False}
@app.get("/api-health")
def api_health():
from .client import Client
try:
c = Client(timeout=3)
try:
return c.get("/auth/health")
finally:
c.close()
except Exception as e:
return {"ok": False, "error": str(e)}
return app

View File

@@ -0,0 +1,105 @@
import os
import httpx
_api_url = os.environ.get("LMS_API_URL", "http://127.0.0.1:8000")
API_BASE = _api_url.rstrip("/") + "/api/v1"
COOKIE = "lms_session"
class ApiError(Exception):
def __init__(self, status: int, detail: str):
self.status = status
self.detail = detail
super().__init__(str(detail))
class ApiDown(Exception):
pass
class Client:
"""HTTP-клиент lms-api. Сессия передаётся токеном из cookie."""
def __init__(self, token: str | None = None, timeout: float = 60.0):
headers = {"Authorization": f"Bearer {token}"} if token else {}
self._c = httpx.Client(base_url=API_BASE, timeout=timeout, headers=headers)
def close(self):
self._c.close()
def _raise(self, r: httpx.Response) -> dict:
if r.status_code >= 400:
try:
detail = r.json().get("detail", "")
except ValueError:
detail = r.text
raise ApiError(r.status_code, str(detail or f"HTTP {r.status_code}"))
return r.json()
def get(self, path: str):
try:
r = self._c.get(path)
except httpx.HTTPError as e:
raise ApiDown(str(e)) from e
return self._raise(r)
def post_json(self, path: str, payload: dict):
try:
r = self._c.post(path, json=payload)
except httpx.HTTPError as e:
raise ApiDown(str(e)) from e
return self._raise(r)
def put_json(self, path: str, payload: dict):
try:
r = self._c.put(path, json=payload)
except httpx.HTTPError as e:
raise ApiDown(str(e)) from e
return self._raise(r)
def delete(self, path: str):
try:
r = self._c.delete(path)
except httpx.HTTPError as e:
raise ApiDown(str(e)) from e
return self._raise(r)
def upload(self, path: str, filename: str, data: bytes, content_type: str | None = None):
files = {"file": (filename, data, content_type)}
try:
r = self._c.post(path, files=files)
except httpx.HTTPError as e:
raise ApiDown(str(e)) from e
return self._raise(r)
def submit_work(self, path: str, content: str, confirm: str, files: list):
data = {"content": content, "confirm": confirm}
parts = [("files", (f.filename, f.file.read(), f.content_type)) for f in files if f.filename]
try:
r = self._c.post(path, data=data, files=parts)
except httpx.HTTPError as e:
raise ApiDown(str(e)) from e
return self._raise(r)
def download(self, path: str, params: dict | None = None) -> tuple[bytes, str]:
try:
r = self._c.get(path, params=params)
except httpx.HTTPError as e:
raise ApiDown(str(e)) from e
if r.status_code >= 400:
raise ApiError(r.status_code, "Ошибка загрузчика")
cd = r.headers.get("content-disposition", "")
name = ""
for part in cd.split(";"):
part = part.strip()
if part.startswith("filename*="):
name = part.split("UTF-8''", 1)[-1]
from urllib.parse import unquote
name = unquote(name)
return r.content, name
def token_of(request) -> str | None:
return request.cookies.get(COOKIE)

View File

@@ -0,0 +1,79 @@
from urllib.parse import quote
from fastapi import HTTPException, Request
from .client import Client, token_of
def api_client(request: Request) -> Client:
return Client(token=token_of(request))
def _redirect(location: str) -> None:
raise HTTPException(status_code=303, headers={"location": quote(location, safe="/:?=&%")})
def current_user(request: Request) -> dict | None:
"""Идентификация через lms-api /auth/me. Фронт не хранит и не проверяет токены."""
token = token_of(request)
if not token:
return None
c = Client(token=token, timeout=15)
try:
try:
r = c.get("/auth/me")
except Exception:
return None
if r.get("status") or "role" not in r:
return None
return r
finally:
c.close()
def need_user(request: Request) -> dict:
u = current_user(request)
if not u:
_redirect("/login?err=" + quote("Требуется вход", safe=""))
return u
def need_teacher(request: Request) -> dict:
u = need_user(request)
if u["role"] != "teacher":
_redirect("/s/")
return u
def need_student(request: Request) -> dict:
u = need_user(request)
if u["role"] != "student":
_redirect("/t/")
if u.get("must_change") and request.url.path != "/change-password":
_redirect("/change-password?msg=" + quote("Первый вход: обязательная смена пароля", safe=""))
return u
def render_page(request, name: str, **ctx):
from fastapi.responses import HTMLResponse
from . import _env
ctx.setdefault("user", None)
ctx.setdefault("flash", request.query_params.get("msg"))
ctx.setdefault("error", request.query_params.get("err"))
html = _env.get_template(name).render(**ctx)
return HTMLResponse(html)
def down_page(request, user, code: int = 503):
from fastapi.responses import HTMLResponse
msg = "Бэкенд lms-api недоступен. Запустите его: make dev (или docker compose up -d)."
return HTMLResponse(
f"""<!doctype html><html lang="ru"><head><meta charset="utf-8">
<link rel="stylesheet" href="/static/style.css"></head><body class="container" style="padding-top:40px">
<div class="error"><b>Сервис lms-web</b>: {msg}</div>
<p class="muted">Код: {code}</p></body></html>""",
status_code=code,
)

View File

@@ -0,0 +1,11 @@
import uvicorn
from . import create_app
app = create_app()
if __name__ == "__main__":
import os
uvicorn.run("lms_web.main:app", host=os.environ.get("LMS_WEB_HOST", "127.0.0.1"),
port=int(os.environ.get("FRONTEND_PORT", "8080")))

View File

View File

@@ -0,0 +1,119 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import RedirectResponse
from .. import ctx
from ..client import ApiDown, ApiError, Client, COOKIE
router = APIRouter()
@router.get("/")
def index(request: Request):
c = Client(timeout=10)
try:
health = c.get("/auth/health")
except (ApiDown, ApiError):
return ctx.down_page(request, None)
finally:
c.close()
user = ctx.current_user(request)
if user:
return RedirectResponse("/t/" if user["role"] == "teacher" else "/s/", status_code=303)
if health.get("teacher_exists"):
return RedirectResponse("/login", status_code=303)
return ctx.render_page(request, "setup.html")
@router.get("/login")
def login_page(request: Request):
c = Client(timeout=10)
try:
health = c.get("/auth/health")
except (ApiDown, ApiError):
return ctx.down_page(request, None)
finally:
c.close()
user = ctx.current_user(request)
if user:
return RedirectResponse("/t/" if user["role"] == "teacher" else "/s/", status_code=303)
if not health.get("teacher_exists"):
return RedirectResponse("/", status_code=303)
return ctx.render_page(request, "login.html")
@router.post("/setup")
def setup(request: Request, login: Annotated[str, Form()], password: Annotated[str, Form()],
password2: Annotated[str, Form()]):
c = Client()
try:
try:
r = c.post_json("/auth/setup", {"login": login, "password": password, "password2": password2})
except ApiDown:
return ctx.down_page(request, None)
finally:
c.close()
if r.get("error"):
return ctx.render_page(request, "setup.html", error=r["error"])
resp = RedirectResponse("/t/?msg=" + "Аккаунт преподавателя создан".replace(" ", "%20"), status_code=303)
resp.set_cookie(COOKIE, r["token"], max_age=43200, httponly=True, samesite="lax")
return resp
@router.post("/login")
def login(request: Request, login: Annotated[str, Form()], password: Annotated[str, Form()]):
c = Client()
try:
try:
r = c.post_json("/auth/login", {"login": login, "password": password})
except ApiDown:
return ctx.down_page(request, None)
finally:
c.close()
if r.get("error"):
return ctx.render_page(request, "login.html", error=r["error"])
if r["role"] == "student" and r.get("must_change"):
url = "/change-password?msg=" + "Первый вход: обязательно поменяйте пароль".replace(" ", "%20")
else:
url = ("/t/?msg=" if r["role"] == "teacher" else "/s/?msg=") + "Вход%20выполнен"
resp = RedirectResponse(url, status_code=303)
resp.set_cookie(COOKIE, r["token"], max_age=43200, httponly=True, samesite="lax")
return resp
@router.post("/logout")
def logout():
resp = RedirectResponse("/", status_code=303)
resp.delete_cookie(COOKIE)
return resp
@router.get("/change-password")
def change_password_page(request: Request):
user = ctx.current_user(request)
if not user:
return RedirectResponse("/login?err=Требуется%20вход", status_code=303)
return ctx.render_page(request, "change_password.html", user=user,
must_change=bool(user.get("must_change")))
@router.post("/change-password")
def change_password(request: Request, old: Annotated[str, Form()], new: Annotated[str, Form()],
new2: Annotated[str, Form()]):
user = ctx.current_user(request)
if not user:
return RedirectResponse("/login", status_code=303)
c = ctx.api_client(request)
try:
try:
r = c.post_json("/auth/change-password", {"old": old, "new": new, "new2": new2})
except ApiDown:
return ctx.down_page(request, user)
finally:
c.close()
if r.get("error"):
return ctx.render_page(request, "change_password.html", user=user,
must_change=bool(user.get("must_change")), error=r["error"])
dest = "/t/?msg=" if user["role"] == "teacher" else "/s/?msg="
return RedirectResponse(dest + "Пароль%20изменён", status_code=303)

View File

@@ -0,0 +1,42 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse
from .. import ctx
from ..client import ApiDown, ApiError
router = APIRouter(prefix="/s")
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_student)):
c = ctx.api_client(request)
try:
data = c.get("/me/dashboard")
except ApiDown:
return ctx.down_page(request, user)
except ApiError as e:
return _page_or_error(request, "s_dashboard.html", user, e)
finally:
c.close()
return ctx.render_page(request, "s_dashboard.html", user=user, labs=data["labs"])
@router.get("/labs/{lid}")
def lab_page(request: Request, lid: int, user=Depends(ctx.need_student)):
c = ctx.api_client(request)
try:
data = c.get(f"/me/labs/{lid}")
except ApiDown:
return ctx.down_page(request, user)
except ApiError as e:
return _page_or_error(request, "s_lab.html", user, e)
finally:
c.close()
return ctx.render_page(request, "s_lab.html", user=user, lab=data)

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

View File

@@ -0,0 +1,63 @@
document.addEventListener("click", (e) => {
const el = e.target.closest("[data-confirm]");
if (!el) return;
if (!window.confirm(el.dataset.confirm)) e.preventDefault();
});
document.querySelectorAll("select[data-autosubmit]").forEach((sel) => {
sel.addEventListener("change", () => {
const form = sel.closest("form");
if (form) form.submit();
});
});
document.querySelectorAll("select[data-nav]").forEach((sel) => {
sel.addEventListener("change", () => {
const base = sel.dataset.nav || location.pathname + "?lab=";
window.location = base + sel.value;
});
});
function renderTimer(el, ms) {
if (ms <= 0) {
el.textContent = "время вышло";
el.classList.add("over");
return true;
}
const m = Math.floor(ms / 60000);
const s = Math.floor((ms % 60000) / 1000);
el.textContent = String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
return false;
}
document.querySelectorAll(".js-timer").forEach((el) => {
const end = Date.parse(el.dataset.end);
if (isNaN(end)) return;
let stopped = false;
const tick = () => {
if (stopped) return;
if (renderTimer(el, end - Date.now())) {
stopped = true;
return;
}
setTimeout(tick, 500);
};
tick();
});
const issueGroup = document.getElementById("issue-group");
const issueStudent = document.getElementById("issue-student");
if (issueGroup && issueStudent) {
const fill = () => {
const g = issueGroup.value;
for (const opt of issueStudent.options) {
opt.style.display = !g || opt.dataset.group === g ? "" : "none";
}
if (issueStudent.value && issueStudent.selectedOptions[0] &&
issueStudent.selectedOptions[0].style.display === "none") {
issueStudent.value = "";
}
};
issueGroup.addEventListener("change", fill);
fill();
}

View File

@@ -0,0 +1,204 @@
:root {
--bg: #f4f6fa;
--surface: #ffffff;
--line: #e5e9f0;
--line-soft: #eef1f6;
--text: #151b26;
--muted: #68738a;
--primary: #3565e5;
--primary-hover: #2a53c4;
--primary-soft: #eaf0fe;
--ok: #177a3d;
--ok-bg: #e4f5ea;
--warn: #9a6212;
--warn-bg: #fdf3de;
--danger: #c2362f;
--danger-bg: #fdeae9;
--info: #2b53c2;
--info-bg: #e9effc;
--radius: 12px;
--shadow: 0 1px 2px rgba(16, 24, 40, 0.05), 0 1px 3px rgba(16, 24, 40, 0.06);
}
* { box-sizing: border-box; }
html { font-size: 15px; }
body {
margin: 0;
font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.45;
color: var(--text);
background: var(--bg);
}
.container { max-width: 1180px; margin: 0 auto; padding: 0 20px; }
main.container { padding-top: 26px; padding-bottom: 70px; }
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: none; }
h1 { font-size: 22px; font-weight: 700; margin: 0 0 4px; letter-spacing: -0.01em; }
h2 { font-size: 16px; font-weight: 650; margin: 24px 0 10px; }
h3 { font-size: 14px; font-weight: 650; margin: 16px 0 6px; }
code, pre, .stat-num, .variant-number { font-variant-numeric: tabular-nums; }
/* ---------- Навигация ---------- */
.nav { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
.nav-inner { display: flex; align-items: center; gap: 4px; height: 56px; flex-wrap: wrap; }
.brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 15px; color: var(--text); margin-right: 18px; white-space: nowrap; }
.brand::before { content: ""; width: 10px; height: 10px; border-radius: 3px; background: var(--primary); display: inline-block; }
.nav a.navlink {
color: #4b5563; font-size: 14px; font-weight: 500;
padding: 7px 12px; border-radius: 8px; white-space: nowrap;
}
.nav a.navlink:hover { background: #eef1f6; color: var(--text); }
.nav-user { margin-left: auto; color: var(--muted); font-size: 13px; padding: 0 10px; white-space: nowrap; }
.nav a.plain { color: #4b5563; font-size: 13.5px; font-weight: 500; padding: 6px 10px; border-radius: 8px; }
.nav a.plain:hover { background: #eef1f6; }
/* ---------- Кнопки ---------- */
.btn, .btn button, button {
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
font: inherit; font-size: 14px; font-weight: 600;
padding: 8px 15px; border-radius: 9px; border: 1px solid transparent;
cursor: pointer; text-decoration: none; line-height: 1.2; white-space: nowrap;
transition: background 0.12s, border-color 0.12s;
}
.btn { background: var(--primary); color: #fff; border-color: var(--primary); }
.btn:hover { background: var(--primary-hover); border-color: var(--primary-hover); text-decoration: none; }
.btn.ghost { background: var(--surface); color: var(--text); border-color: #cfd6e0; }
.btn.ghost:hover { background: #f6f8fb; border-color: #b7c0ce; }
.btn.danger { background: var(--surface); color: var(--danger); border-color: #eac2bd; }
.btn.danger:hover { background: var(--danger-bg); border-color: #e0a7a2; }
.btn.danger.fill, .btn.sm.danger.fill { background: var(--danger); color: #fff; border-color: var(--danger); }
.btn.sm { padding: 5px 11px; font-size: 13px; border-radius: 8px; }
.btn:disabled { opacity: 0.55; cursor: default; }
.linkbtn { font-size: 13.5px; font-weight: 600; }
.linkbtn:hover { text-decoration: underline; }
/* ---------- Карточки, сетки ---------- */
.card {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 18px 20px; margin-bottom: 16px;
}
.card-t { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
.card-t h2 { margin: 0 0 2px; }
.card-t .sub, .card .sub { color: var(--muted); font-size: 13px; margin: 2px 0 0; max-width: 62ch; }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }
.cols > * { min-width: 0; }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }
@media (max-width: 920px) { .cols, .two { grid-template-columns: 1fr; } }
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; margin: 0 0 20px; flex-wrap: wrap; }
.page-head .sub { color: var(--muted); font-size: 13.5px; margin-top: 4px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.head-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px; margin-bottom: 16px; }
.stat { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 14px 16px; display: block; color: var(--text); }
a.stat:hover { border-color: #c9d4ea; color: var(--text); }
.stat-num { font-size: 26px; font-weight: 750; letter-spacing: -0.02em; line-height: 1.1; }
.stat-label { color: var(--muted); font-size: 13px; margin-top: 3px; }
.stat-link { color: var(--muted); font-size: 12.5px; font-weight: 600; display: inline-block; margin-top: 8px; }
a.stat:hover .stat-link { color: var(--primary); }
/* ---------- Таблицы ---------- */
.table-card { padding: 0; overflow-x: auto; }
.table-head { padding: 14px 18px 0; }
table { width: 100%; border-collapse: collapse; background: var(--surface); font-size: 14px; }
th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
th {
font-size: 11.5px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.03em;
color: var(--muted); background: #fafbfd; white-space: nowrap;
}
tbody tr:hover { background: #f8fafd; }
tbody tr:last-child td { border-bottom: none; }
td.num { color: var(--muted); font-size: 13px; white-space: nowrap; }
.cell-main { font-weight: 600; }
.cell-sub { color: var(--muted); font-size: 12.5px; margin-top: 1px; }
.table-foot {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 12px 18px; border-top: 1px solid var(--line-soft); background: #fafbfd;
border-radius: 0 0 var(--radius) var(--radius);
}
td input, td select { width: auto; }
/* ---------- Формы ---------- */
label { font-size: 13px; color: #45506a; font-weight: 550; display: inline-flex; align-items: center; gap: 8px; }
.field { margin-bottom: 14px; }
.field > label { display: block; margin-bottom: 6px; }
input, select, textarea {
font: inherit; font-size: 14px; color: var(--text);
padding: 8px 11px; border: 1px solid #cfd6e0; border-radius: 9px; background: var(--surface);
width: 100%; max-width: 100%;
}
input:focus, select:focus, textarea:focus {
outline: none; border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(53, 101, 229, 0.14);
}
input[type="checkbox"] { width: auto; }
input[type="file"] { border-style: dashed; padding: 3px 6px; background: #fbfcfe; }
select.level-select {
padding: 4px 8px; font-size: 13px; border-radius: 8px; font-weight: 550;
border-color: #cfd6e0; cursor: pointer;
}
.w-90 { width: 90px; flex: none; }
.w-120 { width: 120px; flex: none; }
.w-280 { width: 280px; flex: none; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin: 10px 0; }
.row.no-margin { margin: 0; }
.inline { display: inline-flex; align-items: center; gap: 6px; margin: 0; }
.inline input, .inline select { width: auto; }
.confirm-row { align-items: flex-start !important; }
.confirm-row input { margin-top: 4px; }
.hint { color: var(--muted); font-size: 12.5px; }
/* ---------- Бейджи, статусы ---------- */
.badge {
display: inline-flex; align-items: center;
padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 600;
background: #eceff4; color: #45506a; white-space: nowrap; line-height: 1.5;
}
.badge.ok { background: var(--ok-bg); color: var(--ok); }
.badge.warn { background: var(--warn-bg); color: var(--warn); }
.badge.err { background: var(--danger-bg); color: var(--danger); }
.badge.info { background: var(--info-bg); color: var(--info); }
.variant-number { font-size: 15px; font-weight: 700; }
.flash, .error {
padding: 11px 15px; border-radius: 10px; margin-bottom: 16px; font-size: 14px;
display: flex; align-items: flex-start; gap: 8px;
}
.flash { background: var(--ok-bg); border: 1px solid #bfe3ca; color: var(--ok); }
.error { background: var(--danger-bg); border: 1px solid #efc4c1; color: var(--danger); }
/* ---------- Разное ---------- */
.muted { color: var(--muted); }
.muted-2 { color: var(--muted); font-size: 12.5px; }
.nowrap { white-space: nowrap; }
pre {
background: #10192e; color: #dce6f5; padding: 13px 15px; border-radius: 10px;
overflow: auto; font-size: 13px; line-height: 1.5;
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; margin: 8px 0;
}
pre.plain { background: #f5f7fa; color: #242c3b; border: 1px solid var(--line-soft); }
.timer {
font: 700 30px/1.2 ui-monospace, Consolas, monospace;
text-align: center; padding: 14px; border-radius: 10px;
background: #10192e; color: #fff; margin: 10px 0;
}
.timer.over { background: var(--danger); }
.js-timer.countdown {
display: inline-block; min-width: 64px; text-align: center;
font-weight: 700; font-family: ui-monospace, Consolas, monospace; font-size: 15px;
background: var(--info-bg); color: var(--info); padding: 4px 10px; border-radius: 8px;
}
.js-timer.countdown.over { background: var(--danger-bg); color: var(--danger); }
.auth-card { max-width: 430px; margin: 48px auto; background: var(--surface); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow); padding: 26px 28px; }
details summary { cursor: pointer; color: var(--primary); font-size: 13.5px; font-weight: 600; }
details { margin: 10px 0; }
/* ---------- Вкладки ---------- */
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin: 4px 0 18px; flex-wrap: wrap; }
.tab {
padding: 8px 14px; border-radius: 9px 9px 0 0; font-size: 14px; font-weight: 600;
color: var(--muted); border: 1px solid transparent; border-bottom: none; cursor: pointer;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--primary); background: var(--surface); border-color: var(--line); position: relative; top: 1px; }
.tab.active::after { content: ""; display: block; height: 2px; background: var(--surface); position: relative; top: 9px; }

View File

@@ -0,0 +1,34 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}ЛМС{% endblock %} — Практические занятия</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/static/style.css">
<script src="/static/app.js" defer></script>
</head>
<body>
{% if user %}
<nav class="nav">
<div class="nav-inner container">
<span class="brand">Практикум · C</span>
{% if user.role == "teacher" %}
<a class="navlink" href="/t/labs">Лабораторные</a>
<a class="navlink" href="/t/groups">Группы</a>
{% else %}
<a class="navlink" href="/s/">Мои работы</a>
{% endif %}
<span class="nav-user">{{ user.name }}{% if user.role == "student" and user.group %} · {{ user.group }}{% endif %}</span>
<a class="plain" href="/change-password">Сменить пароль</a>
<form method="post" action="/logout" class="inline"><button type="submit" class="btn ghost sm">Выйти</button></form>
</div>
</nav>
{% endif %}
<main class="container">
{% if flash %}<div class="flash">{{ flash }}</div>{% endif %}
{% if error %}<div class="error">{{ error }}</div>{% endif %}
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Смена пароля{% endblock %}
{% block content %}
<div class="auth-card" style="max-width:640px">
<h1>Смена пароля</h1>
{% if must_change %}<div class="error" style="margin-bottom:14px">Первый вход: необходимо сменить стартовый пароль.</div>{% endif %}
<form method="post">
<div class="field"><label>Старый пароль</label>
<input type="password" name="old" required></div>
<div class="field"><label>Новый пароль (минимум 8 символов)</label>
<input type="password" name="new" required></div>
<div class="field"><label>Повторите новый пароль</label>
<input type="password" name="new2" required></div>
<button class="btn">Сменить пароль</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,5 @@
{% extends "base.html" %}
{% block title %}Ошибка{% endblock %}
{% block content %}
<div class="card"><h1>Ошибка</h1>{{ error or 'Неизвестная ошибка' }}</div>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Вход{% endblock %}
{% block content %}
<div class="auth-card">
<h1>Вход в систему</h1>
<form method="post" action="/login">
<div class="field"><label>Логин (для студентов — номер студенческого)</label>
<input type="text" name="login" required autofocus></div>
<div class="field"><label>Пароль</label>
<input type="password" name="password" required></div>
<button class="btn">Войти</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Мои работы{% endblock %}
{% block content %}
<h1>Мои лабораторные работы</h1>
<div class="card table-card">
<table>
<thead><tr><th>№</th><th>Работа</th><th>Мой вариант</th><th>Срок</th></tr></thead>
<tbody>
{% for l in labs %}
<tr>
<td class="num">{{ l.number }}</td>
<td><a class="cell-main" href="/s/labs/{{ l.id }}">{{ l.title }}</a></td>
<td>
{% if l.variant_label %}{{ l.variant_label }}
{% elif l.deadline_passed %}<span class="muted">не назначен</span>
{% else %}<span class="badge warn">не назначен</span>{% endif %}
</td>
<td>{{ deadline_view(l.deadline) }}</td>
</tr>
{% else %}
<tr><td colspan="4" style="text-align:center;padding:24px" class="muted">Лабораторных работ пока нет.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}ЛР{{ lab.number }}{% endblock %}
{% block content %}
<h1>ЛР{{ lab.number }}. {{ lab.title }}</h1>
<div class="row" style="margin-bottom:14px">
<span class="badge">Срок: {{ deadline_view(lab.deadline) }}</span>
<span class="badge">Баллов: до {{ lab.points_max }}</span>
</div>
<div class="card">
<h2 style="margin-top:0">Мой вариант</h2>
{% set v = lab.variant %}
{% if v %}
<div class="row" style="margin-bottom:8px">
<span class="badge info">{{ 'Основная группа' if v.level == 'main' else 'Повышенная сложность' }}, вариант {{ v.number }}</span>
</div>
<p><b>Условие:</b> {{ v.statement }}</p>
{% if v.notes %}<p><b>Указания к выполнению:</b><br>{{ v.notes }}</p>{% endif %}
{% if v.examples %}
<h3>Примеры входных/выходных данных</h3>
<table>
<tr><th>Вход</th><th>Ожидаемый выход</th><th>Пояснение</th></tr>
{% for ex in v.examples %}
<tr>
<td><pre class="plain">{{ ex.get('input', '') }}</pre></td>
<td><pre class="plain">{{ ex.get('output', '') }}</pre></td>
<td>{{ ex.get('explanation', '') }}</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% elif lab.assigned %}
<p class="muted">Вариант назначен, но в этой работе такого номера нет в условиях. Напишите преподавателю.</p>
{% else %}
<span class="badge warn">Вариант пока не назначен</span>
{% endif %}
</div>
{% if lab.goal %}<div class="card"><h2 style="margin-top:0">Цель работы</h2><p class="muted">{{ lab.goal }}</p></div>{% endif %}
{% if lab.common_requirements %}<div class="card"><h2 style="margin-top:0">Общие требования</h2><pre class="plain">{{ lab.common_requirements }}</pre></div>{% endif %}
{% if lab.submission_instructions %}<div class="card"><h2 style="margin-top:0">Как сдавать</h2><pre class="plain">{{ lab.submission_instructions }}</pre></div>{% endif %}
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Настройка{% endblock %}
{% block content %}
<div class="auth-card">
<h1>Первый запуск</h1>
<p class="muted">Создайте аккаунт преподавателя (единственный администратор).</p>
<form method="post" action="/setup">
<div class="field"><label>Логин</label>
<input type="text" name="login" required autofocus></div>
<div class="field"><label>Пароль (минимум 8 символов)</label>
<input type="password" name="password" required></div>
<div class="field"><label>Повторите пароль</label>
<input type="password" name="password2" required></div>
<button class="btn">Создать аккаунт</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,151 @@
{% extends "base.html" %}
{% block title %}Группа {{ group.name }}{% endblock %}
{% block content %}
{% set lab_id_str = lab_id | string %}
{% set n_main = students|selectattr("level", "equalto", "main")|list|length %}
{% set n_adv = students|selectattr("level", "equalto", "advanced")|list|length %}
<div class="page-head">
<div>
<h1>Группа {{ group.name }}</h1>
<div class="sub">
<span>{{ students|length }} студентов</span>
<span class="badge">Основная: {{ n_main }}</span>
<span class="badge info">Повышенная: {{ n_adv }}</span>
{%- if unassigned is not none and unassigned %}
<span class="badge warn">{{ unassigned }} без варианта</span>
{%- endif %}
</div>
</div>
<div class="head-actions">
<a class="btn ghost" href="/t/groups/{{ group.id }}/credentials.xlsx">Логины и пароли (xlsx)</a>
<a class="btn ghost" href="/t/groups/{{ group.id }}/credentials.csv">Пароли (csv)</a>
</div>
</div>
<div class="cols">
<div class="card">
<div class="card-t">
<div>
<h2>Распределение вариантов</h2>
<p class="sub">Номера берутся из вариантов выбранной лабораторной и раздаются студентам группы:
уникальны внутри группы, по возможности — между группами; если вариантов меньше, чем студентов, повторение допускается.</p>
</div>
</div>
{% if labs %}
<form method="post" action="/t/groups/{{ group.id }}/assign" class="row no-margin">
<select name="lab_id" class="w-280">
<option value="0">Все лабораторные сразу</option>
{% for l in labs %}
<option value="{{ l.id }}">ЛР {{ l.number }} — {{ l.title }} ({{ l.n_main }} + {{ l.n_adv }} вар.)</option>
{% endfor %}
</select>
<button class="btn">Распределить</button>
</form>
{% else %}
<p class="hint">Пока нет ни одной лабораторной с загруженными вариантами —
<a href="/t/labs">загрузите условия</a>.</p>
{% endif %}
</div>
</div>
<div class="card table-card">
<div class="table-head card-t">
<div>
<h2>Студенты</h2>
{%- if labs %}
<div class="sub" style="margin-top:6px">
Варианты:
<select class="level-select" data-nav="{{ '/t/groups/%d/' % group.id }}?lab=">
<option value="">все лабы</option>
{% for l in labs %}
<option value="{{ l.id }}" {% if lab_id == l.id %}selected{% endif %}>ЛР {{ l.number }}</option>
{% endfor %}
</select>
</div>
{%- endif %}
</div>
</div>
<table>
<thead>
<tr>
<th>№ студенческого</th>
<th>ФИО</th>
<th>Уровень</th>
<th>Вариант</th>
<th>Пароль</th>
<th style="width:110px"></th>
</tr>
</thead>
<tbody>
{% for st in students %}
{% set la = (st.lab_numbers or {}).get(lab_id_str) if lab_id else none %}
<tr>
<td class="num">{{ st.id_student }}</td>
<td>
<div class="cell-main">{{ st.surname }} {{ st.first_name }} {{ st.middle_name }}</div>
{% if st.basis %}<div class="cell-sub">{{ st.basis }}</div>{% endif %}
</td>
<td>
<form method="post" action="/t/groups/{{ group.id }}/levels" class="inline">
<input type="hidden" name="student_id" value="{{ st.id_student }}">
<input type="hidden" name="lab_id" value="{{ lab_id or 0 }}">
<select name="level" class="level-select" data-autosubmit>
<option value="main" {% if st.level == 'main' %}selected{% endif %}>{{ levels.main }}</option>
<option value="advanced" {% if st.level == 'advanced' %}selected{% endif %}>{{ levels.advanced }}</option>
</select>
</form>
</td>
<td>
{% if not labs %}
<span class="muted">—</span>
{% elif lab_id %}
{% if la and la.number %}
<span class="variant-number">{{ la.number }}</span>
{% if la.manual %}<span class="badge info">вручную</span>{% endif %}
{% else %}<span class="badge warn">не назначен</span>{% endif %}
{% else %}
<span class="muted">{{ (st.lab_numbers or {}) | length }} из {{ labs | length }}</span>
{% endif %}
</td>
<td>
{% if st.must_change %}<span class="badge ok">стартовый</span>
{% else %}<span class="badge">сменён</span>{% endif %}
</td>
<td class="nowrap" style="text-align:right">
<form method="post" action="/t/students/{{ st.id_student }}/reset-password" class="inline"
onsubmit="return confirm('Сбросить пароль студенту? Старый пароль станет недействительным, новый студент сменит при входе.')">
<button class="btn sm danger">Сбросить пароль</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="6" style="padding:26px;text-align:center" class="muted">
Студентов нет — импортируйте список на <a href="/t/">главной</a>.
</td></tr>
{% endfor %}
</tbody>
</table>
{% if students and labs %}
<div class="table-foot">
<form method="post" action="/t/groups/{{ group.id }}/manual" class="row no-margin" style="flex:1;min-width:340px">
<select name="student_id" class="w-280">
{% for st in students %}
<option value="{{ st.id_student }}">{{ st.surname }} {{ st.first_name }} ({{ st.id_student }})</option>
{% endfor %}
</select>
<select name="lab_id" class="w-120">
{% for l in labs %}
<option value="{{ l.id }}" {% if lab_id and lab_id == l.id %}selected{% endif %}>ЛР {{ l.number }}</option>
{% endfor %}
</select>
<input type="text" name="number" class="w-90" placeholder="№ варианта"
title="Пусто — снять ручное назначение">
<button class="btn">Применить</button>
</form>
<span class="hint">Ручное назначение варианта для конкретного студента.</span>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Группы{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1>Группы</h1>
<div class="sub">Импорт списка, уровни, распределение вариантов, выгрузка паролей</div>
</div>
<div class="head-actions">
</div>
</div>
<div class="card table-card">
<table>
<thead>
<tr><th>Группа</th><th>Студентов</th><th>Без варианта{% if groups and groups[0].unassigned_lab_number %} (ЛР {{ groups[0].unassigned_lab_number }}){% endif %}</th><th></th></tr>
</thead>
<tbody>
{% for g in groups %}
<tr>
<td><a href="/t/groups/{{ g.id }}" class="cell-main">{{ g.name }}</a></td>
<td>{{ g.cnt }}</td>
<td>
{% if g.unassigned is none %}<span class="muted">варианты не загружены</span>
{% elif g.unassigned %}<span class="badge warn">{{ g.unassigned }}</span>
{% else %}<span class="badge ok">все</span>{% endif %}
</td>
<td class="nowrap">
<a class="linkbtn" href="/t/groups/{{ g.id }}/credentials.xlsx">Логины (xlsx)</a> ·
<a class="linkbtn" href="/t/groups/{{ g.id }}/credentials.csv">пароли (csv)</a>
</td>
</tr>
{% else %}
<tr><td colspan="4" style="padding:26px;text-align:center" class="muted">Групп пока нет — импортируйте список.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="two">
<div class="card">
<div class="card-t"><div><h2>Импорт списка группы</h2>
<p class="sub">Группа определяется по названию в шапке файла. Существующие студенты обновляются, новым генерируются пароли.</p></div></div>
<form method="post" action="/t/groups/import" enctype="multipart/form-data">
<div class="field"><input type="file" name="file" accept=".xls,.xlsx" required></div>
<button class="btn">Импортировать</button>
</form>
</div>
<div class="card">
<div class="card-t"><div><h2>Создать группу вручную</h2>
<p class="sub">Пустая группа: студентов можно будет добавить импортом в неё.</p></div></div>
<form method="post" action="/t/groups" class="row no-margin">
<input type="text" name="name" placeholder="Например, ПИ-13БО" required>
<button class="btn">Создать</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}ЛР{{ lab.number }}. {{ lab.title }}{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1>ЛР{{ lab.number }}. {{ lab.title }}</h1>
<div class="sub">
<span>Срок: {{ deadline_view(lab.deadline) }}</span>
<span>Макс. баллов: {{ lab.points_max }}</span>
</div>
</div>
<div class="head-actions">
<details class="inline">
<summary class="btn ghost">Обновить из JSON</summary>
<div style="padding:12px 0 4px">
<form method="post" action="/t/labs/{{ lab.id }}/json" enctype="multipart/form-data" class="row no-margin">
<input type="file" name="file" accept=".json,application/json" required>
<button class="btn">Загрузить варианты</button>
</form>
</div>
</details>
<a class="btn ghost" href="/t/labs">К списку</a>
<form method="post" action="/t/labs/{{ lab.id }}/delete" class="inline"
onsubmit="return confirm('Удалить лабораторную?')">
<button class="btn sm danger">Удалить</button>
</form>
</div>
</div>
{% if lab.goal %}<div class="card"><div class="card-t"><div><h2>Цель работы</h2></div></div><p class="muted">{{ lab.goal }}</p></div>{% endif %}
{% if lab.common_requirements %}<div class="card"><div class="card-t"><div><h2>Общие требования</h2></div></div><pre class="plain">{{ lab.common_requirements }}</pre></div>{% endif %}
{% if lab.submission_instructions %}<div class="card"><div class="card-t"><div><h2>Как сдавать</h2></div></div><pre class="plain">{{ lab.submission_instructions }}</pre></div>{% endif %}
{% for vl in [("main", variants_main, "Основная группа"), ("advanced", variants_advanced, "Повышенная сложность")] %}
<div class="card">
<div class="card-t"><div><h2>{{ vl[2] }} <span class="muted">({{ vl[1] | length }})</span></h2></div></div>
{% for v in vl[1] %}
<details {% if vl[1] | length <= 12 %}open{% endif %}>
<summary><b>Вариант {{ v.number }}.</b> {{ v.statement }}</summary>
{% if v.notes %}<p class="muted"><b>Указания:</b> {{ v.notes }}</p>{% endif %}
{% if v.examples %}
<table>
<tr><th>Вход</th><th>Выход</th><th>Пояснение</th></tr>
{% for ex in v.examples %}
<tr><td><pre class="plain">{{ ex.get('input', '') }}</pre></td>
<td><pre class="plain">{{ ex.get('output', '') }}</pre></td>
<td>{{ ex.get('explanation', '') }}</td></tr>
{% endfor %}
</table>
{% endif %}
</details>
{% else %}
<p class="hint">Вариантов этого уровня пока нет.</p>
{% endfor %}
</div>
{% endfor %}
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Новая лабораторная{% endblock %}
{% block content %}
<h1>Новая лабораторная</h1>
<div class="card">
<div class="card-t">
<div><h2>Создание из JSON</h2>
<p class="sub">Загрузите один файл: название/номер работы и массив «variants»
(основная группа — номера с level: main, повышенная — level: advanced).</p></div>
</div>
<form method="post" action="/t/labs/new/import" enctype="multipart/form-data" class="row no-margin">
<input type="file" name="file" accept=".json,application/json" required>
<button class="btn">Импортировать</button>
</form>
</div>
{% if parsed %}
<div class="card">
<div class="card-t"><div><h2>Проверьте и создайте</h2></div></div>
<table>
<tr><th>№</th><th>Название</th><th>Вариантов</th></tr>
<tr>
<td>{{ parsed.lab.get('number', '—') }}</td>
<td><b>{{ parsed.lab.get('title', '—') }}</b></td>
<td>
{% set mains = parsed.variants | selectattr('level', 'equalto', 'main') | list %}
{% set advs = parsed.variants | selectattr('level', 'equalto', 'advanced') | list %}
<span class="badge">всего {{ parsed.variants | length }}</span>
<span class="badge">осн. {{ mains | length }}</span>
<span class="badge info">повыш. {{ advs | length }}</span>
</td>
</tr>
</table>
<form method="post" action="/t/labs/new" style="margin-top:12px">
<input type="hidden" name="variants_json" value="{{ parsed.raw }}">
<button class="btn">Создать лабораторную</button>
</form>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Лабораторные{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1>Лабораторные работы</h1>
<div class="sub">Загрузите условия и варианты работы одним JSON-файлом</div>
</div>
<div class="head-actions">
<a class="btn" href="/t/labs/new">+ Новая (из JSON)</a>
</div>
</div>
<div class="card table-card">
<table>
<thead><tr><th>№</th><th>Название</th><th>Срок</th><th>Вариантов (осн. + повыш.)</th><th></th></tr></thead>
<tbody>
{% for l in labs %}
<tr>
<td class="num">{{ l.number }}</td>
<td><a class="cell-main" href="/t/labs/{{ l.id }}">{{ l.title }}</a></td>
<td>{{ deadline_view(l.deadline) }}</td>
<td>
{% if not l.n_main and not l.n_adv %}<span class="badge warn">варианты не загружены</span>
{% else %}<span class="muted">{{ l.n_main }} + {{ l.n_adv }}</span>{% endif %}
</td>
<td class="nowrap" style="text-align:right">
<a class="linkbtn" href="/t/labs/{{ l.id }}">условия →</a>
</td>
</tr>
{% else %}
<tr><td colspan="5" style="text-align:center;padding:24px" class="muted">Лабораторных нет —
добавьте первую из JSON.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,31 @@
from datetime import datetime
def fmt_dt(s: str | None) -> str:
if not s:
return ""
try:
d = datetime.strptime(s[:19], "%Y-%m-%d %H:%M:%S")
return d.strftime("%d.%m.%Y %H:%M")
except ValueError:
return s
def deadline_view(s: str | None) -> str:
"""«до 04.10.2026 23:59 · осталось 24 дн. 5 ч.» или «срок истёк»."""
if not s:
return "срок не задан"
try:
d = datetime.strptime(s[:19], "%Y-%m-%d %H:%M:%S")
except ValueError:
return str(s)
left = d - datetime.now()
head = d.strftime("%d.%m.%Y %H:%M")
if left.total_seconds() < 0:
return f"{head} · срок истёк"
days, secs = left.days, int(left.seconds // 3600)
parts = []
if days:
parts.append(f"{days} дн.")
parts.append(f"{secs} ч.")
return f"{head} · осталось {' '.join(parts)}"