Распределение вариантов
This commit is contained in:
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
.gitignore
|
||||
.idea
|
||||
.venv
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
deploy.sh
|
||||
Makefile
|
||||
tests
|
||||
examples
|
||||
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# Порт lms-api на хосте (в контейнере всегда 8000)
|
||||
API_PORT=8000
|
||||
|
||||
# Порт веб-интерфейса на хосте (в контейнере всегда 8080)
|
||||
FRONTEND_PORT=8080
|
||||
|
||||
# Адрес lms-api, который видит lms-web (внутри сети docker-compose)
|
||||
LMS_API_URL=http://api:8000
|
||||
|
||||
# Секрет сессионных токенов. Можно оставить пустым —
|
||||
# при первом запуске сгенерируется и сохранится в БД (volume lms_data)
|
||||
LMS_TOKEN_SECRET=
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
.venv/
|
||||
**/*__pycache__
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
**/*.idea
|
||||
**/*.env
|
||||
data/
|
||||
*.xls.bak
|
||||
/.idea/
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt \
|
||||
&& groupadd --system app && useradd --system --gid app app
|
||||
|
||||
FROM base AS api
|
||||
|
||||
COPY --chown=app:app src/backend ./src/backend
|
||||
ENV LMS_DATA_DIR=/data
|
||||
USER app
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "lms_api.main:app", "--app-dir", "src/backend", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
|
||||
FROM base AS frontend
|
||||
|
||||
COPY --chown=app:app src/frontend ./src/frontend
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
CMD ["uvicorn", "lms_web.main:app", "--app-dir", "src/frontend", "--host", "0.0.0.0", "--port", "8080"]
|
||||
40
Makefile
Normal file
40
Makefile
Normal file
@@ -0,0 +1,40 @@
|
||||
.PHONY: build up down logs restart deploy ps dev test install
|
||||
|
||||
VENV := $(CURDIR)/.venv/bin/python
|
||||
|
||||
install: ## Создать локальный venv и поставить зависимости (разработка)
|
||||
python3 -m venv .venv
|
||||
$(VENV) -m pip install --upgrade pip
|
||||
$(VENV) -m pip install -r requirements-dev.txt
|
||||
|
||||
build: ## Собрать образы микросервисов (Docker)
|
||||
docker compose build
|
||||
|
||||
up: ## Запустить все микросервисы одной командой (Docker)
|
||||
docker compose up -d
|
||||
|
||||
down: ## Остановить и удалить контейнеры (Docker)
|
||||
docker compose down
|
||||
|
||||
restart: ## Перезапустить микросервисы (Docker)
|
||||
docker compose restart
|
||||
|
||||
logs: ## Логи всех микросервисов (Docker)
|
||||
docker compose logs -f
|
||||
|
||||
ps: ## Статус микросервисов (Docker)
|
||||
docker compose ps
|
||||
|
||||
deploy: ## Собрать и запустить (деплой на сервере)
|
||||
./deploy.sh
|
||||
|
||||
test: ## Прогнать smoke-тест микросервисной связки (локальный venv)
|
||||
$(VENV) tests/smoke_test.py
|
||||
|
||||
dev: ## Запустить локально без Docker: lms-api :8000 + lms-web :8080
|
||||
@echo "Запуск lms-api на :8000 и lms-web на :8080..."
|
||||
@export LMS_API_URL=http://127.0.0.1:8000; \
|
||||
trap 'kill 0' EXIT; \
|
||||
cd src/backend && $(CURDIR)/.venv/bin/python -m uvicorn lms_api.main:app --reload --port 8000 & \
|
||||
cd src/frontend && $(CURDIR)/.venv/bin/python -m uvicorn lms_web.main:app --reload --port 8080 & \
|
||||
wait
|
||||
108
README.md
Normal file
108
README.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# LMS: практические занятия по курсу «Промышленное программирование на C»
|
||||
|
||||
Минимальное ядро: регистрация, списки студентов, варианты лабораторных и их
|
||||
распределение. Условие своего варианта студент видит в личном кабинете.
|
||||
|
||||
Два микросервиса:
|
||||
- **lms-api** (`src/backend/lms_api`, :8000) — данные и логика (SQLite), REST `/api/v1`;
|
||||
- **lms-web** (`src/frontend/lms_web`, :8080) — веб-интерфейс, ходит в API по HTTP.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
make install # .venv + зависимости
|
||||
make dev # lms-api :8000 + lms-web :8080
|
||||
# открыть http://127.0.0.1:8080 — при первом запуске создать аккаунт преподавателя
|
||||
```
|
||||
|
||||
Тесты: `make test` (11 проверок, свои порты/БД). В Docker: `make build && make up`
|
||||
(или `make deploy`); данные — в volume `lms_data`.
|
||||
|
||||
## Возможности сейчас
|
||||
|
||||
**Преподаватель**
|
||||
- аккаунт администратора (единственный), смена пароля;
|
||||
- **Лабораторные** — создаются из JSON-файла с вариантами (см. `examples/lab1.json`):
|
||||
`variants` с `level: main/advanced`, номером, условием, примерами и указаниями;
|
||||
если срок сдачи в JSON не задан — автоматически ставится через 25 дней
|
||||
(3 недели и 4 дня) после создания;
|
||||
- **Группы** — импорт списков из `.xls/.xlsx` (название из шапки, столбцы:
|
||||
№ п/п, студенческий номер, ФИО, основа обучения); новым студентам генерируется
|
||||
пароль (логин = номер студенческого), выгрузка «ФИО, логин, пароль» (xlsx/csv),
|
||||
сброс пароля;
|
||||
- **Распределение вариантов**: студенту выбирается уровень (основная /
|
||||
повышенная), затем по группе раздаются варианты конкретной лабораторной
|
||||
(уникальны внутри группы, по возможности — между группами; если вариантов
|
||||
меньше студентов — допустимы повторы), есть ручное назначение номера.
|
||||
|
||||
**Студент**
|
||||
- вход по номеру студенческого и паролю; при первом входе смена пароля обязательна;
|
||||
- список работ и условие своего варианта (по каждой работе — с примерами
|
||||
входов/выходов и указаниями).
|
||||
|
||||
Остальное (отправка решений, задания на защиту/добор, тренировочные задачи,
|
||||
оценки) будет добавлено позднее.
|
||||
|
||||
## Формат JSON лабораторной (`examples/lab1.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"number": 1,
|
||||
"title": "Одномерные массивы и последовательности",
|
||||
"semester": "осень",
|
||||
"deadline": "2026-10-01T23:59",
|
||||
"points_max": 5,
|
||||
"goal": "…", "common_requirements": "…",
|
||||
"variants": [
|
||||
{ "level": "main", "number": "1", "statement": "…",
|
||||
"examples": [ { "input": "4\n1 2 3 4", "output": "10", "explanation": "…" } ],
|
||||
"notes": "…" },
|
||||
{ "level": "advanced", "number": "21", "statement": "…" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Деплой на сервер
|
||||
|
||||
### Вариант 1 — Docker (рекомендую)
|
||||
|
||||
```bash
|
||||
# на сервере: скопировать проект, затем
|
||||
cp .env.example .env
|
||||
nano .env # FRONTEND_PORT=8080 (или 80), API_PORT=8000
|
||||
./deploy.sh # соберёт образы и поднимет сервисы
|
||||
docker compose ps # статус
|
||||
docker compose logs -f # логи
|
||||
```
|
||||
|
||||
Данные (БД и загрузки) хранятся в volume `lms_data` — переживают перезапуск
|
||||
и пересборку. Бэкап одной командой:
|
||||
`docker run --rm -v lms_lms_data:/data -v $PWD:/backup alpine tar czf /backup/lms-backup.tar.gz /data`
|
||||
|
||||
Порты для наружу: пользователям нужен только `lms-web` (по умолчанию `:8080`).
|
||||
`lms-api` достаточно оставить внутри сети compose (в `.env` не публиковать
|
||||
`API_PORT`, либо ограничить доступ файрволом).
|
||||
|
||||
### Вариант 2 — без Docker (процессы через systemd)
|
||||
|
||||
```bash
|
||||
make install
|
||||
# два юнита (пример /etc/systemd/system/lms-api.service):
|
||||
# ExecStart=/path/to/lms/.venv/bin/python -m uvicorn lms_api.main:app
|
||||
# --app-dir /path/to/lms/src/backend --host 0.0.0.0 --port 8000
|
||||
# Environment=LMS_API_URL=http://127.0.0.1:8000
|
||||
# и аналогично lms-web на --host 0.0.0.0 --port 8080 (lms_web.main:app, src/frontend)
|
||||
```
|
||||
|
||||
Удобно поставить `lms-web` за nginx (HTTPS, отдача статики проксируется на :8080),
|
||||
доступ к `/api/v1/*` на :8080 не требуется наружу.
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
src/backend/lms_api/ # API: core/{db,security,variants,xls_import}, routers
|
||||
src/frontend/lms_web/ # веб: routers, templates, static
|
||||
tests/smoke_test.py # make test
|
||||
examples/ # ПИ-11БО.xls, ПИ-12БО.xls, lab1.json
|
||||
Dockerfile docker-compose.yml deploy.sh Makefile data/
|
||||
```
|
||||
24
deploy.sh
Executable file
24
deploy.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if ! command -v docker >/dev/null || ! docker compose version >/dev/null 2>&1;
|
||||
then
|
||||
echo "Ошибка: нужен Docker с compose-плагином (v2)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "Файл .env не найден — создаю из .env.example."
|
||||
cp .env.example .env
|
||||
fi
|
||||
|
||||
echo "Сборка образов..."
|
||||
docker compose build
|
||||
|
||||
echo "Запуск микросервисов..."
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
echo
|
||||
docker compose ps
|
||||
41
docker-compose.yml
Normal file
41
docker-compose.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
target: api
|
||||
environment:
|
||||
LMS_DATA_DIR: /data
|
||||
LMS_TOKEN_SECRET: ${LMS_TOKEN_SECRET:-}
|
||||
volumes:
|
||||
- lms_data:/data
|
||||
ports:
|
||||
- "${API_PORT:-8000}:8000"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
target: frontend
|
||||
environment:
|
||||
LMS_API_URL: ${LMS_API_URL:-http://api:8000}
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-8080}:8080"
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
lms_data:
|
||||
2
requirements-dev.txt
Normal file
2
requirements-dev.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest
|
||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
jinja2
|
||||
python-multipart
|
||||
itsdangerous
|
||||
xlrd
|
||||
openpyxl
|
||||
httpx
|
||||
32
src/backend/lms_api/__init__.py
Normal file
32
src/backend/lms_api/__init__.py
Normal 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
|
||||
0
src/backend/lms_api/core/__init__.py
Normal file
0
src/backend/lms_api/core/__init__.py
Normal file
188
src/backend/lms_api/core/db.py
Normal file
188
src/backend/lms_api/core/db.py
Normal 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),
|
||||
)
|
||||
91
src/backend/lms_api/core/schemas.py
Normal file
91
src/backend/lms_api/core/schemas.py
Normal 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 = ""
|
||||
27
src/backend/lms_api/core/security.py
Normal file
27
src/backend/lms_api/core/security.py
Normal 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))
|
||||
32
src/backend/lms_api/core/tokens.py
Normal file
32
src/backend/lms_api/core/tokens.py
Normal 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
|
||||
17
src/backend/lms_api/core/util.py
Normal file
17
src/backend/lms_api/core/util.py
Normal 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)
|
||||
196
src/backend/lms_api/core/variants.py
Normal file
196
src/backend/lms_api/core/variants.py
Normal 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)
|
||||
90
src/backend/lms_api/core/xls_import.py
Normal file
90
src/backend/lms_api/core/xls_import.py
Normal 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}
|
||||
64
src/backend/lms_api/core/zips.py
Normal file
64
src/backend/lms_api/core/zips.py
Normal 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
|
||||
11
src/backend/lms_api/main.py
Normal file
11
src/backend/lms_api/main.py
Normal 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")))
|
||||
114
src/backend/lms_api/routers/auth.py
Normal file
114
src/backend/lms_api/routers/auth.py
Normal 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}
|
||||
82
src/backend/lms_api/routers/deps.py
Normal file
82
src/backend/lms_api/routers/deps.py
Normal 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)
|
||||
314
src/backend/lms_api/routers/groups.py
Normal file
314
src/backend/lms_api/routers/groups.py
Normal 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')}"},
|
||||
)
|
||||
192
src/backend/lms_api/routers/labs.py
Normal file
192
src/backend/lms_api/routers/labs.py
Normal 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}
|
||||
68
src/backend/lms_api/routers/me.py
Normal file
68
src/backend/lms_api/routers/me.py
Normal 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)
|
||||
58
src/frontend/lms_web/__init__.py
Normal file
58
src/frontend/lms_web/__init__.py
Normal 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
|
||||
105
src/frontend/lms_web/client.py
Normal file
105
src/frontend/lms_web/client.py
Normal 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)
|
||||
79
src/frontend/lms_web/ctx.py
Normal file
79
src/frontend/lms_web/ctx.py
Normal 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,
|
||||
)
|
||||
11
src/frontend/lms_web/main.py
Normal file
11
src/frontend/lms_web/main.py
Normal 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")))
|
||||
0
src/frontend/lms_web/routers/__init__.py
Normal file
0
src/frontend/lms_web/routers/__init__.py
Normal file
119
src/frontend/lms_web/routers/auth_pages.py
Normal file
119
src/frontend/lms_web/routers/auth_pages.py
Normal 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)
|
||||
42
src/frontend/lms_web/routers/student_pages.py
Normal file
42
src/frontend/lms_web/routers/student_pages.py
Normal 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)
|
||||
322
src/frontend/lms_web/routers/teacher_pages.py
Normal file
322
src/frontend/lms_web/routers/teacher_pages.py
Normal 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')}"})
|
||||
63
src/frontend/lms_web/static/app.js
Normal file
63
src/frontend/lms_web/static/app.js
Normal 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();
|
||||
}
|
||||
204
src/frontend/lms_web/static/style.css
Normal file
204
src/frontend/lms_web/static/style.css
Normal 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; }
|
||||
34
src/frontend/lms_web/templates/base.html
Normal file
34
src/frontend/lms_web/templates/base.html
Normal 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>
|
||||
17
src/frontend/lms_web/templates/change_password.html
Normal file
17
src/frontend/lms_web/templates/change_password.html
Normal 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 %}
|
||||
5
src/frontend/lms_web/templates/error.html
Normal file
5
src/frontend/lms_web/templates/error.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Ошибка{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card"><h1>Ошибка</h1>{{ error or 'Неизвестная ошибка' }}</div>
|
||||
{% endblock %}
|
||||
14
src/frontend/lms_web/templates/login.html
Normal file
14
src/frontend/lms_web/templates/login.html
Normal 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 %}
|
||||
26
src/frontend/lms_web/templates/s_dashboard.html
Normal file
26
src/frontend/lms_web/templates/s_dashboard.html
Normal 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 %}
|
||||
42
src/frontend/lms_web/templates/s_lab.html
Normal file
42
src/frontend/lms_web/templates/s_lab.html
Normal 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 %}
|
||||
17
src/frontend/lms_web/templates/setup.html
Normal file
17
src/frontend/lms_web/templates/setup.html
Normal 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 %}
|
||||
151
src/frontend/lms_web/templates/t_group.html
Normal file
151
src/frontend/lms_web/templates/t_group.html
Normal 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 %}
|
||||
58
src/frontend/lms_web/templates/t_groups.html
Normal file
58
src/frontend/lms_web/templates/t_groups.html
Normal 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 %}
|
||||
57
src/frontend/lms_web/templates/t_lab.html
Normal file
57
src/frontend/lms_web/templates/t_lab.html
Normal 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 %}
|
||||
41
src/frontend/lms_web/templates/t_lab_form.html
Normal file
41
src/frontend/lms_web/templates/t_lab_form.html
Normal 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 %}
|
||||
38
src/frontend/lms_web/templates/t_labs.html
Normal file
38
src/frontend/lms_web/templates/t_labs.html
Normal 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 %}
|
||||
31
src/frontend/lms_web/util.py
Normal file
31
src/frontend/lms_web/util.py
Normal 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)}"
|
||||
203
tests/smoke_test.py
Normal file
203
tests/smoke_test.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Smoke-тест минимального ядра: админ, студенты, варианты лабы, распределение.
|
||||
|
||||
Поднимает оба микросервиса на портах 8100/8101 с чистой БД и прогоняет
|
||||
основной сценарий. Запуск: make test (или python tests/smoke_test.py).
|
||||
"""
|
||||
|
||||
import os
|
||||
import re as _re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
API_PORT = int(os.environ.get("LMS_TEST_API_PORT", "8100"))
|
||||
WEB_PORT = int(os.environ.get("LMS_TEST_WEB_PORT", "8101"))
|
||||
API_PY = os.environ.get("LMS_TEST_API_PY", sys.executable)
|
||||
WEB_PY = os.environ.get("LMS_TEST_WEB_PY", sys.executable)
|
||||
|
||||
PASSED = []
|
||||
|
||||
|
||||
def ok(name):
|
||||
PASSED.append(name)
|
||||
print("PASS:", name)
|
||||
|
||||
|
||||
def free(port):
|
||||
s = socket.socket()
|
||||
try:
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind(("127.0.0.1", port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def start_services(data_dir):
|
||||
env = dict(os.environ, LMS_API_PORT=str(API_PORT), FRONTEND_PORT=str(WEB_PORT),
|
||||
LMS_API_URL=f"http://127.0.0.1:{API_PORT}", LMS_DATA_DIR=str(data_dir))
|
||||
procs = [
|
||||
subprocess.Popen(
|
||||
[API_PY, "-m", "uvicorn", "lms_api.main:app", "--app-dir", str(ROOT / "src" / "backend"),
|
||||
"--port", str(API_PORT), "--host", "127.0.0.1"],
|
||||
env=env, cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL),
|
||||
subprocess.Popen(
|
||||
[WEB_PY, "-m", "uvicorn", "lms_web.main:app", "--app-dir", str(ROOT / "src" / "frontend"),
|
||||
"--port", str(WEB_PORT), "--host", "127.0.0.1"],
|
||||
env=env, cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL),
|
||||
]
|
||||
for _ in range(60):
|
||||
try:
|
||||
if httpx.get(f"http://127.0.0.1:{API_PORT}/healthz", timeout=2).status_code == 200:
|
||||
break
|
||||
except httpx.HTTPError:
|
||||
time.sleep(0.5)
|
||||
for _ in range(60):
|
||||
try:
|
||||
if httpx.get(f"http://127.0.0.1:{WEB_PORT}/healthz", timeout=2).status_code == 200:
|
||||
break
|
||||
except httpx.HTTPError:
|
||||
time.sleep(0.5)
|
||||
return procs
|
||||
|
||||
|
||||
def main():
|
||||
for port in (API_PORT, WEB_PORT):
|
||||
assert free(port), f"Порт {port} занят"
|
||||
data_dir = Path(tempfile.mkdtemp(prefix="lms-test-"))
|
||||
procs = start_services(data_dir)
|
||||
try:
|
||||
run(data_dir)
|
||||
finally:
|
||||
for p in procs:
|
||||
p.terminate()
|
||||
for p in procs:
|
||||
try:
|
||||
p.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
p.kill()
|
||||
shutil.rmtree(data_dir, ignore_errors=True)
|
||||
print(f"\nПРОВЕРОК: {len(PASSED)}")
|
||||
|
||||
|
||||
def run(data_dir: Path):
|
||||
db_path = data_dir / "lms.db"
|
||||
|
||||
def dbq(sql, *a):
|
||||
import sqlite3
|
||||
|
||||
db = sqlite3.connect(db_path)
|
||||
db.row_factory = sqlite3.Row
|
||||
try:
|
||||
return [dict(x) for x in db.execute(sql, a).fetchall()]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
web = f"http://127.0.0.1:{WEB_PORT}"
|
||||
api = f"http://127.0.0.1:{API_PORT}/api/v1"
|
||||
c = httpx.Client(base_url=web, follow_redirects=False, timeout=30)
|
||||
|
||||
# --- админ: первый запуск
|
||||
r = c.get("/")
|
||||
assert r.status_code == 200 and "Первый запуск" in r.text
|
||||
ok("setup page")
|
||||
r = c.post("/setup", data={"login": "teach", "password": "teacher-123", "password2": "teacher-123"})
|
||||
assert r.status_code == 303
|
||||
ok("admin created")
|
||||
r = c.get("/t/labs", follow_redirects=True)
|
||||
assert r.status_code == 200 and "Лабораторные работы" in r.text
|
||||
ok("admin lands on labs list")
|
||||
|
||||
h = httpx.Client(base_url=api, timeout=30)
|
||||
tok = h.post("/auth/login", json={"login": "teach", "password": "teacher-123"}).json()["token"]
|
||||
H = {"Authorization": f"Bearer {tok}"}
|
||||
assert h.get("/groups", headers=H).status_code == 200
|
||||
assert h.get("/groups").status_code == 401
|
||||
ok("api auth enforced")
|
||||
|
||||
# --- студенты из групп
|
||||
for fname in ("ПИ-11БО.xls", "ПИ-12БО.xls"):
|
||||
with open(ROOT / "examples" / fname, "rb") as f:
|
||||
r = c.post("/t/groups/import", files={"file": (fname, f, "application/vnd.ms-excel")})
|
||||
loc = urllib.parse.unquote(r.headers["location"])
|
||||
assert "добавлено 20" in loc, loc
|
||||
gid1 = c.get("/t/groups").text.split("/t/groups/")[1].split('"')[0]
|
||||
ok("two groups imported (20+20 students)")
|
||||
|
||||
r = c.get(f"/t/groups/{gid1}/credentials.csv")
|
||||
rows = [l for l in r.text.splitlines() if l.strip()][1:]
|
||||
pwmap = {row.split(";")[1]: row.split(";")[2] for row in rows}
|
||||
assert len(pwmap) == 20
|
||||
ok("credentials export with generated passwords")
|
||||
|
||||
# --- лабораторная из JSON (только варианты)
|
||||
raw_lab = (ROOT / "examples" / "lab1.json").read_text(encoding="utf-8")
|
||||
r = c.post("/t/labs/new/import", files={"file": ("lab1.json", raw_lab.encode(), "application/json")})
|
||||
assert r.status_code == 200 and "Вариантов" in r.text
|
||||
from html import unescape as _un
|
||||
vj = _un(_re.search(r'name="variants_json" value="([^"]*)"', r.text).group(1))
|
||||
r = c.post("/t/labs/new", data={"variants_json": vj})
|
||||
assert r.status_code == 303, r.text[:200]
|
||||
lid = r.headers["location"].split("/labs/")[1].split("/")[0]
|
||||
assert "вариантов: 30" in urllib.parse.unquote(r.headers["location"])
|
||||
ok("lab created from JSON with 30 variants")
|
||||
|
||||
# --- уровни и распределение
|
||||
s3 = dbq("SELECT s.id_student FROM group_students gs JOIN students s ON s.id_student=gs.student_id "
|
||||
"WHERE gs.group_id=? ORDER BY CAST(s.id_student AS INTEGER) LIMIT 3", int(gid1))
|
||||
for st in s3:
|
||||
c.post(f"/t/groups/{gid1}/levels", data={"student_id": st["id_student"], "level": "advanced"})
|
||||
gids = [r["id"] for r in dbq("SELECT id FROM groups ORDER BY id")]
|
||||
for gid in gids:
|
||||
c.post(f"/t/groups/{gid}/assign", data={"lab_id": lid})
|
||||
a1 = dbq("SELECT sa.level, la.number FROM student_assignment sa "
|
||||
"JOIN group_students gs ON gs.group_id=? AND gs.student_id=sa.student_id "
|
||||
"LEFT JOIN lab_assignments la ON la.student_id=sa.student_id AND la.lab_id=?",
|
||||
int(gid1), int(lid))
|
||||
adv = [x["number"] for x in a1 if x["level"] == "advanced"]
|
||||
main = [x["number"] for x in a1 if x["level"] == "main"]
|
||||
assert sorted(int(x) for x in adv) == [21, 22, 23], adv
|
||||
assert len(main) == 17 and len(set(main)) == 17 and all(1 <= int(x) <= 20 for x in main), main
|
||||
ok("variants distributed: unique within group, pool 1..20 / 21..30")
|
||||
|
||||
man_st = next(x for x in dbq("SELECT student_id, level FROM student_assignment WHERE group_id=?",
|
||||
int(gid1)) if x["level"] == "main")["student_id"]
|
||||
c.post(f"/t/groups/{gid1}/manual", data={"student_id": man_st, "lab_id": lid, "number": "5"})
|
||||
m = dbq("SELECT number, manual FROM lab_assignments WHERE student_id=? AND lab_id=?",
|
||||
man_st, int(lid))[0]
|
||||
assert m["number"] == "5" and m["manual"] == 1
|
||||
ok("manual variant per lab")
|
||||
|
||||
# --- студент: вход, смена пароля, условие в личном кабинете
|
||||
pw = pwmap[man_st]
|
||||
r = c.post("/login", data={"login": man_st, "password": pw})
|
||||
assert r.status_code == 303 and r.headers["location"].startswith("/change-password")
|
||||
c.post("/change-password", data={"old": pw, "new": "newpass-123", "new2": "newpass-123"})
|
||||
r = c.get("/s/", follow_redirects=True)
|
||||
assert r.status_code == 200 and "Мои лабораторные" in r.text
|
||||
r = c.get(f"/s/labs/{lid}", follow_redirects=True)
|
||||
assert "вариант 5" in r.text or "Вариант 5" in r.text
|
||||
assert "Отсортировать массив" in r.text
|
||||
ok("student sees own variant statement after login")
|
||||
|
||||
# --- доступы
|
||||
noauth = httpx.Client(base_url=web, follow_redirects=False, timeout=15)
|
||||
assert noauth.get("/t/labs").status_code == 303
|
||||
c.post("/logout")
|
||||
r = c.post("/login", data={"login": "teach", "password": "teacher-123"})
|
||||
assert r.status_code == 303
|
||||
ok("auth enforced")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user