From bbbff6180f01eb55b7adcdc6a161748a27ca7cc3 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Thu, 10 Sep 2026 01:03:12 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=D0=B0=D1=80=D0=B8?= =?UTF-8?q?=D0=B0=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 13 + .env.example | 12 + .gitignore | 9 + Dockerfile | 25 ++ Makefile | 40 +++ README.md | 108 ++++++ deploy.sh | 24 ++ docker-compose.yml | 41 +++ requirements-dev.txt | 2 + requirements.txt | 8 + src/backend/lms_api/__init__.py | 32 ++ src/backend/lms_api/core/__init__.py | 0 src/backend/lms_api/core/db.py | 188 ++++++++++ src/backend/lms_api/core/schemas.py | 91 +++++ src/backend/lms_api/core/security.py | 27 ++ src/backend/lms_api/core/tokens.py | 32 ++ src/backend/lms_api/core/util.py | 17 + src/backend/lms_api/core/variants.py | 196 +++++++++++ src/backend/lms_api/core/xls_import.py | 90 +++++ src/backend/lms_api/core/zips.py | 64 ++++ src/backend/lms_api/main.py | 11 + src/backend/lms_api/routers/auth.py | 114 +++++++ src/backend/lms_api/routers/deps.py | 82 +++++ src/backend/lms_api/routers/groups.py | 314 +++++++++++++++++ src/backend/lms_api/routers/labs.py | 192 +++++++++++ src/backend/lms_api/routers/me.py | 68 ++++ src/frontend/lms_web/__init__.py | 58 ++++ src/frontend/lms_web/client.py | 105 ++++++ src/frontend/lms_web/ctx.py | 79 +++++ src/frontend/lms_web/main.py | 11 + src/frontend/lms_web/routers/__init__.py | 0 src/frontend/lms_web/routers/auth_pages.py | 119 +++++++ src/frontend/lms_web/routers/student_pages.py | 42 +++ src/frontend/lms_web/routers/teacher_pages.py | 322 ++++++++++++++++++ src/frontend/lms_web/static/app.js | 63 ++++ src/frontend/lms_web/static/style.css | 204 +++++++++++ src/frontend/lms_web/templates/base.html | 34 ++ .../lms_web/templates/change_password.html | 17 + src/frontend/lms_web/templates/error.html | 5 + src/frontend/lms_web/templates/login.html | 14 + .../lms_web/templates/s_dashboard.html | 26 ++ src/frontend/lms_web/templates/s_lab.html | 42 +++ src/frontend/lms_web/templates/setup.html | 17 + src/frontend/lms_web/templates/t_group.html | 151 ++++++++ src/frontend/lms_web/templates/t_groups.html | 58 ++++ src/frontend/lms_web/templates/t_lab.html | 57 ++++ .../lms_web/templates/t_lab_form.html | 41 +++ src/frontend/lms_web/templates/t_labs.html | 38 +++ src/frontend/lms_web/util.py | 31 ++ tests/smoke_test.py | 203 +++++++++++ 50 files changed, 3537 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100755 deploy.sh create mode 100644 docker-compose.yml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 src/backend/lms_api/__init__.py create mode 100644 src/backend/lms_api/core/__init__.py create mode 100644 src/backend/lms_api/core/db.py create mode 100644 src/backend/lms_api/core/schemas.py create mode 100644 src/backend/lms_api/core/security.py create mode 100644 src/backend/lms_api/core/tokens.py create mode 100644 src/backend/lms_api/core/util.py create mode 100644 src/backend/lms_api/core/variants.py create mode 100644 src/backend/lms_api/core/xls_import.py create mode 100644 src/backend/lms_api/core/zips.py create mode 100644 src/backend/lms_api/main.py create mode 100644 src/backend/lms_api/routers/auth.py create mode 100644 src/backend/lms_api/routers/deps.py create mode 100644 src/backend/lms_api/routers/groups.py create mode 100644 src/backend/lms_api/routers/labs.py create mode 100644 src/backend/lms_api/routers/me.py create mode 100644 src/frontend/lms_web/__init__.py create mode 100644 src/frontend/lms_web/client.py create mode 100644 src/frontend/lms_web/ctx.py create mode 100644 src/frontend/lms_web/main.py create mode 100644 src/frontend/lms_web/routers/__init__.py create mode 100644 src/frontend/lms_web/routers/auth_pages.py create mode 100644 src/frontend/lms_web/routers/student_pages.py create mode 100644 src/frontend/lms_web/routers/teacher_pages.py create mode 100644 src/frontend/lms_web/static/app.js create mode 100644 src/frontend/lms_web/static/style.css create mode 100644 src/frontend/lms_web/templates/base.html create mode 100644 src/frontend/lms_web/templates/change_password.html create mode 100644 src/frontend/lms_web/templates/error.html create mode 100644 src/frontend/lms_web/templates/login.html create mode 100644 src/frontend/lms_web/templates/s_dashboard.html create mode 100644 src/frontend/lms_web/templates/s_lab.html create mode 100644 src/frontend/lms_web/templates/setup.html create mode 100644 src/frontend/lms_web/templates/t_group.html create mode 100644 src/frontend/lms_web/templates/t_groups.html create mode 100644 src/frontend/lms_web/templates/t_lab.html create mode 100644 src/frontend/lms_web/templates/t_lab_form.html create mode 100644 src/frontend/lms_web/templates/t_labs.html create mode 100644 src/frontend/lms_web/util.py create mode 100644 tests/smoke_test.py 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()