commit bbbff6180f01eb55b7adcdc6a161748a27ca7cc3 Author: Vladislav Kopnin Date: Thu Sep 10 01:03:12 2026 +0300 Распределение вариантов diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4742af3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitignore +.idea +.venv +**/__pycache__ +**/*.pyc +*.log +.env +.env.local +deploy.sh +Makefile +tests +examples diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ac737e6 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90371fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.venv/ +**/*__pycache__ +*.py[cod] +.pytest_cache/ +**/*.idea +**/*.env +data/ +*.xls.bak +/.idea/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a2a6fb2 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..153fd85 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e0302d --- /dev/null +++ b/README.md @@ -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/ +``` diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..8f576f8 --- /dev/null +++ b/deploy.sh @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..29bf1bf --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..26b77f6 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1d9c640 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi +uvicorn +jinja2 +python-multipart +itsdangerous +xlrd +openpyxl +httpx diff --git a/src/backend/lms_api/__init__.py b/src/backend/lms_api/__init__.py new file mode 100644 index 0000000..a84eba9 --- /dev/null +++ b/src/backend/lms_api/__init__.py @@ -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 diff --git a/src/backend/lms_api/core/__init__.py b/src/backend/lms_api/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/lms_api/core/db.py b/src/backend/lms_api/core/db.py new file mode 100644 index 0000000..0b96e9e --- /dev/null +++ b/src/backend/lms_api/core/db.py @@ -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), + ) diff --git a/src/backend/lms_api/core/schemas.py b/src/backend/lms_api/core/schemas.py new file mode 100644 index 0000000..e963728 --- /dev/null +++ b/src/backend/lms_api/core/schemas.py @@ -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 = "" diff --git a/src/backend/lms_api/core/security.py b/src/backend/lms_api/core/security.py new file mode 100644 index 0000000..402f6ac --- /dev/null +++ b/src/backend/lms_api/core/security.py @@ -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)) diff --git a/src/backend/lms_api/core/tokens.py b/src/backend/lms_api/core/tokens.py new file mode 100644 index 0000000..c4309fa --- /dev/null +++ b/src/backend/lms_api/core/tokens.py @@ -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 diff --git a/src/backend/lms_api/core/util.py b/src/backend/lms_api/core/util.py new file mode 100644 index 0000000..da39bba --- /dev/null +++ b/src/backend/lms_api/core/util.py @@ -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) diff --git a/src/backend/lms_api/core/variants.py b/src/backend/lms_api/core/variants.py new file mode 100644 index 0000000..f752da9 --- /dev/null +++ b/src/backend/lms_api/core/variants.py @@ -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) diff --git a/src/backend/lms_api/core/xls_import.py b/src/backend/lms_api/core/xls_import.py new file mode 100644 index 0000000..c73353c --- /dev/null +++ b/src/backend/lms_api/core/xls_import.py @@ -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} diff --git a/src/backend/lms_api/core/zips.py b/src/backend/lms_api/core/zips.py new file mode 100644 index 0000000..ef71fda --- /dev/null +++ b/src/backend/lms_api/core/zips.py @@ -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 diff --git a/src/backend/lms_api/main.py b/src/backend/lms_api/main.py new file mode 100644 index 0000000..2a7d7a3 --- /dev/null +++ b/src/backend/lms_api/main.py @@ -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"))) diff --git a/src/backend/lms_api/routers/auth.py b/src/backend/lms_api/routers/auth.py new file mode 100644 index 0000000..3249447 --- /dev/null +++ b/src/backend/lms_api/routers/auth.py @@ -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} diff --git a/src/backend/lms_api/routers/deps.py b/src/backend/lms_api/routers/deps.py new file mode 100644 index 0000000..55ce163 --- /dev/null +++ b/src/backend/lms_api/routers/deps.py @@ -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) diff --git a/src/backend/lms_api/routers/groups.py b/src/backend/lms_api/routers/groups.py new file mode 100644 index 0000000..29c4614 --- /dev/null +++ b/src/backend/lms_api/routers/groups.py @@ -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')}"}, + ) diff --git a/src/backend/lms_api/routers/labs.py b/src/backend/lms_api/routers/labs.py new file mode 100644 index 0000000..e3e3bc0 --- /dev/null +++ b/src/backend/lms_api/routers/labs.py @@ -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} diff --git a/src/backend/lms_api/routers/me.py b/src/backend/lms_api/routers/me.py new file mode 100644 index 0000000..eacd9f9 --- /dev/null +++ b/src/backend/lms_api/routers/me.py @@ -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) diff --git a/src/frontend/lms_web/__init__.py b/src/frontend/lms_web/__init__.py new file mode 100644 index 0000000..129a7e1 --- /dev/null +++ b/src/frontend/lms_web/__init__.py @@ -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 diff --git a/src/frontend/lms_web/client.py b/src/frontend/lms_web/client.py new file mode 100644 index 0000000..6b9877c --- /dev/null +++ b/src/frontend/lms_web/client.py @@ -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) diff --git a/src/frontend/lms_web/ctx.py b/src/frontend/lms_web/ctx.py new file mode 100644 index 0000000..3f2744a --- /dev/null +++ b/src/frontend/lms_web/ctx.py @@ -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""" + +
Сервис lms-web: {msg}
+

Код: {code}

""", + status_code=code, + ) diff --git a/src/frontend/lms_web/main.py b/src/frontend/lms_web/main.py new file mode 100644 index 0000000..baff667 --- /dev/null +++ b/src/frontend/lms_web/main.py @@ -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"))) diff --git a/src/frontend/lms_web/routers/__init__.py b/src/frontend/lms_web/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/frontend/lms_web/routers/auth_pages.py b/src/frontend/lms_web/routers/auth_pages.py new file mode 100644 index 0000000..7990177 --- /dev/null +++ b/src/frontend/lms_web/routers/auth_pages.py @@ -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) diff --git a/src/frontend/lms_web/routers/student_pages.py b/src/frontend/lms_web/routers/student_pages.py new file mode 100644 index 0000000..e92e7bc --- /dev/null +++ b/src/frontend/lms_web/routers/student_pages.py @@ -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) diff --git a/src/frontend/lms_web/routers/teacher_pages.py b/src/frontend/lms_web/routers/teacher_pages.py new file mode 100644 index 0000000..0769a29 --- /dev/null +++ b/src/frontend/lms_web/routers/teacher_pages.py @@ -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')}"}) diff --git a/src/frontend/lms_web/static/app.js b/src/frontend/lms_web/static/app.js new file mode 100644 index 0000000..1c4642f --- /dev/null +++ b/src/frontend/lms_web/static/app.js @@ -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(); +} diff --git a/src/frontend/lms_web/static/style.css b/src/frontend/lms_web/static/style.css new file mode 100644 index 0000000..43ff012 --- /dev/null +++ b/src/frontend/lms_web/static/style.css @@ -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; } diff --git a/src/frontend/lms_web/templates/base.html b/src/frontend/lms_web/templates/base.html new file mode 100644 index 0000000..1b1a5bf --- /dev/null +++ b/src/frontend/lms_web/templates/base.html @@ -0,0 +1,34 @@ + + + + + + {% block title %}ЛМС{% endblock %} — Практические занятия + + + + + +{% if user %} + +{% endif %} +
+{% if flash %}
{{ flash }}
{% endif %} +{% if error %}
{{ error }}
{% endif %} +{% block content %}{% endblock %} +
+ + diff --git a/src/frontend/lms_web/templates/change_password.html b/src/frontend/lms_web/templates/change_password.html new file mode 100644 index 0000000..1c70c29 --- /dev/null +++ b/src/frontend/lms_web/templates/change_password.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Смена пароля{% endblock %} +{% block content %} +
+

Смена пароля

+ {% if must_change %}
Первый вход: необходимо сменить стартовый пароль.
{% endif %} +
+
+
+
+
+
+
+ +
+
+{% endblock %} diff --git a/src/frontend/lms_web/templates/error.html b/src/frontend/lms_web/templates/error.html new file mode 100644 index 0000000..c14399f --- /dev/null +++ b/src/frontend/lms_web/templates/error.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} +{% block title %}Ошибка{% endblock %} +{% block content %} +

Ошибка

{{ error or 'Неизвестная ошибка' }}
+{% endblock %} diff --git a/src/frontend/lms_web/templates/login.html b/src/frontend/lms_web/templates/login.html new file mode 100644 index 0000000..4e0a0e9 --- /dev/null +++ b/src/frontend/lms_web/templates/login.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Вход{% endblock %} +{% block content %} +
+

Вход в систему

+
+
+
+
+
+ +
+
+{% endblock %} diff --git a/src/frontend/lms_web/templates/s_dashboard.html b/src/frontend/lms_web/templates/s_dashboard.html new file mode 100644 index 0000000..c825fed --- /dev/null +++ b/src/frontend/lms_web/templates/s_dashboard.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Мои работы{% endblock %} +{% block content %} +

Мои лабораторные работы

+
+ + + + {% for l in labs %} + + + + + + + {% else %} + + {% endfor %} + +
№РаботаМой вариантСрок
{{ l.number }}{{ l.title }} + {% if l.variant_label %}{{ l.variant_label }} + {% elif l.deadline_passed %}не назначен + {% else %}не назначен{% endif %} + {{ deadline_view(l.deadline) }}
Лабораторных работ пока нет.
+
+{% endblock %} diff --git a/src/frontend/lms_web/templates/s_lab.html b/src/frontend/lms_web/templates/s_lab.html new file mode 100644 index 0000000..5239ce5 --- /dev/null +++ b/src/frontend/lms_web/templates/s_lab.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block title %}ЛР{{ lab.number }}{% endblock %} +{% block content %} +

ЛР{{ lab.number }}. {{ lab.title }}

+
+ Срок: {{ deadline_view(lab.deadline) }} + Баллов: до {{ lab.points_max }} +
+ +
+

Мой вариант

+ {% set v = lab.variant %} + {% if v %} +
+ {{ 'Основная группа' if v.level == 'main' else 'Повышенная сложность' }}, вариант {{ v.number }} +
+

Условие: {{ v.statement }}

+ {% if v.notes %}

Указания к выполнению:
{{ v.notes }}

{% endif %} + {% if v.examples %} +

Примеры входных/выходных данных

+ + + {% for ex in v.examples %} + + + + + + {% endfor %} +
ВходОжидаемый выходПояснение
{{ ex.get('input', '') }}
{{ ex.get('output', '') }}
{{ ex.get('explanation', '') }}
+ {% endif %} + {% elif lab.assigned %} +

Вариант назначен, но в этой работе такого номера нет в условиях. Напишите преподавателю.

+ {% else %} + Вариант пока не назначен + {% endif %} +
+ +{% if lab.goal %}

Цель работы

{{ lab.goal }}

{% endif %} +{% if lab.common_requirements %}

Общие требования

{{ lab.common_requirements }}
{% endif %} +{% if lab.submission_instructions %}

Как сдавать

{{ lab.submission_instructions }}
{% endif %} +{% endblock %} diff --git a/src/frontend/lms_web/templates/setup.html b/src/frontend/lms_web/templates/setup.html new file mode 100644 index 0000000..e161f83 --- /dev/null +++ b/src/frontend/lms_web/templates/setup.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Настройка{% endblock %} +{% block content %} +
+

Первый запуск

+

Создайте аккаунт преподавателя (единственный администратор).

+
+
+
+
+
+
+
+ +
+
+{% endblock %} diff --git a/src/frontend/lms_web/templates/t_group.html b/src/frontend/lms_web/templates/t_group.html new file mode 100644 index 0000000..c195d7a --- /dev/null +++ b/src/frontend/lms_web/templates/t_group.html @@ -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 %} + +
+
+

Группа {{ group.name }}

+
+ {{ students|length }} студентов + Основная: {{ n_main }} + Повышенная: {{ n_adv }} + {%- if unassigned is not none and unassigned %} + {{ unassigned }} без варианта + {%- endif %} +
+
+ +
+ +
+
+
+
+

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

+

Номера берутся из вариантов выбранной лабораторной и раздаются студентам группы: + уникальны внутри группы, по возможности — между группами; если вариантов меньше, чем студентов, повторение допускается.

+
+
+ {% if labs %} +
+ + +
+ {% else %} +

Пока нет ни одной лабораторной с загруженными вариантами — + загрузите условия.

+ {% endif %} +
+
+ +
+
+
+

Студенты

+ {%- if labs %} +
+ Варианты: + +
+ {%- endif %} +
+
+ + + + + + + + + + + + + {% for st in students %} + {% set la = (st.lab_numbers or {}).get(lab_id_str) if lab_id else none %} + + + + + + + + + {% else %} + + {% endfor %} + +
№ студенческогоФИОУровеньВариантПароль
{{ st.id_student }} +
{{ st.surname }} {{ st.first_name }} {{ st.middle_name }}
+ {% if st.basis %}
{{ st.basis }}
{% endif %} +
+
+ + + +
+
+ {% if not labs %} + — + {% elif lab_id %} + {% if la and la.number %} + {{ la.number }} + {% if la.manual %}вручную{% endif %} + {% else %}не назначен{% endif %} + {% else %} + {{ (st.lab_numbers or {}) | length }} из {{ labs | length }} + {% endif %} + + {% if st.must_change %}стартовый + {% else %}сменён{% endif %} + +
+ +
+
+ Студентов нет — импортируйте список на главной. +
+ + {% if students and labs %} +
+
+ + + + +
+ Ручное назначение варианта для конкретного студента. +
+ {% endif %} +
+{% endblock %} diff --git a/src/frontend/lms_web/templates/t_groups.html b/src/frontend/lms_web/templates/t_groups.html new file mode 100644 index 0000000..d199303 --- /dev/null +++ b/src/frontend/lms_web/templates/t_groups.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% block title %}Группы{% endblock %} +{% block content %} +
+
+

Группы

+
Импорт списка, уровни, распределение вариантов, выгрузка паролей
+
+
+
+
+ +
+ + + + + + {% for g in groups %} + + + + + + + {% else %} + + {% endfor %} + +
ГруппаСтудентовБез варианта{% if groups and groups[0].unassigned_lab_number %} (ЛР {{ groups[0].unassigned_lab_number }}){% endif %}
{{ g.name }}{{ g.cnt }} + {% if g.unassigned is none %}варианты не загружены + {% elif g.unassigned %}{{ g.unassigned }} + {% else %}все{% endif %} + + Логины (xlsx) · + пароли (csv) +
Групп пока нет — импортируйте список.
+
+ +
+
+

Импорт списка группы

+

Группа определяется по названию в шапке файла. Существующие студенты обновляются, новым генерируются пароли.

+
+
+ +
+
+
+

Создать группу вручную

+

Пустая группа: студентов можно будет добавить импортом в неё.

+
+ + +
+
+
+{% endblock %} diff --git a/src/frontend/lms_web/templates/t_lab.html b/src/frontend/lms_web/templates/t_lab.html new file mode 100644 index 0000000..5c1d71a --- /dev/null +++ b/src/frontend/lms_web/templates/t_lab.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}ЛР{{ lab.number }}. {{ lab.title }}{% endblock %} +{% block content %} +
+
+

ЛР{{ lab.number }}. {{ lab.title }}

+
+ Срок: {{ deadline_view(lab.deadline) }} + Макс. баллов: {{ lab.points_max }} +
+
+
+
+ Обновить из JSON +
+
+ + +
+
+
+ К списку +
+ +
+
+
+ +{% if lab.goal %}

Цель работы

{{ lab.goal }}

{% endif %} +{% if lab.common_requirements %}

Общие требования

{{ lab.common_requirements }}
{% endif %} +{% if lab.submission_instructions %}

Как сдавать

{{ lab.submission_instructions }}
{% endif %} + +{% for vl in [("main", variants_main, "Основная группа"), ("advanced", variants_advanced, "Повышенная сложность")] %} +
+

{{ vl[2] }} ({{ vl[1] | length }})

+ {% for v in vl[1] %} +
+ Вариант {{ v.number }}. {{ v.statement }} + {% if v.notes %}

Указания: {{ v.notes }}

{% endif %} + {% if v.examples %} + + + {% for ex in v.examples %} + + + + {% endfor %} +
ВходВыходПояснение
{{ ex.get('input', '') }}
{{ ex.get('output', '') }}
{{ ex.get('explanation', '') }}
+ {% endif %} +
+ {% else %} +

Вариантов этого уровня пока нет.

+ {% endfor %} +
+{% endfor %} +{% endblock %} diff --git a/src/frontend/lms_web/templates/t_lab_form.html b/src/frontend/lms_web/templates/t_lab_form.html new file mode 100644 index 0000000..47df926 --- /dev/null +++ b/src/frontend/lms_web/templates/t_lab_form.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}Новая лабораторная{% endblock %} +{% block content %} +

Новая лабораторная

+ +
+
+

Создание из JSON

+

Загрузите один файл: название/номер работы и массив «variants» + (основная группа — номера с level: main, повышенная — level: advanced).

+
+
+ + +
+
+ +{% if parsed %} +
+

Проверьте и создайте

+ + + + + + + +
№НазваниеВариантов
{{ parsed.lab.get('number', '—') }}{{ parsed.lab.get('title', '—') }} + {% set mains = parsed.variants | selectattr('level', 'equalto', 'main') | list %} + {% set advs = parsed.variants | selectattr('level', 'equalto', 'advanced') | list %} + всего {{ parsed.variants | length }} + осн. {{ mains | length }} + повыш. {{ advs | length }} +
+
+ + +
+
+{% endif %} +{% endblock %} diff --git a/src/frontend/lms_web/templates/t_labs.html b/src/frontend/lms_web/templates/t_labs.html new file mode 100644 index 0000000..b2d3c41 --- /dev/null +++ b/src/frontend/lms_web/templates/t_labs.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}Лабораторные{% endblock %} +{% block content %} +
+
+

Лабораторные работы

+
Загрузите условия и варианты работы одним JSON-файлом
+
+ +
+ +
+ + + + {% for l in labs %} + + + + + + + + {% else %} + + {% endfor %} + +
№НазваниеСрокВариантов (осн. + повыш.)
{{ l.number }}{{ l.title }}{{ deadline_view(l.deadline) }} + {% if not l.n_main and not l.n_adv %}варианты не загружены + {% else %}{{ l.n_main }} + {{ l.n_adv }}{% endif %} + + условия → +
Лабораторных нет — + добавьте первую из JSON.
+
+{% endblock %} diff --git a/src/frontend/lms_web/util.py b/src/frontend/lms_web/util.py new file mode 100644 index 0000000..b4afc02 --- /dev/null +++ b/src/frontend/lms_web/util.py @@ -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)}" diff --git a/tests/smoke_test.py b/tests/smoke_test.py new file mode 100644 index 0000000..4ea90bc --- /dev/null +++ b/tests/smoke_test.py @@ -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()