Phase 7: deployment, data import, UI hardening and security fixes
Docker/prod deployment:
- multi-stage Dockerfile (JDK21 build, JRE21 run), docker-compose.prod.yml,
nginx config for lms.our-place.ru, deploy.sh; DEPLOY/IMPORT guides (UI flow)
- Fix Docker 401: LMS_JWT_JWK_SET_URI for academic/progress; forward-headers
Variant distribution (lab page only):
- round-robin by student level, shared random queue across selected groups,
least-used variants, manual pins always preserved; occupied/dup labels
- setup page: single form + one «Сохранить всё», batch level/variant for
selected students (checkboxes)
Grades, defense, materials:
- publication gate + comment lists; defense CSV import (2/3 cols, quotes)
- additional task after defense ("начать заново"); enroll/unenroll students
from group page; temporary-password lock (no navigation until changed) and
persistent "Смена пароля"; forced change once
- chapter page /learn/{c}; header: LMS home, Войти, user -> dashboard,
compact theme toggle; responsive mobile header
- markdown: single newlines are soft breaks; highlight.js with light/dark themes
- lab/content imports ZIP-only (lab.yaml+README+variants/assets,
chapters tree+assets); duplicate lab numbers rejected; variant ordering
and date defaults honored
Fixes:
- 500 on student create (@MapsId set before persist); broken dialogs/JS refs in group page
This commit is contained in:
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
||||
.git
|
||||
.gradle
|
||||
.kotlin
|
||||
.idea
|
||||
build
|
||||
**/build
|
||||
logs
|
||||
data
|
||||
oldLMS
|
||||
.env
|
||||
.env.prod
|
||||
node_modules
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.iml
|
||||
*.log
|
||||
.gradle/
|
||||
14
.env.prod.example
Normal file
14
.env.prod.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Production-настройки LMS. Скопируйте в .env.prod и заполните секреты.
|
||||
# Этот файл НЕ коммитить (см. .gitignore).
|
||||
|
||||
LMS_DB_NAME=lms
|
||||
LMS_DB_USER=lms
|
||||
# Обязательно смените на длинный случайный пароль:
|
||||
LMS_DB_PASSWORD=change-me-to-a-long-random-string
|
||||
|
||||
# Порт web на loopback (его проксирует nginx).
|
||||
LMS_WEB_PORT=8090
|
||||
|
||||
# Домен (используется в deploy-скрипте/nginx).
|
||||
LMS_DOMAIN=lms.our-place.ru
|
||||
LETSENCRYPT_EMAIL=admin@our-place.ru
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -4,8 +4,13 @@ build/
|
||||
|
||||
### LMS ###
|
||||
.env
|
||||
.env.prod
|
||||
/data/
|
||||
node_modules/
|
||||
# Каталог с боевыми данными и паролями (не коммитить!)
|
||||
examples/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Reference implementation, kept as a separate nested repository.
|
||||
oldLMS/
|
||||
|
||||
64
Dockerfile
Normal file
64
Dockerfile
Normal file
@@ -0,0 +1,64 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# Единый multi-stage Dockerfile для всего LMS.
|
||||
# Сборка идёт внутри образа (Gradle + JDK 21), поэтому на сервере
|
||||
# достаточно Docker — устанавливать Java не требуется.
|
||||
#
|
||||
# Использование (см. docker-compose.prod.yml):
|
||||
# docker build --target web -t lms-web .
|
||||
# docker build --target identity -t lms-identity .
|
||||
|
||||
# ---------- Стадия сборки ----------
|
||||
FROM eclipse-temurin:21-jdk AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Сначала копируем файлы сборки — слой кэшируется отдельно от исходников.
|
||||
COPY gradlew gradlew
|
||||
COPY gradle gradle
|
||||
COPY settings.gradle.kts gradle.properties ./
|
||||
COPY buildSrc buildSrc
|
||||
RUN chmod +x gradlew
|
||||
|
||||
# Исходники модулей.
|
||||
COPY libs libs
|
||||
COPY services services
|
||||
COPY web web
|
||||
|
||||
RUN --mount=type=cache,target=/root/.gradle \
|
||||
./gradlew --no-daemon bootJar -x test
|
||||
|
||||
# ---------- Runtime-стадии (только JRE) ----------
|
||||
FROM eclipse-temurin:21-jre AS identity
|
||||
WORKDIR /app
|
||||
ENV TZ=Europe/Moscow JAVA_OPTS="-Xmx320m"
|
||||
COPY --from=build /src/services/identity-service/build/libs/identity-service-*.jar app.jar
|
||||
EXPOSE 8081
|
||||
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
|
||||
|
||||
FROM eclipse-temurin:21-jre AS academic
|
||||
WORKDIR /app
|
||||
ENV TZ=Europe/Moscow JAVA_OPTS="-Xmx384m"
|
||||
COPY --from=build /src/services/academic-service/build/libs/academic-service-*.jar app.jar
|
||||
EXPOSE 8082
|
||||
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
|
||||
|
||||
FROM eclipse-temurin:21-jre AS progress
|
||||
WORKDIR /app
|
||||
ENV TZ=Europe/Moscow JAVA_OPTS="-Xmx384m"
|
||||
COPY --from=build /src/services/progress-service/build/libs/progress-service-*.jar app.jar
|
||||
EXPOSE 8083
|
||||
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
|
||||
|
||||
FROM eclipse-temurin:21-jre AS gateway
|
||||
WORKDIR /app
|
||||
ENV TZ=Europe/Moscow JAVA_OPTS="-Xmx256m"
|
||||
COPY --from=build /src/services/gateway/build/libs/gateway-*.jar app.jar
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
|
||||
|
||||
FROM eclipse-temurin:21-jre AS web
|
||||
WORKDIR /app
|
||||
ENV TZ=Europe/Moscow JAVA_OPTS="-Xmx320m"
|
||||
COPY --from=build /src/web/build/libs/web-*.jar app.jar
|
||||
EXPOSE 8090
|
||||
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
|
||||
67
README.md
67
README.md
@@ -38,6 +38,33 @@
|
||||
Порты по умолчанию: gateway `:8080`, identity `:8081`, academic `:8082`,
|
||||
progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
|
||||
|
||||
## Запуск в Docker (для сервера)
|
||||
|
||||
На сервере Java не нужна: всё собирается и работает в контейнерах (multi-stage
|
||||
`Dockerfile`, сборка на JDK 21, запуск на JRE 21).
|
||||
|
||||
```bash
|
||||
cp .env.prod.example .env.prod # заполнить LMS_DB_PASSWORD
|
||||
./deploy/deploy.sh up # сборка + запуск (postgres, 5 сервисов)
|
||||
./deploy/deploy.sh logs web
|
||||
./deploy/deploy.sh ps
|
||||
./deploy/deploy.sh down
|
||||
```
|
||||
|
||||
Наружу публикуется только web на `127.0.0.1:8090`; reverse proxy — nginx
|
||||
(конфиг и полная инструкция по деплою на `lms.our-place.ru` в
|
||||
[`deploy/DEPLOY.md`](deploy/DEPLOY.md) и `deploy/nginx/lms.our-place.ru.conf`).
|
||||
|
||||
## Наполнение данными
|
||||
|
||||
Порядок ввода данных (всё через админку): `/setup` → администратор; импорт групп
|
||||
(`.xls`, логин = студенческий номер, временный пароль со сменой при первом входе);
|
||||
импорт лабораторной ZIP'ом (`lab.yaml` + `README.md` + `variants/*.md`); «Распределить»
|
||||
— случайная раздача по кругам в рамках уровня; оценки/защита — на странице группы;
|
||||
учебные материалы — статьи или ZIP (`chapters/…`, доступны гостям).
|
||||
|
||||
Подробный пошаговый порядок — в [`deploy/IMPORT.md`](deploy/IMPORT.md).
|
||||
|
||||
## Текущий функционал
|
||||
|
||||
Открыть `http://localhost:8090`. При первом запуске создаётся аккаунт
|
||||
@@ -46,15 +73,24 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
|
||||
- **Доступ**: JWT (RSA, ключи identity генерирует при первом старте и хранит в БД),
|
||||
проверка токена на gateway, роли ADMIN/STUDENT, CSRF на всех POST-формах,
|
||||
session fixation-защита; вход студента с обязательной сменой пароля.
|
||||
- **Группы и студенты**: создание groups, импорт `.xls/.xlsx/.csv`, ручное
|
||||
добавление студента (генерация пароля), выгрузка логинов XLSX/CSV, сброс пароля;
|
||||
на странице группы — баллы по каждой лабе и средний балл.
|
||||
- **Лабораторные**: импорт ZIP (`lab.yaml` + `README.md` + `variants/*.md` c
|
||||
front-matter) и старого JSON; создание через UI с произвольным числом уровней;
|
||||
просмотр/редактирование условия (markdown), вариантов, дат; статусы
|
||||
draft/published (клик по бейджу меняет); даты выводятся в московском времени.
|
||||
- **Распределение вариантов**: по уровню студента на лабу (по умолчанию — самый
|
||||
простой), round-robin с уникальностью в группе; ручное назначение варианта.
|
||||
- **Группы и студенты**: создание групп, импорт `.xls/.xlsx/.csv`, ручное
|
||||
добавление студента (генерация пароля) и «Добавить существующего» (перевод из
|
||||
другой группы), «Отчислить из группы», выгрузка логинов XLSX/CSV, сброс пароля.
|
||||
- **Лабораторные**: импорт ZIP (`lab.yaml` + `README.md` + `variants/*.md` +
|
||||
`assets/`); создание через UI с произвольным числом уровней; единая форма
|
||||
настроек с одной кнопкой «Сохранить всё»; статусы draft/published
|
||||
(клик по бейджу меняет); даты в московском времени.
|
||||
- **Распределение вариантов** (только на странице лабы): «Распределить» с
|
||||
выбором групп; раздача случайная и равномерная — студенты выбранных групп
|
||||
идут общей очередью, вариант берётся со своего уровня, пока в уровне есть
|
||||
невыданные — без повторов, затем выдаются наименее занятые; ручные
|
||||
закрепления сохраняются всегда. На странице лабы — список назначений по
|
||||
группам (уровень/вариант каждому), массовые операции чекбоксами, индикаторы
|
||||
«выдано/всего». Матрица на странице группы — просмотр (вариант, сдача) +
|
||||
оценки/защита, управление раздачей оттуда убрано.
|
||||
- **Индивидуальные задания (защита)**: на странице группы — назначение/изменение
|
||||
задания, импорт CSV (2 или 3 колонки, с кавычками и `\n`), выдача кода,
|
||||
«доп. задание: начать заново» после завершения защиты.
|
||||
- **Материалы** (`/learn`, публичная читалка): главы → разделы → статьи в
|
||||
markdown (код, спойлеры, картинки; санитизация OWASP); импорт ZIP
|
||||
(`chapters/N-глава/N-раздел/N-статья.md` + `assets/`); создание/переименование/
|
||||
@@ -62,9 +98,16 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
|
||||
- **Студент**: дашборд успеваемости (сдано, средний балл, лучшая/слабая оценка),
|
||||
карточки опубликованных лаб, страница лабы: условие, свой вариант, сдача
|
||||
файлов (zip/.c, переотправка до дедлайна, история, скачивание последней),
|
||||
комментарий преподавателя к оценке, защита по одноразовому коду (30 мин).
|
||||
- **Комментарии к оценкам**: тональность (pos/neu/neg), хранятся в БД,
|
||||
автоподсказки при вводе ранжируются по частоте использования; видны студенту чипсами.
|
||||
оценка и комментарии отдельным блоком, защита по одноразовому коду (30 мин).
|
||||
- **Публикация оценок**: в группе по каждой лабе — кнопка «опубликовать»; студент
|
||||
видит оценку и комментарии только после публикации (публикуется вся группа).
|
||||
- **Комментарии к оценкам**: тональность (positive/negative/neutral), на странице
|
||||
студента — ненумерованными списками в трёх колонках, хранятся в БД,
|
||||
автоподсказки при вводе ранжируются по частоте использования.
|
||||
- **Архив сдач группы**: одна кнопка «Скачать все работы» — ZIP со всеми
|
||||
последними решениями группы, разложенными по лабам и студентам.
|
||||
- **Материалы** `/learn` доступны без входа; аккордеон глав: свёрнуты все,
|
||||
открыта последняя.
|
||||
- UI: светлая/тёмная тема (сохраняется в localStorage), модальные окна вместо
|
||||
отдельных страниц, аккордеоны глав/разделов в `/learn`, фавиконка,
|
||||
адаптивная вёрстка без inline-стилей.
|
||||
|
||||
145
deploy/DEPLOY.md
Normal file
145
deploy/DEPLOY.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Деплой LMS на сервер `lms.our-place.ru`
|
||||
|
||||
Стек целиком запускается в Docker. **Java на сервере не устанавливается** — приложение
|
||||
собирается и работает внутри контейнеров (multi-stage `Dockerfile`: сборка на JDK 21,
|
||||
запуск на JRE 21).
|
||||
|
||||
Компоненты:
|
||||
|
||||
| Сервис | Контейнер | Наружу |
|
||||
|---|---|---|
|
||||
| web (Thymeleaf UI) | `lms-web` | `127.0.0.1:8090` (проксирует nginx) |
|
||||
| gateway | `lms-gateway` | нет |
|
||||
| identity / academic / progress | `lms-*` | нет |
|
||||
| PostgreSQL 18 | `lms-postgres` | нет |
|
||||
|
||||
Данные лежат в именованных volume: `lms_pgdata` (БД), `lms_submissions` (решения),
|
||||
`lms_assets` (картинки материалов).
|
||||
|
||||
---
|
||||
|
||||
## 0. Что нужно
|
||||
|
||||
- Сервер с Ubuntu 22.04/24.04 (или другим Linux с Docker), sudo/root.
|
||||
- Домен `lms.our-place.ru`, A-запись которого указывает на IP сервера.
|
||||
- Открытые порты 80 и 443 (443 — если настраиваете HTTPS).
|
||||
|
||||
## 1. Установка Docker
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
sudo usermod -aG docker "$USER" # перелогиньтесь, чтобы работать без sudo
|
||||
docker compose version # нужна версия v2
|
||||
```
|
||||
|
||||
## 2. Получить код
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/lms && sudo chown "$USER" /opt/lms
|
||||
git clone <URL-репозитория> /opt/lms
|
||||
cd /opt/lms
|
||||
```
|
||||
|
||||
## 3. Настроить окружение
|
||||
|
||||
```bash
|
||||
cp .env.prod.example .env.prod
|
||||
nano .env.prod
|
||||
```
|
||||
|
||||
Обязательно задайте надёжный `LMS_DB_PASSWORD`. Остальные значения по умолчанию подходят.
|
||||
|
||||
## 4. Собрать и запустить
|
||||
|
||||
```bash
|
||||
./deploy/deploy.sh up
|
||||
```
|
||||
|
||||
Скрипт соберёт образы (первый раз ~5–15 минут: скачиваются Gradle и зависимости)
|
||||
и поднимет контейнеры. Проверка:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.prod ps
|
||||
curl -fsS http://127.0.0.1:8090/actuator/health # {"status":"UP",...}
|
||||
```
|
||||
|
||||
## 5. Nginx + HTTPS
|
||||
|
||||
```bash
|
||||
sudo apt-get update && sudo apt-get install -y nginx certbot python3-certbot-nginx
|
||||
sudo mkdir -p /var/www/certbot
|
||||
|
||||
sudo cp deploy/nginx/lms.our-place.ru.conf /etc/nginx/sites-available/lms.our-place.ru.conf
|
||||
sudo ln -sf /etc/nginx/sites-available/lms.our-place.ru.conf /etc/nginx/sites-enabled/lms.our-place.ru.conf
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
sudo certbot --nginx -d lms.our-place.ru --redirect -m admin@our-place.ru --agree-tos
|
||||
```
|
||||
|
||||
`certbot --nginx` сам добавит TLS-блок и редирект с HTTP на HTTPS. Автопродление
|
||||
уже настроено пакетом (`systemctl status certbot.timer`).
|
||||
|
||||
## 6. Первый запуск приложения
|
||||
|
||||
Откройте `https://lms.our-place.ru` → откроется `/setup` → создайте администратора.
|
||||
|
||||
## 6a. Наполнение данными
|
||||
|
||||
Дальше всё через интерфейс: импорт групп (`.xls`), импорт лабораторной ZIP'ом,
|
||||
раздача вариантов, оценки и защита, материалы. Подробный порядок с проверками —
|
||||
в [`IMPORT.md`](IMPORT.md). Файлы со списками студентов и выгрузками паролей —
|
||||
персональные данные, держите их вне репозитория.
|
||||
|
||||
## 7. Обновление версии
|
||||
|
||||
```bash
|
||||
cd /opt/lms
|
||||
git pull
|
||||
./deploy/deploy.sh up # пересоберёт изменённые образы и перезапустит
|
||||
```
|
||||
|
||||
Миграции БД (Flyway) применяются автоматически при старте сервисов.
|
||||
|
||||
## 8. Резервное копирование
|
||||
|
||||
```bash
|
||||
# БД
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.prod exec -T postgres \
|
||||
pg_dump -U "$LMS_DB_USER" "$LMS_DB_NAME" | gzip > lms-$(date +%F).sql.gz
|
||||
|
||||
# Файлы (решения и материалы)
|
||||
docker run --rm -v lms_lms_submissions:/sub -v lms_lms_assets:/assets -v "$PWD":/backup alpine \
|
||||
tar czf /backup/lms-files-$(date +%F).tar.gz -C / sub assets
|
||||
```
|
||||
|
||||
Восстановление БД: `gunzip -c backup.sql.gz | docker compose ... exec -T postgres psql -U lms lms`.
|
||||
|
||||
## 9. Диагностика
|
||||
|
||||
```bash
|
||||
./deploy/deploy.sh ps
|
||||
./deploy/deploy.sh logs web
|
||||
./deploy/deploy.sh logs gateway
|
||||
./deploy/deploy.sh logs identity
|
||||
```
|
||||
|
||||
Типичные проблемы:
|
||||
|
||||
- **web отдаёт 502 через nginx** — контейнер `web` не поднялся: смотрите `logs web`.
|
||||
Часто причина — занят порт 8090 или не задан `LMS_DB_PASSWORD` (compose не стартует).
|
||||
- **Страница входа открывается, но запросы падают** — проверьте `logs gateway` и
|
||||
`logs identity`; gateway должен получить JWKS от identity.
|
||||
- **401 на страницах после входа (Docker)** — у backend-сервисов не задан адрес
|
||||
JWKS: должно быть `LMS_JWT_JWK_SET_URI=http://identity:8081/api/v1/auth/jwks.json`
|
||||
(уже прописано в `docker-compose.prod.yml` для academic/progress/gateway).
|
||||
Внутри контейнера `localhost:8081` недоступен, поэтому токен не проверяется.
|
||||
- **Не проходят загрузки больших ZIP** — увеличьте `client_max_body_size` в nginx
|
||||
(сейчас 120 МБ) и `spring.servlet.multipart.max-request-size`.
|
||||
|
||||
## Замечания по безопасности
|
||||
|
||||
- PostgreSQL наружу не публикуется; связь между контейнерами — по внутренней сети `lms`.
|
||||
- Публикуется только `127.0.0.1:8090`, доступ снаружи — исключительно через nginx/TLS.
|
||||
- `.env.prod` содержит пароль БД и не коммитится (в `.gitignore`).
|
||||
- Пароль администратора задаётся вами на шаге 6; хранение — BCrypt.
|
||||
78
deploy/IMPORT.md
Normal file
78
deploy/IMPORT.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Порядок наполнения LMS данными (через интерфейс)
|
||||
|
||||
Всё делается в админке, порядок: администратор → группы и студенты → лабораторная →
|
||||
раздача вариантов → защита → материалы.
|
||||
|
||||
## Шаг 1. Деплой
|
||||
|
||||
См. [`DEPLOY.md`](DEPLOY.md): Docker + nginx + HTTPS. После старта сервисов
|
||||
откройте домен — попадёте на `/setup`, создайте администратора (логин и пароль
|
||||
храните в секрете, в репозиторий их не писать).
|
||||
|
||||
## Шаг 2. Группы и студенты
|
||||
|
||||
**Группы → Импорт** — загрузите `.xls`/`.xlsx`/`.csv` со списком группы
|
||||
(столбцы: «Студенческий номер», «Фамилия Имя Отчество», «Основа обучения»).
|
||||
Название группы берётся из файла или указывается при импорте. Логином студента
|
||||
становится студенческий номер, пароль генерируется (временный, с обязательной
|
||||
сменой при первом входе).
|
||||
|
||||
Дальше по каждому студенту при необходимости:
|
||||
* **«Сбросить пароль»** — выдать новый временный пароль;
|
||||
* **«Добавить существующего»** — перевести студента из другой группы;
|
||||
* **«Отчислить из группы»** — убрать из группы (аккаунт и данные сохраняются).
|
||||
|
||||
Выгрузить логины: **«Логины XLSX» / «CSV»**.
|
||||
|
||||
## Шаг 3. Лабораторная
|
||||
|
||||
**Лабораторные → Импорт** — ZIP со структурой:
|
||||
|
||||
```text
|
||||
lab.yaml # номер, название, points_max, уровни (code/title), сроки, статус
|
||||
README.md # общее условие (markdown)
|
||||
variants/01.md # вариант: front-matter (level, number, title) + текст задания
|
||||
```
|
||||
|
||||
Примеры — в личном рабочем каталоге (не в репозитории). После импорта на
|
||||
странице лабораторной:
|
||||
|
||||
* «Настроить» — срок сдачи, дата защиты, максимум баллов, статус;
|
||||
* уровни/варианты можно править и добавлять прямо там же.
|
||||
|
||||
## Шаг 4. Раздача вариантов
|
||||
|
||||
На странице лабораторной — **«Распределить»**: выбрать группы, при необходимости
|
||||
«пересоздать заново». Логика: случайная очередь по всем вариантам выбранного
|
||||
уровня; без повторов, пока в уровне есть невыданные варианты; закрепленные
|
||||
вручную не трогаются. Частично/точно — в списке назначений (уровень и вариант по
|
||||
каждому студенту) или массово чекбоксами.
|
||||
|
||||
## Шаг 5. Оценки и защита
|
||||
|
||||
На странице группы (матрица):
|
||||
|
||||
* **«опубликовать оценки»** — показать/скрыть оценки и комментарии студентам;
|
||||
* **«поставить оценку»** — балл + комментарии (позитивный/негативный/нейтральный);
|
||||
* раздел «Индивидуальные задания (защита)»: «Назначить задание» или импорт CSV
|
||||
(`студент;ЛР;задание` или `студент;задание` при выбранной ЛР), «выдать код»,
|
||||
«изменить», «текст», после готовности — «доп. задание: начать заново».
|
||||
|
||||
## Шаг 6. Учебные материалы
|
||||
|
||||
**Лабораторные → материалы (`/learn`)** доступны гостям. Для наполнения:
|
||||
создание глав/разделов/статей в админке или **импорт ZIP** со структурой:
|
||||
|
||||
```text
|
||||
chapters/01-глава/01-раздел/01-статья.md # front-matter: title, slug
|
||||
assets/ # картинки для md
|
||||
```
|
||||
|
||||
Материалы видны всем; для редактирования — «Материалы» с правами администратора.
|
||||
|
||||
## Замечания
|
||||
|
||||
* Данные в `*.xls`/PDF/PDF-пароли содержат персональные сведения — храните их во
|
||||
временном каталоге и не коммитьте в репозиторий.
|
||||
* Пароли студентов выдаются автоматически; временный пароль нужно менять при
|
||||
первом входе (до смены другие страницы недоступны).
|
||||
59
deploy/deploy.sh
Executable file
59
deploy/deploy.sh
Executable file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Деплой LMS в Docker на сервере. Java на хосте не требуется.
|
||||
#
|
||||
# ./deploy/deploy.sh # сборка + запуск/обновление
|
||||
# ./deploy/deploy.sh logs web # логи сервиса
|
||||
# ./deploy/deploy.sh ps
|
||||
# ./deploy/deploy.sh down # остановить (данные сохраняются в volume)
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
COMPOSE="docker compose -f docker-compose.prod.yml --env-file .env.prod"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then
|
||||
echo "Ошибка: нужен Docker с плагином compose v2." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f .env.prod ]]; then
|
||||
if [[ -f .env.prod.example ]]; then
|
||||
cp .env.prod.example .env.prod
|
||||
echo "Создан .env.prod из примера. ОБЯЗАТЕЛЬНО смените LMS_DB_PASSWORD и повторите запуск." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Не найден .env.prod" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cmd="${1:-up}"
|
||||
|
||||
case "$cmd" in
|
||||
up)
|
||||
echo ">> Сборка образов (Gradle внутри Docker, сервер Java не требует)..."
|
||||
$COMPOSE build
|
||||
echo ">> Запуск сервисов..."
|
||||
$COMPOSE up -d --remove-orphans
|
||||
echo
|
||||
$COMPOSE ps
|
||||
echo
|
||||
echo "Готово. Проверьте: curl -fsS http://127.0.0.1:${LMS_WEB_PORT:-8090}/actuator/health"
|
||||
echo "Первый запуск: откройте https://<домен>/setup и создайте администратора."
|
||||
;;
|
||||
logs)
|
||||
$COMPOSE logs -f --tail=200 "${2:-web}"
|
||||
;;
|
||||
ps)
|
||||
$COMPOSE ps
|
||||
;;
|
||||
down)
|
||||
$COMPOSE down
|
||||
;;
|
||||
restart)
|
||||
$COMPOSE restart "${2:-}"
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 [up|ps|logs <service>|restart [service]|down]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
67
deploy/nginx/lms.our-place.ru.conf
Normal file
67
deploy/nginx/lms.our-place.ru.conf
Normal file
@@ -0,0 +1,67 @@
|
||||
# Nginx для LMS на сервере lms.our-place.ru.
|
||||
#
|
||||
# Схема:
|
||||
# интернет -> nginx (80/443) -> 127.0.0.1:8090 (контейнер web)
|
||||
#
|
||||
# Установка (Debian/Ubuntu):
|
||||
# sudo cp deploy/nginx/lms.our-place.ru.conf /etc/nginx/sites-available/lms.our-place.ru.conf
|
||||
# sudo ln -sf /etc/nginx/sites-available/lms.our-place.ru.conf /etc/nginx/sites-enabled/lms.our-place.ru.conf
|
||||
# sudo nginx -t && sudo systemctl reload nginx
|
||||
# sudo certbot --nginx -d lms.our-place.ru # добавит TLS автоматически
|
||||
#
|
||||
# Ниже — HTTP-версия, достаточная и для первичного выпуска сертификата,
|
||||
# и для работы без TLS. После `certbot --nginx` блок server 443 появится сам.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name lms.our-place.ru;
|
||||
|
||||
# ACME-челлендж Let's Encrypt.
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# Большие загрузки: ZIP лаб, решения, материалы.
|
||||
client_max_body_size 120m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# Загрузка файлов может быть долгой.
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_connect_timeout 30s;
|
||||
}
|
||||
}
|
||||
|
||||
# --- Если TLS настраивается вручную (без certbot --nginx) ---
|
||||
# server {
|
||||
# listen 443 ssl;
|
||||
# listen [::]:443 ssl;
|
||||
# http2 on;
|
||||
# server_name lms.our-place.ru;
|
||||
#
|
||||
# ssl_certificate /etc/letsencrypt/live/lms.our-place.ru/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/lms.our-place.ru/privkey.pem;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
#
|
||||
# client_max_body_size 120m;
|
||||
#
|
||||
# location / {
|
||||
# proxy_pass http://127.0.0.1:8090;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# proxy_read_timeout 300s;
|
||||
# proxy_send_timeout 300s;
|
||||
# }
|
||||
# }
|
||||
116
docker-compose.prod.yml
Normal file
116
docker-compose.prod.yml
Normal file
@@ -0,0 +1,116 @@
|
||||
# Production-стек LMS: PostgreSQL 18 + 5 сервисов.
|
||||
# Java на сервере не нужна — всё собирается и запускается в контейнерах.
|
||||
#
|
||||
# Запуск:
|
||||
# cp .env.prod.example .env.prod # и заполнить секреты
|
||||
# docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build
|
||||
#
|
||||
# Наружу публикуется только web на 127.0.0.1:8090; его проксирует nginx (deploy/nginx).
|
||||
|
||||
name: lms
|
||||
|
||||
x-db-env: &db-env
|
||||
LMS_DB_USER: ${LMS_DB_USER:-lms}
|
||||
LMS_DB_PASSWORD: ${LMS_DB_PASSWORD:?Задайте LMS_DB_PASSWORD в .env.prod}
|
||||
|
||||
# Все backend-сервисы проверяют JWT по JWKS identity. В контейнерах это
|
||||
# http://identity:8081, а не localhost (иначе 401 на авторизованных запросах).
|
||||
x-jwt-env: &jwt-env
|
||||
LMS_JWT_JWK_SET_URI: http://identity:8081/api/v1/auth/jwks.json
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${LMS_DB_NAME:-lms}
|
||||
POSTGRES_USER: ${LMS_DB_USER:-lms}
|
||||
POSTGRES_PASSWORD: ${LMS_DB_PASSWORD:?Задайте LMS_DB_PASSWORD в .env.prod}
|
||||
volumes:
|
||||
- lms_pgdata:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${LMS_DB_USER:-lms} -d ${LMS_DB_NAME:-lms}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
networks: [lms]
|
||||
|
||||
identity:
|
||||
build: { context: ., target: identity }
|
||||
image: lms-identity:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
<<: *db-env
|
||||
LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=identity
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
networks: [lms]
|
||||
|
||||
academic:
|
||||
build: { context: ., target: academic }
|
||||
image: lms-academic:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
<<: [*db-env, *jwt-env]
|
||||
LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=academic
|
||||
LMS_CONTENT_ASSETS_DIR: /data/content-assets
|
||||
volumes:
|
||||
- lms_assets:/data/content-assets
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
identity: { condition: service_started }
|
||||
networks: [lms]
|
||||
|
||||
progress:
|
||||
build: { context: ., target: progress }
|
||||
image: lms-progress:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
<<: [*db-env, *jwt-env]
|
||||
LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=progress
|
||||
LMS_SUBMISSIONS_DIR: /data/submissions
|
||||
LMS_IDENTITY_URI: http://identity:8081
|
||||
LMS_ACADEMIC_URI: http://academic:8082
|
||||
volumes:
|
||||
- lms_submissions:/data/submissions
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
identity: { condition: service_started }
|
||||
academic: { condition: service_started }
|
||||
networks: [lms]
|
||||
|
||||
gateway:
|
||||
build: { context: ., target: gateway }
|
||||
image: lms-gateway:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LMS_IDENTITY_URI: http://identity:8081
|
||||
LMS_ACADEMIC_URI: http://academic:8082
|
||||
LMS_PROGRESS_URI: http://progress:8083
|
||||
LMS_JWT_JWK_SET_URI: http://identity:8081/api/v1/auth/jwks.json
|
||||
depends_on:
|
||||
identity: { condition: service_started }
|
||||
academic: { condition: service_started }
|
||||
progress: { condition: service_started }
|
||||
networks: [lms]
|
||||
|
||||
web:
|
||||
build: { context: ., target: web }
|
||||
image: lms-web:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LMS_GATEWAY_URL: http://gateway:8080
|
||||
SERVER_PORT: 8090
|
||||
ports:
|
||||
- "127.0.0.1:${LMS_WEB_PORT:-8090}:8090"
|
||||
depends_on:
|
||||
gateway: { condition: service_started }
|
||||
networks: [lms]
|
||||
|
||||
networks:
|
||||
lms:
|
||||
|
||||
volumes:
|
||||
lms_pgdata:
|
||||
lms_submissions:
|
||||
lms_assets:
|
||||
@@ -7,4 +7,6 @@ dependencies {
|
||||
|
||||
api(libs.springWeb)
|
||||
api(libs.springBootAutoconfigure)
|
||||
// Kotlin-модуль Jackson: корректная десериализация data-классов со значениями по умолчанию.
|
||||
api(libs.jacksonModuleKotlin)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ class MarkdownRenderer(
|
||||
),
|
||||
)
|
||||
set(HtmlRenderer.GENERATE_HEADER_ID, true)
|
||||
set(HtmlRenderer.SOFT_BREAK, "<br />\n")
|
||||
set(HtmlRenderer.ESCAPE_HTML, !allowHtml)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package ru.ysu.lms.academic.lab
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
|
||||
@@ -8,43 +7,25 @@ import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||
import org.springframework.stereotype.Component
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.math.BigDecimal
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
/**
|
||||
* Импорт лабораторной из ZIP: `lab.yaml` (метаданные и уровни), `README.md`
|
||||
* (условие), `variants/` (файлы вариантов с front-matter: level, number, title),
|
||||
* каталог `assets/` (картинки).
|
||||
*/
|
||||
@Component
|
||||
class LabImportParser {
|
||||
private val yaml = ObjectMapper(YAMLFactory())
|
||||
.registerModule(KotlinModule.Builder().build())
|
||||
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
|
||||
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
private val json = ObjectMapper()
|
||||
|
||||
fun parse(filename: String, bytes: ByteArray): LabImportBundle {
|
||||
val lower = filename.lowercase()
|
||||
if (!(lower.endsWith(".zip") || lower.endsWith(".json"))) {
|
||||
throw BadRequestException("Формат должен быть .zip (рекомендуется) или .json")
|
||||
if (!filename.lowercase().endsWith(".zip")) {
|
||||
throw BadRequestException("Формат импорта — .zip (lab.yaml + README.md + variants/)")
|
||||
}
|
||||
return if (lower.endsWith(".json")) {
|
||||
parseJson(bytes)
|
||||
} else {
|
||||
parseZip(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseJson(bytes: ByteArray): LabImportBundle {
|
||||
val tree = json.readTree(bytes)
|
||||
val meta = LabMeta(
|
||||
number = tree.path("number").asInt(),
|
||||
title = tree.path("title").asText(),
|
||||
pointsMax = BigDecimal.valueOf(tree.path("points_max").asDouble(5.0)),
|
||||
deadlineAt = tree.path("deadline").asText("").ifBlank { null },
|
||||
defenseAt = tree.path("defense_at").asText("").ifBlank { null },
|
||||
status = tree.path("status").asText("published"),
|
||||
levels = parseLevels(tree),
|
||||
)
|
||||
val descriptionMd = tree.path("common_requirements").asText("")
|
||||
val variants = parseVariantsFromTree(tree, meta.levels)
|
||||
return LabImportBundle(meta, descriptionMd, variants)
|
||||
return parseZip(bytes)
|
||||
}
|
||||
|
||||
private fun parseZip(bytes: ByteArray): LabImportBundle {
|
||||
@@ -90,42 +71,6 @@ class LabImportParser {
|
||||
return LabImportBundle(meta, readmeMd, variants, assets)
|
||||
}
|
||||
|
||||
private fun parseLevels(tree: JsonNode): List<LabLevelMeta> {
|
||||
val levels = tree.path("levels")
|
||||
if (levels.isArray && levels.size() > 0) {
|
||||
return levels.map { n ->
|
||||
LabLevelMeta(
|
||||
code = n.path("code").asText(),
|
||||
title = n.path("title").asText(""),
|
||||
description = n.path("description").asText(""),
|
||||
position = n.path("position").asInt(0),
|
||||
)
|
||||
}
|
||||
}
|
||||
return listOf(
|
||||
LabLevelMeta("basic", "Основная группа", position = 1),
|
||||
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseVariantsFromTree(tree: JsonNode, levels: List<LabLevelMeta>): List<VariantMeta> {
|
||||
val variants = tree.path("variants")
|
||||
if (!variants.isArray) return emptyList()
|
||||
val levelMap = levels.associateBy { it.code }
|
||||
return variants.map { v ->
|
||||
val levelCode = normalizeLevel(v.path("level").asText("basic"))
|
||||
if (!levelMap.containsKey(levelCode)) {
|
||||
throw BadRequestException("Неизвестный уровень: ${v.path("level").asText()}")
|
||||
}
|
||||
VariantMeta(
|
||||
level = levelCode,
|
||||
number = v.path("number").asText(),
|
||||
title = v.path("title").asText(""),
|
||||
statement = v.path("statement").asText(""),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseVariantFile(path: String, content: String, levels: List<LabLevelMeta>): VariantMeta? {
|
||||
val levelMap = levels.associateBy { it.code }
|
||||
val lines = content.lines()
|
||||
|
||||
@@ -35,6 +35,12 @@ class LabService(
|
||||
val semester = semesters.findFirstByActiveTrue()
|
||||
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
|
||||
|
||||
if (labs.existsByNumberAndSemesterId(bundle.meta.number, semester.id)) {
|
||||
throw ConflictException(
|
||||
"Лабораторная №${bundle.meta.number} уже существует. Удалите её перед повторным импортом.",
|
||||
)
|
||||
}
|
||||
|
||||
val lab = labs.save(Lab(
|
||||
number = bundle.meta.number,
|
||||
title = bundle.meta.title,
|
||||
@@ -42,6 +48,8 @@ class LabService(
|
||||
descriptionMd = bundle.descriptionMd,
|
||||
pointsMax = bundle.meta.pointsMax,
|
||||
status = bundle.meta.status,
|
||||
deadlineAt = parseInstant(bundle.meta.deadlineAt),
|
||||
defenseAt = parseInstant(bundle.meta.defenseAt),
|
||||
))
|
||||
|
||||
val levelMetas = bundle.meta.levels.ifEmpty {
|
||||
@@ -115,8 +123,10 @@ class LabService(
|
||||
defenseAt = lab.defenseAt?.toString(),
|
||||
pointsMax = lab.pointsMax,
|
||||
levels = levels.map { level ->
|
||||
// Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой).
|
||||
val vs = variantsRepo.findByLabIdOrderByNumber(lab.id)
|
||||
.filter { it.labLevel?.id == level.id }
|
||||
.sortedWith(compareBy({ naturalNumber(it.number) }, { it.number }))
|
||||
LabLevelDetail(
|
||||
id = level.id.toString(),
|
||||
code = level.code,
|
||||
@@ -287,6 +297,9 @@ class LabService(
|
||||
return semesters.save(Semester(title = t, year = y, active = true))
|
||||
}
|
||||
|
||||
private fun naturalNumber(value: String): Int =
|
||||
Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
|
||||
|
||||
private fun parseInstant(value: String?): Instant? {
|
||||
val v = value?.takeIf { it.isNotBlank() } ?: return null
|
||||
return runCatching { Instant.parse(v) }
|
||||
|
||||
@@ -20,7 +20,7 @@ spring:
|
||||
- id: progress
|
||||
uri: ${LMS_PROGRESS_URI:http://localhost:8083}
|
||||
predicates:
|
||||
- Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**,/api/v1/comment-templates,/api/v1/submissions/**
|
||||
- Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**,/api/v1/grade-publications/**,/api/v1/comment-templates,/api/v1/submissions/**
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
|
||||
@@ -14,6 +14,7 @@ interface EnrollmentRepository : JpaRepository<Enrollment, UUID> {
|
||||
fun findAllByGroupId(@Param("groupId") groupId: UUID): List<Enrollment>
|
||||
|
||||
fun findByStudent_AccountId(accountId: UUID): Enrollment?
|
||||
fun findFirstByStudent_AccountId(accountId: UUID): Enrollment?
|
||||
|
||||
fun deleteByStudent_AccountId(accountId: UUID)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
@@ -16,6 +17,8 @@ import org.springframework.web.multipart.MultipartFile
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@@ -46,6 +49,25 @@ class GroupController(private val groupService: GroupService) {
|
||||
return groupService.import(name, file.originalFilename ?: "group.xlsx", file.bytes)
|
||||
}
|
||||
|
||||
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
|
||||
@GetMapping("/{id}/candidates")
|
||||
fun candidates(@PathVariable id: UUID): List<StudentCandidate> = groupService.candidates(id)
|
||||
|
||||
/** Зачислить существующего студента. */
|
||||
@PostMapping("/{id}/students/{studentId}/enroll")
|
||||
fun enroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail =
|
||||
groupService.enroll(id, studentId)
|
||||
|
||||
/** Отчислить студента из группы. */
|
||||
@DeleteMapping("/{id}/students/{studentId}")
|
||||
fun unenroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail =
|
||||
groupService.unenroll(id, studentId)
|
||||
|
||||
@GetMapping("/me")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
fun myGroup(@AuthenticationPrincipal jwt: Jwt): List<GroupSummary> =
|
||||
groupService.groupOfStudent(UUID.fromString(jwt.subject))?.let { listOf(it) } ?: emptyList()
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun detail(@PathVariable id: UUID): GroupDetail = groupService.detail(id)
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ class GroupService(
|
||||
tempPassword = password,
|
||||
),
|
||||
)
|
||||
// Student.id — это account_id через @MapsId: ассоциацию задаём до persist.
|
||||
val student = students.save(
|
||||
Student(
|
||||
studentNo = studentNo,
|
||||
@@ -57,9 +58,9 @@ class GroupService(
|
||||
middleName = request.middleName.trim(),
|
||||
basis = request.basis.trim(),
|
||||
note = request.note.trim(),
|
||||
account = account,
|
||||
),
|
||||
)
|
||||
student.account = account
|
||||
enrollments.save(Enrollment(group = group, student = student))
|
||||
return Credential(
|
||||
id = account.id.toString(),
|
||||
@@ -78,6 +79,61 @@ class GroupService(
|
||||
return summary(groups.save(GroupEntity(name = name)))
|
||||
}
|
||||
|
||||
/** Студенты, которых ещё нет в этой группе (включая состоящих в других). */
|
||||
@Transactional(readOnly = true)
|
||||
fun candidates(groupId: UUID): List<StudentCandidate> {
|
||||
groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
val memberIds = enrollments.findAllByGroupId(groupId).mapNotNull { it.student?.accountId }.toSet()
|
||||
val currentByStudent = HashMap<UUID, Pair<UUID, String>>()
|
||||
for (e in enrollments.findAll()) {
|
||||
val sid = e.student?.accountId ?: continue
|
||||
val gid = e.group?.id ?: continue
|
||||
val name = e.group?.name ?: continue
|
||||
currentByStudent[sid] = gid to name
|
||||
}
|
||||
return students.findAll()
|
||||
.filter { it.accountId !in memberIds }
|
||||
.map { s ->
|
||||
val current = s.accountId?.let { currentByStudent[it] }
|
||||
StudentCandidate(
|
||||
id = s.accountId!!.toString(),
|
||||
fullName = s.fullName,
|
||||
studentNo = s.studentNo,
|
||||
currentGroupId = current?.first?.toString(),
|
||||
currentGroupName = current?.second,
|
||||
)
|
||||
}
|
||||
.sortedWith(compareBy({ it.fullName }, { it.studentNo }))
|
||||
}
|
||||
|
||||
/** Зачислить существующего студента; если он в другой группе — переводим. */
|
||||
@Transactional
|
||||
fun enroll(groupId: UUID, studentId: UUID): GroupDetail {
|
||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
val student = students.findById(studentId).orElseThrow { NotFoundException("Студент не найден") }
|
||||
val existing = enrollments.findByStudent_AccountId(studentId)
|
||||
if (existing != null) {
|
||||
if (existing.group?.id == group.id) {
|
||||
throw ConflictException("Студент уже в этой группе")
|
||||
}
|
||||
// unique-констрейт на student_id: переводим перепривязкой, без delete+insert
|
||||
existing.group = group
|
||||
enrollments.save(existing)
|
||||
} else {
|
||||
enrollments.save(Enrollment(group = group, student = student))
|
||||
}
|
||||
return detail(groupId)
|
||||
}
|
||||
|
||||
/** Отчислить из группы (студент и его аккаунт сохраняются). */
|
||||
@Transactional
|
||||
fun unenroll(groupId: UUID, studentId: UUID): GroupDetail {
|
||||
val enrollment = enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
|
||||
?: throw NotFoundException("Студент не входит в эту группу")
|
||||
enrollments.delete(enrollment)
|
||||
return detail(groupId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun import(requestedName: String?, filename: String, bytes: ByteArray): ImportResult {
|
||||
val parsed = parser.parse(filename, bytes)
|
||||
@@ -141,6 +197,17 @@ class GroupService(
|
||||
return ImportResult(group.id.toString(), group.name, added, updated)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun groupOfStudent(accountId: UUID): GroupSummary? {
|
||||
val group = enrollments.findByStudent_AccountId(accountId)?.group ?: return null
|
||||
return GroupSummary(
|
||||
id = group.id.toString(),
|
||||
name = group.name,
|
||||
studentCount = enrollments.findAllByGroupId(group.id).size.toLong(),
|
||||
taskDurationMin = group.taskDurationMin,
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun detail(groupId: UUID): GroupDetail {
|
||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
|
||||
@@ -59,6 +59,14 @@ data class ResetPasswordResponse(
|
||||
val password: String,
|
||||
)
|
||||
|
||||
data class StudentCandidate(
|
||||
val id: String,
|
||||
val fullName: String,
|
||||
val studentNo: String,
|
||||
val currentGroupId: String?,
|
||||
val currentGroupName: String?,
|
||||
)
|
||||
|
||||
class ExportFile(
|
||||
val bytes: ByteArray,
|
||||
val filename: String,
|
||||
|
||||
@@ -35,3 +35,8 @@ management:
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /v3/api-docs
|
||||
|
||||
lms:
|
||||
jwt:
|
||||
# Срок жизни access-токена; сессия web-BFF — 30 дней (см. web/application.yml).
|
||||
access-token-ttl: ${LMS_JWT_ACCESS_TOKEN_TTL:30d}
|
||||
|
||||
@@ -17,9 +17,13 @@ data class LevelAssignmentResponse(
|
||||
)
|
||||
|
||||
data class DistributeRequest(
|
||||
val labId: String,
|
||||
/** If true, re-assigns even existing manual assignments */
|
||||
val force: Boolean = false,
|
||||
val labId: String? = null,
|
||||
/** Можно передать несколько лабораторных за один вызов. */
|
||||
val labIds: List<String>? = null,
|
||||
/** Пересоздать варианты заново, сбросив текущие назначения. */
|
||||
val force: Boolean? = null,
|
||||
/** Ограничить группы; пусто — все группы. */
|
||||
val groupIds: List<String>? = null,
|
||||
)
|
||||
|
||||
data class VariantAssignmentResponse(
|
||||
@@ -31,10 +35,11 @@ data class VariantAssignmentResponse(
|
||||
)
|
||||
|
||||
data class DistributeResult(
|
||||
val labId: String,
|
||||
val labTitle: String,
|
||||
val labs: Int = 1,
|
||||
val assigned: Int,
|
||||
val total: Int,
|
||||
val labId: String? = null,
|
||||
val labTitle: String? = null,
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.progress.domain.GradePublication
|
||||
import ru.ysu.lms.progress.domain.GradePublicationKey
|
||||
import ru.ysu.lms.progress.repo.GradePublicationRepository
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class GradePublicationService(private val repo: GradePublicationRepository) {
|
||||
|
||||
@Transactional
|
||||
fun setPublished(groupId: UUID, labId: UUID, published: Boolean) {
|
||||
val existing = repo.findByLabIdAndGroupId(labId, groupId)
|
||||
if (published && existing == null) repo.save(GradePublication(groupId, labId))
|
||||
if (!published && existing != null) repo.delete(existing)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun isPublished(groupId: UUID, labId: UUID): Boolean =
|
||||
repo.findByLabIdAndGroupId(labId, groupId) != null
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun publishedGroupIdsForLab(labId: UUID): List<String> =
|
||||
repo.findByLabId(labId).map { it.groupId.toString() }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun publishedLabIdsForGroup(groupId: UUID): List<String> =
|
||||
repo.findByGroupId(groupId).map { it.labId.toString() }
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import java.util.UUID
|
||||
@RequestMapping("/api/v1")
|
||||
class ProgressController(
|
||||
private val service: ProgressService,
|
||||
private val publications: GradePublicationService,
|
||||
private val submissions: SubmissionService,
|
||||
private val academic: AcademicClient,
|
||||
) {
|
||||
@@ -36,8 +37,10 @@ class ProgressController(
|
||||
|
||||
@PostMapping("/levels")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun setLevel(@Valid @RequestBody body: LevelAssignmentRequest): LevelAssignmentResponse =
|
||||
service.setLevel(body)
|
||||
fun setLevel(
|
||||
@Valid @RequestBody body: LevelAssignmentRequest,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): LevelAssignmentResponse = service.setLevel(body, jwt.tokenValue)
|
||||
|
||||
@GetMapping("/levels")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@@ -80,9 +83,31 @@ class ProgressController(
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun listGrades(): List<GradeResponse> = service.listGrades()
|
||||
|
||||
@PostMapping("/grades/publish")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun publishGrades(@RequestParam labId: java.util.UUID, @RequestParam groupId: java.util.UUID, @RequestParam published: Boolean) {
|
||||
publications.setPublished(groupId, labId, published)
|
||||
}
|
||||
|
||||
@GetMapping("/grades/published")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun gradesPublished(@RequestParam("labId") labId: java.util.UUID, @RequestParam groupId: java.util.UUID): Boolean =
|
||||
publications.isPublished(groupId, labId)
|
||||
|
||||
@GetMapping("/grades/publications")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
fun groupPublications(@RequestParam groupId: java.util.UUID): List<String> =
|
||||
publications.publishedLabIdsForGroup(groupId)
|
||||
|
||||
@GetMapping("/me/grades")
|
||||
fun myGrades(@AuthenticationPrincipal jwt: Jwt): List<GradeResponse> =
|
||||
service.listGrades().filter { it.studentId == jwt.subject }
|
||||
fun myGrades(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
@RequestParam(required = false) groupId: java.util.UUID?,
|
||||
): List<GradeResponse> {
|
||||
val all = service.listGrades().filter { it.studentId == jwt.subject }
|
||||
if (groupId == null) return emptyList()
|
||||
return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId)) }
|
||||
}
|
||||
|
||||
@GetMapping("/comment-templates")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
|
||||
@@ -30,19 +30,39 @@ class ProgressService(
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun setLevel(request: LevelAssignmentRequest): LevelAssignmentResponse {
|
||||
val key = StudentLabLevelKey(
|
||||
UUID.fromString(request.studentId),
|
||||
UUID.fromString(request.labId),
|
||||
)
|
||||
fun setLevel(request: LevelAssignmentRequest, token: String): LevelAssignmentResponse {
|
||||
val sid = UUID.fromString(request.studentId)
|
||||
val lid = UUID.fromString(request.labId)
|
||||
val key = StudentLabLevelKey(sid, lid)
|
||||
val existing = levels.findById(key)
|
||||
val entity = existing.orElse(StudentLabLevel(
|
||||
studentId = UUID.fromString(request.studentId),
|
||||
labId = UUID.fromString(request.labId),
|
||||
))
|
||||
val entity = existing.orElse(StudentLabLevel(studentId = sid, labId = lid))
|
||||
entity.labLevelId = UUID.fromString(request.labLevelId)
|
||||
entity.manual = true
|
||||
levels.save(entity)
|
||||
|
||||
// Сразу подбираем вариант из выбранного уровня: админ видит результат,
|
||||
// а не «пустой» вариант до нажатия «Распределить».
|
||||
val lab = runCatching { academic.getLab(request.labId, token) }.getOrNull()
|
||||
val level = lab?.levels?.firstOrNull { it.id == request.labLevelId }
|
||||
if (level != null && level.variants.isNotEmpty()) {
|
||||
val assignment = assignments.findByStudentIdAndLabId(sid, lid)
|
||||
val currentInLevel = assignment?.variantId?.toString()
|
||||
?.let { vid -> level.variants.any { it.id == vid } } == true
|
||||
if (!currentInLevel) {
|
||||
val used = assignments.findByLabId(lid)
|
||||
.map { it.variantId.toString() }
|
||||
.filter { it != assignment?.variantId?.toString() }
|
||||
.toMutableSet()
|
||||
val pool = level.variants.map { it.id }
|
||||
val chosen = pool.filter { it !in used }.shuffled().firstOrNull()
|
||||
?: pool.shuffled().first()
|
||||
val target = (assignment ?: VariantAssignment(studentId = sid, labId = lid)).apply {
|
||||
variantId = UUID.fromString(chosen)
|
||||
manual = false
|
||||
}
|
||||
assignments.save(target)
|
||||
}
|
||||
}
|
||||
return LevelAssignmentResponse(
|
||||
studentId = request.studentId,
|
||||
labId = request.labId,
|
||||
@@ -61,73 +81,95 @@ class ProgressService(
|
||||
|
||||
@Transactional
|
||||
fun distribute(request: DistributeRequest, token: String): DistributeResult {
|
||||
val labId = UUID.fromString(request.labId)
|
||||
val lab = academic.getLab(request.labId, token)
|
||||
val levelMap = lab.levels.associateBy { it.id }
|
||||
val labIds = request.labIds.orEmpty().ifEmpty { listOfNotNull(request.labId) }
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
if (labIds.isEmpty()) throw BadRequestException("Не указана ни одна лабораторная")
|
||||
|
||||
if (request.force) {
|
||||
assignments.deleteByLabId(labId)
|
||||
}
|
||||
|
||||
val groups = identity.listGroups(token)
|
||||
val students = mutableListOf<Pair<UUID, UUID>>() // (studentId, labLevelId)
|
||||
for (g in groups) {
|
||||
val force = request.force == true
|
||||
val groupFilter = request.groupIds.orEmpty().filter { it.isNotBlank() }.toSet()
|
||||
val studentIds = mutableListOf<UUID>()
|
||||
for (g in identity.listGroups(token)) {
|
||||
val gid = g["id"] as String
|
||||
val group = identity.getGroup(gid, token)
|
||||
for (s in group.students) {
|
||||
val sid = UUID.fromString(s.id)
|
||||
val existingLevel = levels.findByStudentIdAndLabId(sid, labId)
|
||||
val levelId = if (existingLevel != null) {
|
||||
existingLevel.labLevelId
|
||||
} else {
|
||||
val levelInfo = lab.levels.firstOrNull() ?: continue
|
||||
UUID.fromString(levelInfo.id)
|
||||
if (groupFilter.isNotEmpty() && gid !in groupFilter) continue
|
||||
for (s in identity.getGroup(gid, token).students) {
|
||||
studentIds.add(UUID.fromString(s.id))
|
||||
}
|
||||
students.add(sid to levelId)
|
||||
}
|
||||
val allStudents = studentIds.distinct()
|
||||
|
||||
var assignedTotal = 0
|
||||
var totalStudents = 0
|
||||
var lastLabId: String? = null
|
||||
var lastLabTitle: String? = null
|
||||
|
||||
for (labIdStr in labIds) {
|
||||
val labId = UUID.fromString(labIdStr)
|
||||
val lab = academic.getLab(labIdStr, token)
|
||||
|
||||
// force сбрасывает авто-назначения только у выбранных студентов;
|
||||
// закреплённые вручную варианты остаются как есть.
|
||||
if (force) {
|
||||
assignments.findByLabId(labId)
|
||||
.filter { it.studentId in allStudents && !it.manual }
|
||||
.forEach { assignments.delete(it) }
|
||||
}
|
||||
|
||||
val assigned = mutableSetOf<String>()
|
||||
val variantIdsByLevel = lab.levels.associate { lv ->
|
||||
lv.id to lv.variants.map { it.id }
|
||||
}
|
||||
val kept = mutableMapOf<UUID, VariantAssignment>()
|
||||
assignedTotal += assignments.findByLabId(labId).count { it.studentId in allStudents }
|
||||
assignments.findByLabId(labId)
|
||||
.filter { it.studentId in allStudents }
|
||||
.forEach { kept[it.studentId] = it }
|
||||
|
||||
val result = mutableListOf<VariantAssignment>()
|
||||
for ((sid, levelId) in students) {
|
||||
val existing = assignments.findByStudentIdAndLabId(sid, labId)
|
||||
if (existing != null && existing.manual) {
|
||||
result.add(existing)
|
||||
assigned.add(existing.variantId.toString())
|
||||
continue
|
||||
}
|
||||
if (existing != null && !request.force) {
|
||||
result.add(existing)
|
||||
assigned.add(existing.variantId.toString())
|
||||
continue
|
||||
}
|
||||
val toAssign = allStudents.filterNot { kept.containsKey(it) }
|
||||
|
||||
val pool = variantIdsByLevel[levelId.toString()].orEmpty()
|
||||
// Уровень ограничивает пул: студент получает вариант только СВОЕГО уровня.
|
||||
// При этом очередь одна общая для всех выбранных групп: круг не
|
||||
// «перезапускается» на каждой группе, а продолжается по наименее
|
||||
// занятым вариантам уровня (пока хоть один не выдан — повторов нет).
|
||||
val poolsByLevel = lab.levels.associate { lv -> lv.id to lv.variants.map { it.id } }
|
||||
val defaultLevel = lab.levels.firstOrNull()?.id
|
||||
|
||||
if (toAssign.isNotEmpty() && poolsByLevel.isNotEmpty()) {
|
||||
val usage = assignments.findByLabId(labId)
|
||||
.groupingBy { it.variantId.toString() }
|
||||
.eachCount()
|
||||
.toMutableMap()
|
||||
|
||||
for (sid in toAssign.shuffled()) {
|
||||
val levelId = levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString()
|
||||
?: defaultLevel
|
||||
val pool = levelId?.let { poolsByLevel[it] }.orEmpty()
|
||||
.ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() }
|
||||
.ifEmpty { poolsByLevel.values.firstOrNull { it.isNotEmpty() }.orEmpty() }
|
||||
if (pool.isEmpty()) continue
|
||||
|
||||
val chosen = pool.firstOrNull { it !in assigned }
|
||||
?: pool.first()
|
||||
val entity = VariantAssignment(
|
||||
val minCount = pool.minOf { usage[it] ?: 0 }
|
||||
val chosen = pool.filter { (usage[it] ?: 0) == minCount }.random()
|
||||
assignments.save(
|
||||
VariantAssignment(
|
||||
studentId = sid,
|
||||
labId = labId,
|
||||
variantId = UUID.fromString(chosen),
|
||||
manual = false,
|
||||
),
|
||||
)
|
||||
assignments.save(entity)
|
||||
result.add(entity)
|
||||
assigned.add(chosen)
|
||||
usage[chosen] = (usage[chosen] ?: 0) + 1
|
||||
assignedTotal++
|
||||
}
|
||||
}
|
||||
|
||||
totalStudents += allStudents.size
|
||||
lastLabId = labIdStr
|
||||
lastLabTitle = lab.title
|
||||
}
|
||||
|
||||
val title = lab.levels.firstOrNull()?.let { lab.title } ?: lab.title
|
||||
return DistributeResult(
|
||||
labId = request.labId,
|
||||
labTitle = title,
|
||||
assigned = result.size,
|
||||
total = students.size,
|
||||
labs = labIds.size,
|
||||
assigned = assignedTotal,
|
||||
total = totalStudents,
|
||||
labId = if (labIds.size == 1) lastLabId else null,
|
||||
labTitle = if (labIds.size == 1) lastLabTitle else null,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package ru.ysu.lms.progress.domain
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.IdClass
|
||||
import jakarta.persistence.Table
|
||||
import java.io.Serializable
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@Table(name = "grade_publications")
|
||||
@IdClass(GradePublicationKey::class)
|
||||
class GradePublication(
|
||||
@Id @Column(name = "group_id", nullable = false) var groupId: UUID = UUID(0, 0),
|
||||
@Id @Column(name = "lab_id", nullable = false) var labId: UUID = UUID(0, 0),
|
||||
@Column(name = "published_at", nullable = false) var publishedAt: Instant = Instant.now(),
|
||||
)
|
||||
|
||||
data class GradePublicationKey(
|
||||
val groupId: UUID = UUID(0, 0),
|
||||
val labId: UUID = UUID(0, 0),
|
||||
) : Serializable
|
||||
@@ -0,0 +1,12 @@
|
||||
package ru.ysu.lms.progress.repo
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import ru.ysu.lms.progress.domain.GradePublication
|
||||
import ru.ysu.lms.progress.domain.GradePublicationKey
|
||||
import java.util.UUID
|
||||
|
||||
interface GradePublicationRepository : JpaRepository<GradePublication, GradePublicationKey> {
|
||||
fun findByLabIdAndGroupId(labId: UUID, groupId: UUID): GradePublication?
|
||||
fun findByLabId(labId: UUID): List<GradePublication>
|
||||
fun findByGroupId(groupId: UUID): List<GradePublication>
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Grade publication flags: a teacher publishes grades for a group per lab.
|
||||
|
||||
CREATE TABLE grade_publications (
|
||||
group_id uuid NOT NULL,
|
||||
lab_id uuid NOT NULL,
|
||||
published_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (group_id, lab_id)
|
||||
);
|
||||
@@ -117,30 +117,65 @@ class AdminController(
|
||||
): String {
|
||||
val t = token(session)
|
||||
val group = api.groupDetail(t, id)
|
||||
val labs = api.listLabs(t)
|
||||
val labs = api.listLabs(t).filter { it.status == "published" }
|
||||
val grades = api.listGrades(t)
|
||||
val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score }
|
||||
val commentsBy = grades.associate { "${it.studentId}|${it.labId}" to it.comments.map { ct -> ct.text to ct.tone } }
|
||||
val studentGrades = group.students.map { s ->
|
||||
val scores = labs.map { lab -> gradeBy["${s.id}|${lab.id}"] }
|
||||
val nums = scores.mapNotNull { it?.toBigDecimalOrNull() }
|
||||
AdminStudentGrades(
|
||||
val publishedLabs = java.util.HashSet(api.groupPublications(t, id))
|
||||
val assignmentsBy = labs.associateWith { lab -> api.getLabAssignments(t, lab.id).associateBy { it.studentId } }
|
||||
val submissionsBy = labs.associateWith { lab -> api.labSubmissions(t, lab.id).associateBy { it.studentId } }
|
||||
val defensesBy = labs.associateWith { lab -> api.labDefenses(t, lab.id).associateBy { it.studentId } }
|
||||
|
||||
val rows = group.students.map { s ->
|
||||
val comments = labs.map { lab -> commentsBy["${s.id}|${lab.id}"] ?: emptyList() }
|
||||
val cells = labs.map { lab ->
|
||||
val va = assignmentsBy.getValue(lab)[s.id]
|
||||
val sub = submissionsBy.getValue(lab)[s.id]
|
||||
val def = defensesBy.getValue(lab)[s.id]
|
||||
GroupLabCell(
|
||||
labId = lab.id, labNumber = lab.number, labTitle = lab.title,
|
||||
variantNumber = va?.variantNumber, variantManual = va?.manual == true,
|
||||
score = gradeBy["${s.id}|${lab.id}"],
|
||||
published = publishedLabs.contains(lab.id),
|
||||
submissionSeq = sub?.seq, submissionId = sub?.id,
|
||||
defenseStatus = def?.status, defenseCode = def?.code,
|
||||
defenseSecondsLeft = def?.secondsLeft,
|
||||
defenseAssigned = def?.assignmentMd?.isNotBlank() == true,
|
||||
)
|
||||
}
|
||||
GroupStudentRow(
|
||||
studentId = s.id,
|
||||
fullName = s.fullName,
|
||||
studentNo = s.studentNo,
|
||||
scores = scores,
|
||||
average = if (nums.isEmpty()) null else nums.map { it.toDouble() }.average(),
|
||||
comments = labs.map { lab -> commentsBy["${s.id}|${lab.id}"] ?: emptyList() },
|
||||
commentsJson = mapper.writeValueAsString(labs.map { lab ->
|
||||
(commentsBy["${"$"}{s.id}|${"$"}{lab.id}"] ?: emptyList()).map { (t, tone) -> mapOf("text" to t, "tone" to tone) }
|
||||
}),
|
||||
|
||||
basis = s.basis,
|
||||
mustChangePassword = s.mustChangePassword,
|
||||
cells = cells,
|
||||
variantsSummary = cells.filter { it.variantNumber != null }
|
||||
.joinToString(", ") { "ЛР ${it.labNumber}: ${it.variantNumber}" },
|
||||
commentsJson = mapper.writeValueAsString(comments.map { cs -> cs.map { (tx, tn) -> mapOf("text" to tx, "tone" to tn) } }),
|
||||
)
|
||||
}
|
||||
|
||||
val defenseRows = labs.flatMap { lab ->
|
||||
val byStudent = api.labDefenses(t, lab.id).associateBy { it.studentId }
|
||||
group.students.mapNotNull { s ->
|
||||
val d = byStudent[s.id] ?: return@mapNotNull null
|
||||
GroupDefenseRow(
|
||||
sessionId = d.id,
|
||||
studentId = s.id, fullName = s.fullName, studentNo = s.studentNo,
|
||||
labId = lab.id, labNumber = lab.number,
|
||||
status = d.status, code = d.code?.takeIf { d.status == "waiting" },
|
||||
assignmentMd = d.assignmentMd, secondsLeft = d.secondsLeft,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
model.addAttribute("group", group)
|
||||
model.addAttribute("labs", labs)
|
||||
model.addAttribute("studentGrades", studentGrades)
|
||||
model.addAttribute("commentOptions", api.suggestComments(t, ""))
|
||||
model.addAttribute("candidates", runCatching { api.groupCandidates(t, id) }.getOrDefault(emptyList()))
|
||||
model.addAttribute("studentGrades", rows)
|
||||
model.addAttribute("defenseRows", defenseRows)
|
||||
model.addAttribute("publishedLabIds", publishedLabs)
|
||||
return "admin/group"
|
||||
}
|
||||
|
||||
@@ -259,56 +294,57 @@ class AdminController(
|
||||
): String {
|
||||
val t = token(session)
|
||||
val lab = api.labDetail(t, id)
|
||||
val levelAssignments = api.getLevels(t, id)
|
||||
val variantAssignments = api.getLabAssignments(t, id)
|
||||
val submissionsByStudent = api.labSubmissions(t, id).associateBy { it.studentId }
|
||||
val defensesByStudent = api.labDefenses(t, id).associateBy { it.studentId }
|
||||
val assignments = api.getLabAssignments(t, id)
|
||||
val assignedCount = assignments.size
|
||||
val levelByStudent = api.getLevels(t, id).associateBy { it.studentId }
|
||||
val assignmentByStudent = assignments.associateBy { it.studentId }
|
||||
val variantUsage = java.util.HashMap<String, Int>()
|
||||
assignments.forEach { variantUsage[it.variantId] = (variantUsage[it.variantId] ?: 0) + 1 }
|
||||
val allVariants = lab.levels.flatMap { it.variants }
|
||||
val totalVariants = allVariants.size
|
||||
val occupiedVariants = allVariants.count { (variantUsage[it.id] ?: 0) > 0 }
|
||||
val defaultLevel = lab.levels.firstOrNull()
|
||||
|
||||
// Для каждой группы: студенты с их уровнем (по умолчанию — первый/самый простой) и вариантом
|
||||
val groupSections = api.listGroups(t).map { group ->
|
||||
val detail = api.groupDetail(t, group.id)
|
||||
val assignmentGroups = api.listGroups(t).map { g ->
|
||||
val detail = api.groupDetail(t, g.id)
|
||||
AdminLabGroup(
|
||||
groupName = group.name,
|
||||
groupName = g.name,
|
||||
students = detail.students.map { s ->
|
||||
val level = levelAssignments.firstOrNull { it.studentId == s.id }
|
||||
val levelId = level?.labLevelId ?: lab.levels.firstOrNull()?.id.orEmpty()
|
||||
val levelTitle = lab.levels.firstOrNull { it.id == levelId }?.title ?: "—"
|
||||
val va = variantAssignments.firstOrNull { it.studentId == s.id }
|
||||
val sub = submissionsByStudent[s.id]
|
||||
val levelState = levelByStudent[s.id]
|
||||
val level = lab.levels.firstOrNull { it.id == levelState?.labLevelId } ?: defaultLevel
|
||||
val va = assignmentByStudent[s.id]
|
||||
AdminLabStudent(
|
||||
studentId = s.id,
|
||||
fullName = s.fullName,
|
||||
studentNo = s.studentNo,
|
||||
levelId = levelId,
|
||||
levelTitle = levelTitle,
|
||||
levelChanged = level != null,
|
||||
levelId = level?.id ?: "",
|
||||
levelTitle = level?.title ?: "—",
|
||||
levelChanged = levelState?.manual == true,
|
||||
variantId = va?.variantId,
|
||||
variantNumber = va?.variantNumber,
|
||||
source = when {
|
||||
va == null -> "не распределён"
|
||||
va.manual -> "вручную"
|
||||
else -> "авто"
|
||||
},
|
||||
submissionId = sub?.id,
|
||||
submissionSeq = sub?.seq,
|
||||
defenseSessionId = defensesByStudent[s.id]?.id,
|
||||
defenseStatus = defensesByStudent[s.id]?.status,
|
||||
defenseCode = defensesByStudent[s.id]?.takeIf { it.status == "waiting" }?.code,
|
||||
defenseSecondsLeft = defensesByStudent[s.id]?.secondsLeft,
|
||||
defenseAssigned = defensesByStudent[s.id]?.assignmentMd?.isNotBlank() == true,
|
||||
adminAssignmentMd = defensesByStudent[s.id]?.assignmentMd ?: "",
|
||||
source = if (va?.manual == true) "вручную" else "авто",
|
||||
submissionId = submissionsByStudent[s.id]?.id,
|
||||
submissionSeq = submissionsByStudent[s.id]?.seq,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
model.addAttribute("lab", lab)
|
||||
model.addAttribute("groups", groupSections)
|
||||
model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd))
|
||||
model.addAttribute("deadlineMsk", ru.ysu.lms.web.DateUtils.formatMsk(lab.deadlineAt))
|
||||
model.addAttribute("defenseMsk", ru.ysu.lms.web.DateUtils.formatMsk(lab.defenseAt))
|
||||
model.addAttribute("deadlineLocal", ru.ysu.lms.web.DateUtils.localInput(lab.deadlineAt))
|
||||
model.addAttribute("defenseLocal", ru.ysu.lms.web.DateUtils.localInput(lab.defenseAt))
|
||||
model.addAttribute("defensesByStudent", defensesByStudent)
|
||||
model.addAttribute("assignedCount", assignedCount)
|
||||
model.addAttribute("submissionCount", submissionsByStudent.size)
|
||||
model.addAttribute("assignmentGroups", assignmentGroups)
|
||||
model.addAttribute("groups", api.listGroups(t))
|
||||
model.addAttribute("variantUsage", variantUsage)
|
||||
model.addAttribute("totalVariants", totalVariants)
|
||||
model.addAttribute("occupiedVariants", occupiedVariants)
|
||||
model.addAttribute("freeVariants", totalVariants - occupiedVariants)
|
||||
return "admin/lab"
|
||||
}
|
||||
|
||||
@@ -317,82 +353,395 @@ class AdminController(
|
||||
@PathVariable labId: String,
|
||||
@RequestParam studentId: String,
|
||||
@RequestParam assignmentMd: String,
|
||||
@RequestParam(required = false) groupId: String?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String = try {
|
||||
api.assignDefense(token(session), studentId, labId, assignmentMd)
|
||||
redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено")
|
||||
"redirect:/admin/labs/$labId"
|
||||
redirectToGroup(groupId, labId)
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/labs/$labId"
|
||||
redirectToGroup(groupId, labId)
|
||||
}
|
||||
|
||||
/** Назначение/правка индивидуального задания со страницы группы. */
|
||||
@PostMapping("/defenses/save")
|
||||
fun saveDefense(
|
||||
@RequestParam studentId: String,
|
||||
@RequestParam labId: String,
|
||||
@RequestParam assignmentMd: String,
|
||||
@RequestParam(required = false) groupId: String?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String = try {
|
||||
api.assignDefense(token(session), studentId, labId, assignmentMd)
|
||||
redirect.addFlashAttribute("notice", "Индивидуальное задание сохранено")
|
||||
redirectToGroup(groupId, labId)
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
redirectToGroup(groupId, labId)
|
||||
}
|
||||
|
||||
@PostMapping("/labs/{labId}/defense/import")
|
||||
fun importDefenses(
|
||||
@PathVariable labId: String,
|
||||
@RequestParam("file") file: MultipartFile,
|
||||
@RequestParam(required = false) groupId: String?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String {
|
||||
val t = token(session)
|
||||
return try {
|
||||
val map = mutableMapOf<String, String>()
|
||||
): String = importDefenses(token(session), labId, file, groupId, redirect)
|
||||
|
||||
/** Импорт CSV со страницы группы: лабораторная выбирается в форме или берётся из строки. */
|
||||
@PostMapping("/defenses/import")
|
||||
fun importDefensesGeneric(
|
||||
@RequestParam(required = false) labId: String?,
|
||||
@RequestParam("file") file: MultipartFile,
|
||||
@RequestParam(required = false) groupId: String?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String = importDefenses(token(session), labId, file, groupId, redirect)
|
||||
|
||||
private fun importDefenses(
|
||||
t: String,
|
||||
labId: String?,
|
||||
file: MultipartFile,
|
||||
groupId: String?,
|
||||
redirect: RedirectAttributes,
|
||||
): String = try {
|
||||
val studentByNo = mutableMapOf<String, String>()
|
||||
for (g in api.listGroups(t)) {
|
||||
api.groupDetail(t, g.id).students.forEach { map[it.studentNo] = it.id }
|
||||
api.groupDetail(t, g.id).students.forEach { studentByNo[it.studentNo] = it.id }
|
||||
}
|
||||
val text = String(file.bytes, Charsets.UTF_8).removePrefix("\uFEFF")
|
||||
val labByNumber = api.listLabs(t).associateBy { it.number.toString() }
|
||||
val rows = parseCsv(file.bytes)
|
||||
var count = 0
|
||||
for (raw in text.lineSequence()) {
|
||||
val line = raw.trim()
|
||||
if (line.isBlank()) continue
|
||||
if (line.contains("студен", ignoreCase = true) && line.contains(";")) continue
|
||||
val pair = line.split(";", limit = 2)
|
||||
if (pair.size < 2) continue
|
||||
val sid = map[pair[0].trim()] ?: continue
|
||||
val md = pair[1].trim().replace("\\n", "\n")
|
||||
if (md.isEmpty()) continue
|
||||
api.assignDefense(t, sid, labId, md)
|
||||
var skipped = 0
|
||||
for ((idx, rawCols) in rows.withIndex()) {
|
||||
val cells = rawCols.map { it.trim() }
|
||||
if (cells.all { it.isBlank() }) continue
|
||||
if (idx == 0 && looksLikeHeader(cells)) continue
|
||||
var sid: String? = null
|
||||
var lid: String? = labId?.takeIf { it.isNotBlank() }
|
||||
var md: String? = null
|
||||
when {
|
||||
cells.size >= 3 -> {
|
||||
sid = studentByNo[cells[0]]
|
||||
lid = labByNumber[cells[1]]?.id ?: lid
|
||||
md = cells.drop(2).joinToString("; ")
|
||||
}
|
||||
cells.size == 2 -> {
|
||||
sid = studentByNo[cells[0]]
|
||||
md = cells[1]
|
||||
}
|
||||
}
|
||||
if (sid == null || lid.isNullOrBlank() || md.isNullOrBlank()) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
api.assignDefense(t, sid, lid, md.replace("\\n", "\n"))
|
||||
count++
|
||||
}
|
||||
redirect.addFlashAttribute("notice", "Импортировано заданий: $count")
|
||||
"redirect:/admin/labs/$labId"
|
||||
val tail = if (skipped > 0) ", пропущено: $skipped" else ""
|
||||
redirect.addFlashAttribute("notice", "Импортировано заданий: $count$tail")
|
||||
redirectToGroup(groupId, labId)
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/labs/$labId"
|
||||
redirectToGroup(groupId, labId)
|
||||
}
|
||||
|
||||
private fun looksLikeHeader(cells: List<String>): Boolean {
|
||||
val joined = cells.joinToString(" ").lowercase()
|
||||
return joined.contains("студент") || joined.contains("student") ||
|
||||
joined.contains("фио") || joined.contains("задан") || joined.contains("assignment")
|
||||
}
|
||||
|
||||
/** Простой CSV: разделитель ; , или таб; поддержка кавычек и экранирования "" */
|
||||
private fun parseCsv(bytes: ByteArray): List<List<String>> {
|
||||
val text = String(bytes, Charsets.UTF_8).removePrefix("\uFEFF")
|
||||
val lines = text.lines().filter { it.isNotBlank() }
|
||||
val sample = lines.firstOrNull() ?: return emptyList()
|
||||
val delimiter = when {
|
||||
sample.contains('\t') -> '\t'
|
||||
sample.contains(';') -> ';'
|
||||
else -> ','
|
||||
}
|
||||
return lines.map { splitCsvLine(it, delimiter) }
|
||||
}
|
||||
|
||||
private fun splitCsvLine(line: String, delimiter: Char): List<String> {
|
||||
val out = mutableListOf<String>()
|
||||
val sb = StringBuilder()
|
||||
var inQuotes = false
|
||||
var i = 0
|
||||
while (i < line.length) {
|
||||
val c = line[i]
|
||||
if (inQuotes) {
|
||||
if (c == '"') {
|
||||
if (i + 1 < line.length && line[i + 1] == '"') {
|
||||
sb.append('"')
|
||||
i++
|
||||
} else {
|
||||
inQuotes = false
|
||||
}
|
||||
} else {
|
||||
sb.append(c)
|
||||
}
|
||||
} else {
|
||||
when (c) {
|
||||
'"' -> inQuotes = true
|
||||
delimiter -> {
|
||||
out.add(sb.toString())
|
||||
sb.setLength(0)
|
||||
}
|
||||
else -> sb.append(c)
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
out.add(sb.toString())
|
||||
return out
|
||||
}
|
||||
|
||||
@PostMapping("/defenses/{id}/issue-code")
|
||||
fun issueDefenseCode(
|
||||
@PathVariable id: String,
|
||||
@RequestParam labId: String,
|
||||
@RequestParam(required = false) groupId: String?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String = try {
|
||||
val code = api.issueDefenseCode(token(session), id)
|
||||
redirect.addFlashAttribute("notice", "Код для входа на защиту: $code")
|
||||
"redirect:/admin/labs/$labId"
|
||||
redirectToGroup(groupId, labId)
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/labs/$labId"
|
||||
redirectToGroup(groupId, labId)
|
||||
}
|
||||
|
||||
@PostMapping("/defenses/{id}/reset")
|
||||
fun resetDefenseSession(
|
||||
@PathVariable id: String,
|
||||
@RequestParam labId: String,
|
||||
@RequestParam(required = false) groupId: String?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String = try {
|
||||
api.resetDefense(token(session), id)
|
||||
redirect.addFlashAttribute("notice", "Сессия защиты сброшена в ожидание кода")
|
||||
redirectToGroup(groupId, labId)
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
redirectToGroup(groupId, labId)
|
||||
}
|
||||
|
||||
private fun redirectToGroup(groupId: String?, labId: String?): String = when {
|
||||
!groupId.isNullOrBlank() -> "redirect:/admin/groups/$groupId"
|
||||
!labId.isNullOrBlank() -> "redirect:/admin/labs/$labId"
|
||||
else -> "redirect:/admin/groups"
|
||||
}
|
||||
|
||||
@PostMapping("/groups/{gid}/set-level")
|
||||
fun setStudentLevel(
|
||||
@PathVariable gid: String,
|
||||
@RequestParam studentId: String,
|
||||
@RequestParam labId: String,
|
||||
@RequestParam labLevelId: String,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String = try {
|
||||
api.setLevel(token(session), LevelAssignPayload(studentId, labId, labLevelId))
|
||||
redirect.addFlashAttribute("notice", "Уровень обновлён — вариант подобран автоматически")
|
||||
"redirect:/admin/groups/$gid"
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/groups/$gid"
|
||||
}
|
||||
|
||||
@PostMapping("/groups/{gid}/pin-variant")
|
||||
fun pinStudentVariant(
|
||||
@PathVariable gid: String,
|
||||
@RequestParam studentId: String,
|
||||
@RequestParam labId: String,
|
||||
@RequestParam variantId: String,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String = try {
|
||||
val result = api.assignVariant(token(session), AssignVariantPayload(studentId, labId, variantId))
|
||||
redirect.addFlashAttribute("notice", "Закреплён вариант ${result.variantNumber}")
|
||||
"redirect:/admin/groups/$gid"
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/groups/$gid"
|
||||
}
|
||||
|
||||
@PostMapping("/groups/{gid}/distribute")
|
||||
fun distributeForGroup(
|
||||
@PathVariable gid: String,
|
||||
@RequestParam labId: String,
|
||||
@RequestParam(defaultValue = "false") force: Boolean,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String = try {
|
||||
val result = api.distribute(
|
||||
token(session),
|
||||
DistributeRequestPayload(labId = labId, force = force, groupIds = listOf(gid)),
|
||||
)
|
||||
redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}")
|
||||
"redirect:/admin/groups/$gid"
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/groups/$gid"
|
||||
}
|
||||
|
||||
/** Раздать варианты по всем опубликованным ЛР для одной группы. */
|
||||
@PostMapping("/groups/{gid}/distribute-all")
|
||||
fun distributeAllForGroup(
|
||||
@PathVariable gid: String,
|
||||
@RequestParam(defaultValue = "false") force: Boolean,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String = try {
|
||||
val t = token(session)
|
||||
val labs = api.listLabs(t).filter { it.status == "published" }
|
||||
if (labs.isEmpty()) {
|
||||
redirect.addFlashAttribute("error", "Нет опубликованных лабораторных")
|
||||
return "redirect:/admin/groups/$gid"
|
||||
}
|
||||
val result = api.distribute(
|
||||
t,
|
||||
DistributeRequestPayload(labIds = labs.map { it.id }, force = force, groupIds = listOf(gid)),
|
||||
)
|
||||
redirect.addFlashAttribute("notice", "Распределено по ${result.labs} ЛР: ${result.assigned} из ${result.total}")
|
||||
"redirect:/admin/groups/$gid"
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/groups/$gid"
|
||||
}
|
||||
|
||||
/** Массовая установка уровня выделенным студентам. */
|
||||
@PostMapping("/labs/{labId}/batch-level")
|
||||
fun batchLevel(
|
||||
@PathVariable labId: String,
|
||||
@RequestParam("studentIds") studentIds: List<String>,
|
||||
@RequestParam labLevelId: String,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String = try {
|
||||
val t = token(session)
|
||||
studentIds.filter { it.isNotBlank() }.forEach { sid ->
|
||||
api.setLevel(t, LevelAssignPayload(sid, labId, labLevelId))
|
||||
}
|
||||
redirect.addFlashAttribute("notice", "Уровень обновлён у ${studentIds.size} студент(ов)")
|
||||
"redirect:/admin/labs/$labId"
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/labs/$labId"
|
||||
}
|
||||
|
||||
/** Массовое закрепление конкретного варианта выделенным студентам. */
|
||||
@PostMapping("/labs/{labId}/batch-variant")
|
||||
fun batchVariant(
|
||||
@PathVariable labId: String,
|
||||
@RequestParam("studentIds") studentIds: List<String>,
|
||||
@RequestParam variantId: String,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String = try {
|
||||
val t = token(session)
|
||||
studentIds.filter { it.isNotBlank() }.forEach { sid ->
|
||||
api.assignVariant(t, AssignVariantPayload(sid, labId, variantId))
|
||||
}
|
||||
redirect.addFlashAttribute("notice", "Вариант закреплён у ${studentIds.size} студент(ов)")
|
||||
"redirect:/admin/labs/$labId"
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/labs/$labId"
|
||||
}
|
||||
|
||||
@PostMapping("/labs/{id}/distribute")
|
||||
fun distribute(
|
||||
@PathVariable id: String,
|
||||
@RequestParam(defaultValue = "false") force: Boolean,
|
||||
@RequestParam(required = false) groupIds: List<String>?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String = try {
|
||||
val result = api.distribute(
|
||||
token(session),
|
||||
DistributeRequestPayload(
|
||||
labId = id,
|
||||
force = force,
|
||||
groupIds = groupIds.orEmpty().filter { it.isNotBlank() },
|
||||
),
|
||||
)
|
||||
redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}")
|
||||
"redirect:/admin/labs/$id"
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/labs/$id"
|
||||
}
|
||||
|
||||
@GetMapping("/groups/{id}/submissions.zip")
|
||||
fun downloadGroupSubmissions(
|
||||
@PathVariable id: String,
|
||||
@RequestParam(required = false) labId: String?,
|
||||
session: HttpSession,
|
||||
): ResponseEntity<ByteArray> {
|
||||
val t = token(session)
|
||||
val group = api.groupDetail(t, id)
|
||||
val studentById = group.students.associateBy { it.id }
|
||||
val labs = api.listLabs(t).filter { lab -> labId == null || lab.id == labId }
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
java.util.zip.ZipOutputStream(out).use { zos ->
|
||||
java.util.zip.ZipEntry("ОПИСАНИЕ.txt").also { it.time = 0; zos.putNextEntry(it) }
|
||||
zos.write("Архив сдач группы ${group.name} (последние попытки)\n".toByteArray(Charsets.UTF_8))
|
||||
zos.closeEntry()
|
||||
for (lab in labs) {
|
||||
val subs = api.labSubmissions(t, lab.id)
|
||||
for (sub in subs) {
|
||||
val student = studentById[sub.studentId] ?: continue
|
||||
val bytes = api.downloadSubmissionAsAdmin(t, sub.id).bytes
|
||||
java.io.ByteArrayInputStream(bytes).let { src ->
|
||||
java.util.zip.ZipInputStream(src).use { zin ->
|
||||
var e = zin.nextEntry
|
||||
while (e != null) {
|
||||
if (!e.isDirectory) {
|
||||
java.util.zip.ZipEntry("ЛР${lab.number}/${student.studentNo}_${student.fullName.replace(Regex("[\\\\/:*?\"<>|]"), "_")}/${e.name}").let { ne -> ne.time = 0; zos.putNextEntry(ne) }
|
||||
zin.copyTo(zos)
|
||||
zos.closeEntry()
|
||||
}
|
||||
e = zin.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val name = "solutions-${group.name}.zip"
|
||||
val encoded = java.net.URLEncoder.encode(name, java.nio.charset.StandardCharsets.UTF_8)
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''${encoded}")
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.body(out.toByteArray())
|
||||
}
|
||||
|
||||
@PostMapping("/groups/{id}/publish")
|
||||
fun publishGrades(
|
||||
@PathVariable id: String,
|
||||
@RequestParam labId: String,
|
||||
@RequestParam published: Boolean,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String = try {
|
||||
api.publishGrades(token(session), labId, id, published)
|
||||
redirect.addFlashAttribute("notice", if (published) "Оценки опубликованы для группы" else "Оценки скрыты для группы")
|
||||
"redirect:/admin/groups/$id"
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/groups/$id"
|
||||
}
|
||||
|
||||
@GetMapping("/submissions/{id}/download")
|
||||
fun downloadSubmissionAsAdmin(
|
||||
@PathVariable id: String,
|
||||
@@ -532,6 +881,36 @@ class AdminController(
|
||||
"redirect:/admin/groups/$groupId"
|
||||
}
|
||||
|
||||
@PostMapping("/groups/{groupId}/students/enroll")
|
||||
fun enrollStudent(
|
||||
@PathVariable groupId: String,
|
||||
@RequestParam studentId: String,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String = try {
|
||||
api.enrollStudent(token(session), groupId, studentId)
|
||||
redirect.addFlashAttribute("notice", "Студент зачислен в группу")
|
||||
"redirect:/admin/groups/$groupId"
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/groups/$groupId"
|
||||
}
|
||||
|
||||
@PostMapping("/groups/{groupId}/students/{studentId}/unenroll")
|
||||
fun unenrollStudent(
|
||||
@PathVariable groupId: String,
|
||||
@PathVariable studentId: String,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String = try {
|
||||
api.unenrollStudent(token(session), groupId, studentId)
|
||||
redirect.addFlashAttribute("notice", "Студент отчислён из группы (аккаунт и данные сохранены)")
|
||||
"redirect:/admin/groups/$groupId"
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/groups/$groupId"
|
||||
}
|
||||
|
||||
@GetMapping("/labs/{id}/edit")
|
||||
fun editLabForm(
|
||||
@PathVariable id: String,
|
||||
@@ -546,6 +925,7 @@ class AdminController(
|
||||
return "admin/lab-edit"
|
||||
}
|
||||
|
||||
/** Единое сохранение: параметры лабы + переименование уровней + правка всех вариантов. */
|
||||
@PostMapping("/labs/{id}/edit")
|
||||
fun editLab(
|
||||
@PathVariable id: String,
|
||||
@@ -555,19 +935,89 @@ class AdminController(
|
||||
@RequestParam pointsMax: String,
|
||||
@RequestParam status: String,
|
||||
@RequestParam descriptionMd: String,
|
||||
@RequestParam(defaultValue = "save") action: String,
|
||||
@RequestParam(required = false) levelIds: List<String>?,
|
||||
@RequestParam(required = false) levelTitles: List<String>?,
|
||||
@RequestParam(required = false) variantIds: List<String>?,
|
||||
@RequestParam(required = false) variantLevelIds: List<String>?,
|
||||
@RequestParam(required = false) variantNumbers: List<String>?,
|
||||
@RequestParam(required = false) variantTitles: List<String>?,
|
||||
@RequestParam(required = false) variantStatements: List<String>?,
|
||||
@RequestParam(required = false) newLevelCode: String?,
|
||||
@RequestParam(required = false) newLevelTitle: String?,
|
||||
@RequestParam(required = false) addLevelId: String?,
|
||||
@RequestParam(required = false) addNumber: String?,
|
||||
@RequestParam(required = false) addTitle: String?,
|
||||
@RequestParam(required = false) addStatementMd: String?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String = try {
|
||||
): String {
|
||||
val t = token(session)
|
||||
return try {
|
||||
when {
|
||||
action.startsWith("delete-level-") -> {
|
||||
api.deleteLevel(t, id, action.removePrefix("delete-level-"))
|
||||
redirect.addFlashAttribute("notice", "Уровень удалён")
|
||||
return "redirect:/admin/labs/$id/edit"
|
||||
}
|
||||
action.startsWith("delete-variant-") -> {
|
||||
api.deleteVariant(t, id, action.removePrefix("delete-variant-"))
|
||||
redirect.addFlashAttribute("notice", "Вариант удалён")
|
||||
return "redirect:/admin/labs/$id/edit"
|
||||
}
|
||||
}
|
||||
|
||||
api.updateLab(
|
||||
token(session), id,
|
||||
LabUpdatePayload(title, ru.ysu.lms.web.DateUtils.toUtcIso(deadlineAt), ru.ysu.lms.web.DateUtils.toUtcIso(defenseAt), pointsMax, status, descriptionMd),
|
||||
t, id,
|
||||
LabUpdatePayload(
|
||||
title,
|
||||
ru.ysu.lms.web.DateUtils.toUtcIso(deadlineAt),
|
||||
ru.ysu.lms.web.DateUtils.toUtcIso(defenseAt),
|
||||
pointsMax, status, descriptionMd,
|
||||
),
|
||||
)
|
||||
redirect.addFlashAttribute("notice", "Лабораторная сохранена")
|
||||
"redirect:/admin/labs/$id"
|
||||
levelIds.orEmpty().zip(levelTitles.orEmpty()).forEach { (levelId, newTitle) ->
|
||||
if (levelId.isNotBlank() && newTitle.isNotBlank()) {
|
||||
api.renameLevel(t, id, levelId, newTitle.trim())
|
||||
}
|
||||
}
|
||||
variantIds.orEmpty().forEachIndexed { idx, variantId ->
|
||||
val number = variantNumbers.orEmpty().getOrNull(idx).orEmpty().trim()
|
||||
if (variantId.isNotBlank() && number.isNotBlank()) {
|
||||
api.saveVariant(
|
||||
t, id, variantId,
|
||||
VariantSavePayload(
|
||||
variantLevelIds.orEmpty().getOrNull(idx).orEmpty(),
|
||||
number,
|
||||
variantTitles.orEmpty().getOrNull(idx).orEmpty().trim(),
|
||||
variantStatements.orEmpty().getOrNull(idx).orEmpty(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
when {
|
||||
action == "add-level" && !newLevelCode.isNullOrBlank() && !newLevelTitle.isNullOrBlank() -> {
|
||||
api.addLevel(t, id, LevelSavePayload(newLevelCode.trim(), newLevelTitle.trim()))
|
||||
redirect.addFlashAttribute("notice", "Сохранено; уровень добавлен")
|
||||
}
|
||||
action == "add-variant" && !addLevelId.isNullOrBlank() && !addNumber.isNullOrBlank() -> {
|
||||
api.saveVariant(
|
||||
t, id, null,
|
||||
VariantSavePayload(
|
||||
addLevelId, addNumber.trim(),
|
||||
addTitle.orEmpty().trim(), addStatementMd.orEmpty(),
|
||||
),
|
||||
)
|
||||
redirect.addFlashAttribute("notice", "Сохранено; вариант добавлен")
|
||||
}
|
||||
else -> redirect.addFlashAttribute("notice", "Все изменения сохранены")
|
||||
}
|
||||
"redirect:/admin/labs/$id/edit"
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/labs/$id/edit"
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/labs/{labId}/variants/save")
|
||||
fun saveVariant(
|
||||
@@ -603,21 +1053,6 @@ class AdminController(
|
||||
"redirect:/admin/labs/$labId/edit"
|
||||
}
|
||||
|
||||
@PostMapping("/labs/{id}/distribute")
|
||||
fun distribute(
|
||||
@PathVariable id: String,
|
||||
@RequestParam(defaultValue = "false") force: Boolean,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String = try {
|
||||
val result = api.distribute(token(session), DistributeRequestPayload(labId = id, force = force))
|
||||
redirect.addFlashAttribute("notice", "Распределено: ${result.assigned} из ${result.total}")
|
||||
"redirect:/admin/labs/$id"
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/labs/$id"
|
||||
}
|
||||
|
||||
@PostMapping("/labs/{labId}/set-level")
|
||||
fun setLevel(
|
||||
@PathVariable labId: String,
|
||||
|
||||
@@ -34,3 +34,45 @@ data class AdminStudentGrades(
|
||||
val comments: List<List<Pair<String, String>>>,
|
||||
val commentsJson: String,
|
||||
)
|
||||
/** Ячейка матрицы «студент × лабораторная» на странице группы. */
|
||||
data class GroupLabCell(
|
||||
val labId: String = "",
|
||||
val labNumber: Int = 0,
|
||||
val labTitle: String = "",
|
||||
val variantNumber: String? = null,
|
||||
val variantManual: Boolean = false,
|
||||
val score: String? = null,
|
||||
val published: Boolean = false,
|
||||
val submissionSeq: Int? = null,
|
||||
val submissionId: String? = null,
|
||||
val defenseStatus: String? = null,
|
||||
val defenseCode: String? = null,
|
||||
val defenseSecondsLeft: Long? = null,
|
||||
val defenseAssigned: Boolean = false,
|
||||
)
|
||||
|
||||
/** Строка студента в матрице + комментарии для диалога. */
|
||||
data class GroupStudentRow(
|
||||
val studentId: String,
|
||||
val fullName: String,
|
||||
val studentNo: String,
|
||||
val basis: String,
|
||||
val mustChangePassword: Boolean,
|
||||
val cells: List<GroupLabCell>,
|
||||
val variantsSummary: String,
|
||||
val commentsJson: String,
|
||||
)
|
||||
|
||||
/** Ряд блока защит (одна сессия на строку). */
|
||||
data class GroupDefenseRow(
|
||||
val sessionId: String?,
|
||||
val studentId: String,
|
||||
val fullName: String,
|
||||
val studentNo: String,
|
||||
val labId: String,
|
||||
val labNumber: Int,
|
||||
val status: String,
|
||||
val code: String?,
|
||||
val assignmentMd: String,
|
||||
val secondsLeft: Long?,
|
||||
)
|
||||
|
||||
@@ -98,6 +98,31 @@ class ApiClient(
|
||||
.retrieve().body(GroupDetailDto::class.java)
|
||||
}
|
||||
|
||||
fun groupCandidates(token: String, groupId: String): List<StudentCandidateDto> {
|
||||
val type = object : ParameterizedTypeReference<List<StudentCandidateDto>>() {}
|
||||
return call {
|
||||
client.get().uri("/api/v1/groups/{id}/candidates", groupId)
|
||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||
.retrieve().body(type)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
fun enrollStudent(token: String, groupId: String, studentId: String) {
|
||||
call {
|
||||
client.post().uri("/api/v1/groups/{id}/students/{sid}/enroll", groupId, studentId)
|
||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||
.retrieve().toBodilessEntity()
|
||||
}
|
||||
}
|
||||
|
||||
fun unenrollStudent(token: String, groupId: String, studentId: String) {
|
||||
call {
|
||||
client.delete().uri("/api/v1/groups/{id}/students/{sid}", groupId, studentId)
|
||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||
.retrieve().toBodilessEntity()
|
||||
}
|
||||
}
|
||||
|
||||
fun resetPassword(token: String, studentId: String): ResetPasswordDto =
|
||||
call {
|
||||
client.post().uri("/api/v1/students/{id}/reset-password", studentId)
|
||||
@@ -315,10 +340,40 @@ class ApiClient(
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
fun myGrades(token: String): List<GradeDto> {
|
||||
fun myGrades(token: String): List<GradeDto> = myGrades(token, null)
|
||||
|
||||
fun myGrades(token: String, groupId: String?): List<GradeDto> {
|
||||
val type = object : ParameterizedTypeReference<List<GradeDto>>() {}
|
||||
return call {
|
||||
client.get().uri("/api/v1/me/grades")
|
||||
val uri = if (groupId.isNullOrBlank()) "/api/v1/me/grades"
|
||||
else "/api/v1/me/grades?groupId={gid}"
|
||||
val spec = client.get()
|
||||
.uri(if (groupId.isNullOrBlank()) uri else uri, *if (groupId.isNullOrBlank()) emptyArray() else arrayOf(groupId))
|
||||
spec.header(HttpHeaders.AUTHORIZATION, bearer(token)).retrieve().body(type)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
fun groupPublications(token: String, groupId: String): List<String> {
|
||||
val type = object : ParameterizedTypeReference<List<String>>() {}
|
||||
return call {
|
||||
client.get().uri("/api/v1/grades/publications?groupId={gid}", groupId)
|
||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||
.retrieve().body(type)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
fun publishGrades(token: String, labId: String, groupId: String, published: Boolean) {
|
||||
call {
|
||||
client.post().uri("/api/v1/grades/publish?labId={lab}&groupId={g}&published={p}", labId, groupId, published)
|
||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||
.retrieve().toBodilessEntity()
|
||||
}
|
||||
}
|
||||
|
||||
fun myGroup(token: String): List<GroupDto> {
|
||||
val type = object : ParameterizedTypeReference<List<GroupDto>>() {}
|
||||
return call {
|
||||
client.get().uri("/api/v1/groups/me")
|
||||
.header(HttpHeaders.AUTHORIZATION, bearer(token))
|
||||
.retrieve().body(type)
|
||||
} ?: emptyList()
|
||||
|
||||
@@ -68,6 +68,14 @@ data class GroupDetailDto(
|
||||
val students: List<StudentRowDto> = emptyList(),
|
||||
)
|
||||
|
||||
data class StudentCandidateDto(
|
||||
val id: String = "",
|
||||
val fullName: String = "",
|
||||
val studentNo: String = "",
|
||||
val currentGroupId: String? = null,
|
||||
val currentGroupName: String? = null,
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class ResetPasswordDto(val password: String = "")
|
||||
|
||||
@@ -95,15 +103,18 @@ data class LabImportResultDto(
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class DistributeResultDto(
|
||||
val labId: String = "",
|
||||
val labTitle: String = "",
|
||||
val labs: Int = 1,
|
||||
val labId: String? = null,
|
||||
val labTitle: String? = null,
|
||||
val assigned: Int = 0,
|
||||
val total: Int = 0,
|
||||
)
|
||||
|
||||
data class DistributeRequestPayload(
|
||||
val labId: String,
|
||||
val labId: String? = null,
|
||||
val labIds: List<String> = emptyList(),
|
||||
val force: Boolean = false,
|
||||
val groupIds: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class LevelAssignPayload(
|
||||
|
||||
@@ -17,10 +17,7 @@ import ru.ysu.lms.web.session.SessionUser
|
||||
class AuthPageController(private val api: ApiClient) {
|
||||
|
||||
@GetMapping("/")
|
||||
fun root(session: HttpSession): String {
|
||||
val user = currentUser(session) ?: return "redirect:/login"
|
||||
return if (user.isAdmin) "redirect:/admin" else "redirect:/student"
|
||||
}
|
||||
fun root(): String = "redirect:/learn"
|
||||
|
||||
@GetMapping("/login")
|
||||
fun loginPage(session: HttpSession, model: Model): String {
|
||||
@@ -93,7 +90,7 @@ class AuthPageController(private val api: ApiClient) {
|
||||
return try {
|
||||
api.changePassword(token, old, new, new2)
|
||||
session.setAttribute(SessionKeys.USER, user.copy(mustChangePassword = false))
|
||||
"redirect:/"
|
||||
"redirect:" + if (user.isAdmin) "/admin" else "/student"
|
||||
} catch (ex: ApiException) {
|
||||
model.addAttribute("error", ex.message)
|
||||
"change_password"
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.springframework.web.servlet.config.annotation.InterceptorRegistry
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
||||
import ru.ysu.lms.web.session.AdminInterceptor
|
||||
import ru.ysu.lms.web.session.AuthInterceptor
|
||||
import ru.ysu.lms.web.session.MustChangePasswordInterceptor
|
||||
import ru.ysu.lms.web.session.SessionKeys
|
||||
import ru.ysu.lms.web.session.SessionUser
|
||||
|
||||
@@ -16,10 +17,16 @@ class WebConfig(
|
||||
private val authInterceptor: AuthInterceptor,
|
||||
private val adminInterceptor: AdminInterceptor,
|
||||
private val csrfInterceptor: CsrfInterceptor,
|
||||
private val mustChangePasswordInterceptor: MustChangePasswordInterceptor,
|
||||
) : WebMvcConfigurer {
|
||||
|
||||
override fun addInterceptors(registry: InterceptorRegistry) {
|
||||
registry.addInterceptor(csrfInterceptor).addPathPatterns("/**")
|
||||
registry.addInterceptor(mustChangePasswordInterceptor)
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns(
|
||||
"/change-password", "/logout", "/login", "/css/**", "/js/**", "/favicon.svg", "/error",
|
||||
)
|
||||
registry.addInterceptor(authInterceptor)
|
||||
.addPathPatterns("/admin/**", "/student/**", "/change-password")
|
||||
registry.addInterceptor(adminInterceptor)
|
||||
|
||||
@@ -43,6 +43,19 @@ class LearnController(
|
||||
return "learn/index"
|
||||
}
|
||||
|
||||
@GetMapping("/learn/{chapter}")
|
||||
fun chapter(
|
||||
@PathVariable chapter: String,
|
||||
session: HttpSession,
|
||||
model: Model,
|
||||
): String {
|
||||
val tree: ContentTreeNodeDto = api.contentTree(token(session))
|
||||
val ch = tree.chapters.firstOrNull { it.slug == chapter } ?: return "redirect:/learn"
|
||||
model.addAttribute("tree", tree)
|
||||
model.addAttribute("chapter", ch)
|
||||
return "learn/chapter"
|
||||
}
|
||||
|
||||
@GetMapping("/learn/{chapter}/{section}")
|
||||
fun section(
|
||||
@PathVariable chapter: String,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package ru.ysu.lms.web.session
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.servlet.HandlerInterceptor
|
||||
|
||||
/**
|
||||
* Пока временный пароль не заменён, пользователю доступны только
|
||||
* страница смены пароля, выход и статика — все остальные запросы
|
||||
* принудительно возвращают на /change-password.
|
||||
*/
|
||||
@Component
|
||||
class MustChangePasswordInterceptor : HandlerInterceptor {
|
||||
|
||||
override fun preHandle(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
handler: Any,
|
||||
): Boolean {
|
||||
val user = request.getSession(false)?.getAttribute(SessionKeys.USER) as? SessionUser
|
||||
?: return true
|
||||
if (!user.mustChangePassword) return true
|
||||
response.sendRedirect("/change-password")
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,9 @@ class StudentController(
|
||||
private fun token(session: HttpSession): String? =
|
||||
session.getAttribute(SessionKeys.TOKEN) as? String
|
||||
|
||||
private fun studentGroupId(t: String): String? =
|
||||
api.myGroup(t).firstOrNull()?.id
|
||||
|
||||
data class LabCard(
|
||||
val id: String,
|
||||
val number: Int,
|
||||
@@ -83,21 +86,29 @@ class StudentController(
|
||||
model.addAttribute("descriptionHtml", markdown.render(lab.descriptionMd))
|
||||
model.addAttribute("assignment", assignment)
|
||||
model.addAttribute("statementHtml", assignment?.statementHtml)
|
||||
model.addAttribute("submissions", api.mySubmissions(t, id))
|
||||
val myGrade = api.myGrades(t).firstOrNull { it.labId == id }
|
||||
model.addAttribute("grade", myGrade?.score)
|
||||
model.addAttribute("gradeComments", myGrade?.comments ?: emptyList<ru.ysu.lms.web.api.CommentDto>())
|
||||
val subs = api.mySubmissions(t, id)
|
||||
model.addAttribute("submissions", subs.map { it.copyMsk() })
|
||||
model.addAttribute("deadlineMsk", DateUtils.formatMsk(lab.deadlineAt))
|
||||
model.addAttribute("canSubmit", canSubmit(lab.deadlineAt))
|
||||
val gid = studentGroupId(t)
|
||||
val myGrade = if (gid != null) api.myGrades(t, gid).firstOrNull { it.labId == id } else null
|
||||
model.addAttribute("canSeeGrade", myGrade != null)
|
||||
model.addAttribute("grade", myGrade?.score)
|
||||
model.addAttribute("commentsPositive", myGrade?.comments?.filter { it.tone == "positive" } ?: emptyList<ru.ysu.lms.web.api.CommentDto>())
|
||||
model.addAttribute("commentsNeutral", myGrade?.comments?.filter { it.tone == "neutral" } ?: emptyList<ru.ysu.lms.web.api.CommentDto>())
|
||||
model.addAttribute("commentsNegative", myGrade?.comments?.filter { it.tone == "negative" } ?: emptyList<ru.ysu.lms.web.api.CommentDto>())
|
||||
val defense = api.myDefense(t, id)
|
||||
defense?.let { it.assignmentHtml = markdown.render(it.assignmentMd) }
|
||||
defense?.let { it.assignmentHtml = if (it.assignmentMd.isNotBlank()) markdown.render(it.assignmentMd) else "" }
|
||||
model.addAttribute("defense", defense)
|
||||
val submittedOrDefending = defense != null && (defense.status == "waiting" || defense.assignmentMd.isNotBlank())
|
||||
model.addAttribute("canSubmit", canSubmit(lab.deadlineAt) && !submittedOrDefending)
|
||||
"student/lab"
|
||||
} catch (ex: ApiException) {
|
||||
"redirect:/student/labs"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@PostMapping("/labs/{id}/submit")
|
||||
fun submit(
|
||||
@PathVariable id: String,
|
||||
@@ -169,7 +180,8 @@ class StudentController(
|
||||
private fun buildCards(token: String): List<LabCard> {
|
||||
val labs = api.listLabs(token).filter { it.status == "published" }
|
||||
val assignments = api.meAssignments(token).associateBy { it.labId }
|
||||
val grades = api.myGrades(token).associate { it.labId to it }
|
||||
val gid = studentGroupId(token)
|
||||
val grades = if (gid != null) api.myGrades(token, gid).associate { it.labId to it } else emptyMap()
|
||||
return labs.map { lab ->
|
||||
val subs = api.mySubmissions(token, lab.id)
|
||||
val assignment = assignments[lab.id]
|
||||
@@ -187,6 +199,11 @@ class StudentController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun SubmissionDto.copyMsk(): SubmissionDto = SubmissionDto(
|
||||
id = id, seq = seq, studentId = studentId, labId = labId,
|
||||
createdAt = ru.ysu.lms.web.DateUtils.formatMsk(createdAt) ?: createdAt, files = files,
|
||||
)
|
||||
|
||||
private fun canSubmit(deadlineAt: String?): Boolean {
|
||||
val d = deadlineAt ?: return true
|
||||
val instant = runCatching { Instant.parse(d) }.getOrNull() ?: return true
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
server:
|
||||
port: ${SERVER_PORT:8090}
|
||||
# За nginx: доверяем X-Forwarded-Proto/Host, чтобы редиректы были https и с верным хостом.
|
||||
forward-headers-strategy: framework
|
||||
servlet:
|
||||
session:
|
||||
timeout: 30d
|
||||
cookie:
|
||||
http-only: true
|
||||
same-site: lax
|
||||
max-age: 30d
|
||||
tracking-modes: cookie
|
||||
|
||||
spring:
|
||||
|
||||
@@ -47,7 +47,13 @@ a:hover { color: var(--c-text-link-hover); }
|
||||
/* Layout */
|
||||
.page { display: flex; flex-direction: column; min-height: 100vh; }
|
||||
.page-header { position: sticky; top: 0; z-index: 10; border-bottom: 1px solid var(--c-border); background: var(--c-surface); }
|
||||
.page-header-inner { max-width: 1152px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1.5rem; }
|
||||
.page-header-inner { max-width: 1152px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 0.5rem 1rem; flex-wrap: wrap; padding: 0.75rem 1.5rem; }
|
||||
@media (max-width: 640px) {
|
||||
.page-header-inner { padding: 0.5rem 0.75rem; }
|
||||
.nav { width: 100%; gap: 0.5rem 0.75rem; flex-wrap: wrap; font-size: 0.8125rem; }
|
||||
.nav .btn { padding: 0.25rem 0.6rem; font-size: 0.75rem; }
|
||||
.nav-user { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
}
|
||||
.page-main { flex: 1; width: 100%; max-width: 1152px; margin: 0 auto; padding: 2rem 1.5rem; }
|
||||
.page-footer { border-top: 1px solid var(--c-border); padding: 1rem; text-align: center; font-size: 0.75rem; color: var(--c-text-muted); }
|
||||
|
||||
@@ -57,6 +63,12 @@ a:hover { color: var(--c-text-link-hover); }
|
||||
.nav-link:hover { color: var(--c-text-link-hover); }
|
||||
.nav-sep { color: var(--c-text-muted); }
|
||||
.logo { font-size: 1.125rem; font-weight: 600; color: var(--c-text); text-decoration: none; }
|
||||
.nav-user { font-weight: 500; }
|
||||
.theme-btn { display: inline-flex; align-items: center; justify-content: center; padding: 0.3rem 0.5rem; }
|
||||
.theme-icon { width: 1.05rem; height: 1.05rem; display: block; }
|
||||
.breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
|
||||
.breadcrumb .nav-sep { user-select: none; }
|
||||
.check-list { display: flex; flex-direction: column; gap: 0.25rem; margin-top: 0.25rem; max-height: 12rem; overflow-y: auto; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
@@ -244,3 +256,76 @@ details.sub-acc > summary::-webkit-details-marker { display: none; }
|
||||
.sub-arrow { display: inline-block; width: 8px; height: 8px; border-right: 2px solid var(--c-text-muted); border-bottom: 2px solid var(--c-text-muted); transform: rotate(45deg); transition: transform 0.2s; margin-right: 6px; vertical-align: 2px;}
|
||||
details.sub-acc[open] > summary .sub-arrow { transform: rotate(-135deg); }
|
||||
.sub-actions { margin: 0 0 2px 18px; }
|
||||
|
||||
/* ---- Матрица группы и карточки защиты ---- */
|
||||
.m-0 { margin: 0; }
|
||||
.mb-8 { margin-bottom: 2rem; }
|
||||
.mb-0 { margin-bottom: 0; }
|
||||
.text-strong { font-weight: 600; color: var(--c-text); }
|
||||
.text-right { text-align: right; }
|
||||
.font-medium { font-weight: 500; }
|
||||
.scroll-x { overflow-x: auto; }
|
||||
.matrix { border-collapse: separate; border-spacing: 0; }
|
||||
.matrix th, .matrix td { vertical-align: top; }
|
||||
.matrix th.lab-col { min-width: 9.5rem; border-left: 1px solid var(--c-border); }
|
||||
.matrix td.cell-lab { min-width: 9.5rem; border-left: 1px solid var(--c-border); }
|
||||
.matrix td.student-cell { min-width: 12rem; }
|
||||
.lab-col-head { display: flex; flex-direction: column; align-items: center; gap: 0.375rem; }
|
||||
.cell-line { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; text-align: left; }
|
||||
.cell-label { color: var(--c-text-muted); min-width: 3.5rem; }
|
||||
.cell-link { font-size: 0.8125rem; }
|
||||
.cell-edit { padding: 0 0.4rem; }
|
||||
.cell-grade-line { font-weight: 500; }
|
||||
.defense-cell-actions { display: flex; gap: 0.35rem; justify-content: flex-end; flex-wrap: wrap; }
|
||||
.th-w-sm { width: 4rem; }
|
||||
.th-w-md { width: 12rem; }
|
||||
.span-2 { grid-column: 1 / -1; }
|
||||
.preview { max-width: 34rem; }
|
||||
.md-preview { max-width: 22rem; }
|
||||
|
||||
/* ---- Комментарии к оценке (ЛК студента) ---- */
|
||||
.review-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 1rem; }
|
||||
.review-col h3 { margin-bottom: 0.35rem; }
|
||||
.review-list { margin: 0; padding-left: 1.15rem; }
|
||||
.review-list li { margin: 0.15rem 0; }
|
||||
.tone-positive-text { color: #166534; }
|
||||
.tone-negative-text { color: #991b1b; }
|
||||
[data-theme="dark"] .tone-positive-text { color: #6ee7b7; }
|
||||
[data-theme="dark"] .tone-negative-text { color: #fca5a5; }
|
||||
.tone-label { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; }
|
||||
.pre-wrap { white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
/* Редактируемая матрица: закреплённый столбец и компактные селекты */
|
||||
.matrix-sticky { position: sticky; left: 0; z-index: 2; background: var(--c-surface); }
|
||||
.matrix thead .matrix-sticky { z-index: 3; }
|
||||
.level-form { margin: 0; }
|
||||
.level-select {
|
||||
width: auto; min-width: 6rem; max-width: 9.5rem;
|
||||
padding: 0.15rem 1.5rem 0.15rem 0.4rem; font-size: 0.75rem;
|
||||
}
|
||||
.cell-level { justify-content: center; }
|
||||
.pin-hint { font-size: 0.75rem; }
|
||||
|
||||
/* Компактные ячейки матрицы группы */
|
||||
.btn-xs { padding: 0.05rem 0.4rem; font-size: 0.7rem; line-height: 1.5; }
|
||||
.badge-xs { font-size: 0.65rem; padding: 0.05rem 0.4rem; }
|
||||
.chip-xs { font-size: 0.65rem; padding: 0.05rem 0.4rem; }
|
||||
.cell-top { display: flex; align-items: center; justify-content: center; gap: 0.25rem; }
|
||||
.cell-chips { display: flex; flex-wrap: wrap; gap: 0.18rem; justify-content: center; margin-top: 0.25rem; }
|
||||
.cell-chip {
|
||||
display: inline-flex; align-items: center; gap: 0.15rem;
|
||||
font-size: 0.72rem; line-height: 1.7; padding: 0.05rem 0.45rem;
|
||||
border: 1px solid var(--c-border); border-radius: 99px;
|
||||
background: var(--c-bg); color: var(--c-text); white-space: nowrap; cursor: pointer;
|
||||
text-decoration: none; font-family: inherit;
|
||||
}
|
||||
.cell-chip:hover { border-color: var(--c-border-strong, var(--c-border)); filter: brightness(0.97); }
|
||||
.chip-dim { color: var(--c-text-muted); }
|
||||
.chip-warn { color: #92400e; background: #fef3c7; border-color: #fde68a; }
|
||||
.chip-ok { color: #166534; background: #dcfce7; border-color: #bbf7d0; }
|
||||
.chip-link:hover { color: var(--c-text-link); }
|
||||
[data-theme="dark"] .chip-warn { color: #fcd34d; background: rgba(146, 64, 14, 0.25); border-color: rgba(146, 64, 14, 0.5); }
|
||||
[data-theme="dark"] .chip-ok { color: #86efac; background: rgba(22, 101, 52, 0.25); border-color: rgba(22, 101, 52, 0.5); }
|
||||
.lab-head-row { display: flex; align-items: center; justify-content: center; gap: 0.3rem; }
|
||||
.actions-cell { white-space: nowrap; }
|
||||
.actions-cell .inline-form { display: inline-block; }
|
||||
|
||||
10
web/src/main/resources/static/css/hljs-github-dark.css
Normal file
10
web/src/main/resources/static/css/hljs-github-dark.css
Normal file
@@ -0,0 +1,10 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
Theme: GitHub Dark
|
||||
Description: Dark theme as seen on github.com
|
||||
Author: github.com
|
||||
Maintainer: @Hirse
|
||||
Updated: 2021-05-15
|
||||
|
||||
Outdated base version: https://github.com/primer/github-syntax-dark
|
||||
Current colors taken from GitHub's CSS
|
||||
*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}
|
||||
10
web/src/main/resources/static/css/hljs-github.css
Normal file
10
web/src/main/resources/static/css/hljs-github.css
Normal file
@@ -0,0 +1,10 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
Theme: GitHub
|
||||
Description: Light theme as seen on github.com
|
||||
Author: github.com
|
||||
Maintainer: @Hirse
|
||||
Updated: 2021-05-15
|
||||
|
||||
Outdated base version: https://github.com/primer/github-syntax-light
|
||||
Current colors taken from GitHub's CSS
|
||||
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}
|
||||
116
web/src/main/resources/static/js/admin-group.js
Normal file
116
web/src/main/resources/static/js/admin-group.js
Normal file
@@ -0,0 +1,116 @@
|
||||
function $id(id) { return document.getElementById(id); }
|
||||
|
||||
/* ---------- Оценка + комментарии ---------- */
|
||||
|
||||
function openGradeEditor(btn) {
|
||||
var all = [];
|
||||
try { all = JSON.parse(btn.getAttribute('data-comments') || '[]'); } catch (e) { all = []; }
|
||||
var idx = parseInt(btn.getAttribute('data-ci') || '0', 10);
|
||||
var list = Array.isArray(all[idx]) ? all[idx] : [];
|
||||
|
||||
$id('grade-student').value = btn.getAttribute('data-student') || '';
|
||||
$id('grade-lab').value = btn.getAttribute('data-lab') || '';
|
||||
$id('grade-who').value = btn.getAttribute('data-student-name') || '';
|
||||
var title = $id('grade-title');
|
||||
if (title) title.textContent = (btn.getAttribute('data-lab-name') || '') + ' — ' + (btn.getAttribute('data-student-name') || '');
|
||||
$id('grade-score').value = btn.getAttribute('data-score') || '';
|
||||
|
||||
var box = $id('cs-rows');
|
||||
box.innerHTML = '';
|
||||
list.forEach(function (c) { csRow(c.text, c.tone); });
|
||||
if (!list.length) csRow('', 'neutral');
|
||||
|
||||
var d = $id('modal-grade');
|
||||
if (d && d.showModal) d.showModal();
|
||||
}
|
||||
|
||||
function csRow(text, tone) {
|
||||
var box = $id('cs-rows');
|
||||
var row = document.createElement('div');
|
||||
row.className = 'cs-row';
|
||||
|
||||
var inp = document.createElement('input');
|
||||
inp.className = 'input cs-text';
|
||||
inp.placeholder = 'Комментарий';
|
||||
inp.value = text || '';
|
||||
|
||||
var sel = document.createElement('select');
|
||||
sel.className = 'input cs-tone';
|
||||
[['positive', '+ позитивный'], ['negative', '— негативный'], ['neutral', '○ нейтральный']].forEach(function (o) {
|
||||
var op = document.createElement('option');
|
||||
op.value = o[0];
|
||||
op.textContent = o[1];
|
||||
sel.appendChild(op);
|
||||
});
|
||||
sel.value = tone || 'neutral';
|
||||
|
||||
var rm = document.createElement('button');
|
||||
rm.type = 'button';
|
||||
rm.className = 'btn btn-secondary btn-sm';
|
||||
rm.textContent = 'удалить';
|
||||
rm.onclick = function () { row.remove(); };
|
||||
|
||||
row.appendChild(inp);
|
||||
row.appendChild(sel);
|
||||
row.appendChild(rm);
|
||||
box.appendChild(row);
|
||||
}
|
||||
|
||||
function csSerialize() {
|
||||
var out = [];
|
||||
document.querySelectorAll('#cs-rows .cs-row').forEach(function (r) {
|
||||
var t = r.querySelector('.cs-text').value.trim();
|
||||
if (t) out.push({ text: t, tone: r.querySelector('.cs-tone').value });
|
||||
});
|
||||
$id('grade-comments').value = JSON.stringify(out);
|
||||
}
|
||||
|
||||
/* ---------- Индивидуальные задания (защита) ---------- */
|
||||
|
||||
function openDefenseForm(btn) {
|
||||
var form = $id('defense-form');
|
||||
if (!form) return;
|
||||
form.reset();
|
||||
var student = btn.getAttribute('data-student') || '';
|
||||
var lab = btn.getAttribute('data-lab') || '';
|
||||
if (student) $id('defense-student-select').value = student;
|
||||
if (lab) $id('defense-lab-select').value = lab;
|
||||
$id('defense-md').value = btn.getAttribute('data-md') || '';
|
||||
var d = $id('modal-defense');
|
||||
if (d && d.showModal) d.showModal();
|
||||
}
|
||||
|
||||
function showDefenseText(btn) {
|
||||
var el = $id('defense-text-body');
|
||||
if (el) el.textContent = btn.getAttribute('data-md') || '(пусто)';
|
||||
var title = $id('defense-text-title');
|
||||
if (title) title.textContent = (btn.getAttribute('data-student-name') || '') + ' — ' + (btn.getAttribute('data-lab-name') || '');
|
||||
var d = $id('modal-defense-text');
|
||||
if (d && d.showModal) d.showModal();
|
||||
}
|
||||
|
||||
function filterDefense() {
|
||||
var s = $id('defense-lab-filter');
|
||||
if (!s) return;
|
||||
var labId = s.value;
|
||||
document.querySelectorAll('#defense-table tr[data-lab]').forEach(function (tr) {
|
||||
tr.style.display = (!labId || tr.getAttribute('data-lab') === labId) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Матрица: фокус на лабораторной и закрепление варианта ---------- */
|
||||
|
||||
function focusLab() {
|
||||
var s = $id('lab-focus');
|
||||
if (!s) return;
|
||||
var labId = s.value;
|
||||
document.querySelectorAll('th.lab-col[data-lab], td.cell-lab[data-lab]').forEach(function (el) {
|
||||
el.style.display = (!labId || el.getAttribute('data-lab') === labId) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('submit', function (ev) {
|
||||
if (ev.target && ev.target.id === 'grade-form') csSerialize();
|
||||
}, true);
|
||||
|
||||
window.addEventListener('DOMContentLoaded', function () { filterDefense(); focusLab(); });
|
||||
36
web/src/main/resources/static/js/admin-lab.js
Normal file
36
web/src/main/resources/static/js/admin-lab.js
Normal file
@@ -0,0 +1,36 @@
|
||||
function toggleAllStudents(cb) {
|
||||
document.querySelectorAll('.pick-student').forEach(function (c) { c.checked = cb.checked; });
|
||||
}
|
||||
|
||||
function collectPicked(form) {
|
||||
var picked = Array.prototype.slice.call(document.querySelectorAll('.pick-student:checked'));
|
||||
if (!picked.length) {
|
||||
alert('Отметьте хотя бы одного студента.');
|
||||
return false;
|
||||
}
|
||||
picked.forEach(function (c) {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'studentIds';
|
||||
input.value = c.value;
|
||||
form.appendChild(input);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function confirmOccupied(sel) {
|
||||
var opt = sel.options[sel.selectedIndex];
|
||||
var taken = opt ? parseInt(opt.getAttribute('data-taken') || '0', 10) : 0;
|
||||
if (opt && opt.value && taken > 0 && sel.value !== sel.dataset.prev) {
|
||||
if (!confirm('Вариант уже занят (' + taken + '). Назначить всё равно?')) {
|
||||
if (sel.dataset.prev !== undefined) sel.value = sel.dataset.prev;
|
||||
return;
|
||||
}
|
||||
}
|
||||
sel.dataset.prev = sel.value;
|
||||
if (sel.form) sel.form.submit();
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.pick-variant').forEach(function (s) { s.dataset.prev = s.value; });
|
||||
});
|
||||
23
web/src/main/resources/static/js/defense-timer.js
Normal file
23
web/src/main/resources/static/js/defense-timer.js
Normal file
@@ -0,0 +1,23 @@
|
||||
document.querySelectorAll('.defense-timer').forEach(function (el) {
|
||||
var left = parseInt(el.getAttribute('data-seconds') || '0', 10);
|
||||
function render(v) {
|
||||
var m = Math.floor(v / 60), s = v % 60;
|
||||
el.textContent = 'Осталось времени: ' + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
}
|
||||
render(left);
|
||||
var tick = setInterval(function () {
|
||||
left -= 1;
|
||||
if (left <= 0) { clearInterval(tick); window.location.reload(); return; }
|
||||
render(left);
|
||||
}, 1000);
|
||||
});
|
||||
document.querySelectorAll('.ru-datetime').forEach(function (el) {
|
||||
try {
|
||||
var iso = (el.textContent || '').trim();
|
||||
if (iso && !el.getAttribute('data-done')) {
|
||||
var d = new Date(iso);
|
||||
el.textContent = new Intl.DateTimeFormat('ru-RU', { dateStyle: 'short', timeStyle: 'short', timeZone: 'Europe/Moscow' }).format(d);
|
||||
el.setAttribute('data-done', '1');
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
1244
web/src/main/resources/static/js/highlight.min.js
vendored
Normal file
1244
web/src/main/resources/static/js/highlight.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -4,14 +4,18 @@
|
||||
<body>
|
||||
<div id="content">
|
||||
<a th:href="@{/admin/groups}" class="nav-link">← Все группы</a>
|
||||
<div class="mt-4 mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="mt-2 mb-4 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="page-title" th:text="${group.name}">Группа</h1>
|
||||
<h1 class="page-title m-0" th:text="${group.name}">Группа</h1>
|
||||
<p class="text-sm text-muted mt-1">Студентов: <span th:text="${#lists.size(group.students)}">0</span></p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button class="btn btn-primary" type="button" onclick="openModal('modal-add-student')">+ Студент</button>
|
||||
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Выгрузить XLSX</a>
|
||||
<button class="btn btn-secondary" type="button"
|
||||
th:disabled="${#lists.isEmpty(candidates)}"
|
||||
onclick="openModal('modal-enroll-student')">Добавить существующего</button>
|
||||
<a class="btn btn-secondary" th:href="'/admin/groups/' + ${group.id} + '/submissions.zip'">Скачать все работы</a>
|
||||
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Логины XLSX</a>
|
||||
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='csv')}">CSV</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,73 +23,141 @@
|
||||
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
|
||||
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
|
||||
<div th:if="${resetPassword}" class="alert alert-warning">
|
||||
Новый пароль: <code class="code" th:text="${resetPassword}">password</code>
|
||||
Новый пароль: <code class="code font-mono" th:text="${resetPassword}">password</code>
|
||||
</div>
|
||||
|
||||
<div th:if="${importResult}" class="card import-card mb-4">
|
||||
<div class="card-body">
|
||||
<h2 class="section-title">Импорт: <span th:text="${importResult.groupName}">группа</span></h2>
|
||||
<p class="text-sm text-muted">
|
||||
Новых студентов: <span th:text="${#lists.size(importResult.added)}">0</span>,
|
||||
обновлено: <span th:text="${#lists.size(importResult.updated)}">0</span>.
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||
<h2 class="section-title m-0">Результаты по лабораторным</h2>
|
||||
<div class="flex flex-wrap items-center gap-2" th:if="${!#lists.isEmpty(labs)}">
|
||||
<label class="label m-0" for="lab-focus">Показать:</label>
|
||||
<select class="input input-sm" id="lab-focus" onchange="focusLab()">
|
||||
<option value="">все лабораторные</option>
|
||||
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}|">—</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-muted mb-4">
|
||||
Значки в ячейке: «№» — вариант, «🛡» — защита, «↓» — сдача (скачать), «✎» — оценка.
|
||||
Наведите на значок — появится пояснение; клик по «↓» скачивает последнее решение,
|
||||
клик по «✎» открывает оценку и комментарии. Раздача вариантов — на странице лабораторной.
|
||||
</p>
|
||||
<div th:if="${not #lists.isEmpty(importResult.added)}" class="mt-4 overflow-x-auto">
|
||||
<p class="text-sm alert-warning mini-alert">Сохраните пароли — они показываются для выдачи студентам.</p>
|
||||
<table>
|
||||
<thead><tr><th>ФИО</th><th>Логин</th><th>Пароль</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="credential : ${importResult.added}">
|
||||
<td th:text="${credential.fullName}">—</td>
|
||||
<td class="font-mono" th:text="${credential.studentNo}">—</td>
|
||||
<td class="font-mono font-semibold" th:text="${credential.password}">—</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Баллы за лабораторные</h2>
|
||||
<p class="text-sm text-muted mb-4">Нажмите ✎ в ячейке балла — откроется ввод оценки с комментариями. Средний балл считается по оценённым лабам.</p>
|
||||
<div class="card card-flush mb-6 overflow-x-auto">
|
||||
<table>
|
||||
<div th:if="${#lists.isEmpty(labs)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
|
||||
<div class="card card-flush mb-6" th:if="${!#lists.isEmpty(labs)}">
|
||||
<div class="scroll-x">
|
||||
<table class="matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Студент</th>
|
||||
<th class="text-center" th:each="lab : ${labs}">
|
||||
<th class="matrix-sticky">Студент</th>
|
||||
<th th:each="lab : ${labs}" class="text-center lab-col" th:attr="data-lab=${lab.id}">
|
||||
<div class="lab-col-head">
|
||||
<div class="lab-head-row">
|
||||
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}|">ЛР</a>
|
||||
<form class="inline-form" th:action="@{/admin/groups/{id}/publish(id=${group.id})}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="labId" th:value="${lab.id}">
|
||||
<input type="hidden" name="published"
|
||||
th:value="${publishedLabIds.contains(lab.id) ? 'false' : 'true'}">
|
||||
<button class="badge badge-btn badge-sm" type="submit"
|
||||
th:classappend="${publishedLabIds.contains(lab.id)} ? 'badge-ok' : 'badge-warn'"
|
||||
th:title="${publishedLabIds.contains(lab.id) ? 'Скрыть оценки и комментарии от студентов' : 'Показать оценки и комментарии студентам'}"
|
||||
th:text="${publishedLabIds.contains(lab.id) ? 'опубликовано' : 'опубликовать оценки'}">опубликовать оценки</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
<th class="text-center">Средний</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="row : ${studentGrades}">
|
||||
<td th:text="${row.fullName}">—</td>
|
||||
<td th:each="score, iter : ${row.scores}">
|
||||
<div>
|
||||
<div class="score-cell-root">
|
||||
<form class="inline-form">
|
||||
<td class="student-cell matrix-sticky">
|
||||
<div class="font-medium" th:text="${row.fullName}">—</div>
|
||||
<div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div>
|
||||
</td>
|
||||
<td th:each="cell, iterStat : ${row.cells}" class="text-center cell-lab" th:attr="data-lab=${cell.labId}">
|
||||
<div class="cell-chips">
|
||||
<span class="cell-chip"
|
||||
th:title="(${cell.variantNumber} ? 'Вариант ' + ${cell.variantNumber} : 'Вариант') + ' — ' + (${cell.variantManual} ? 'закреплён вручную (раздача его не тронет)' : 'выдан автоматически')">
|
||||
№<span class="font-mono" th:classappend="${cell.variantManual} ? 'text-strong' : ''"
|
||||
th:text="${cell.variantNumber} ?: '—'">12</span>
|
||||
</span>
|
||||
<span th:if="${cell.defenseStatus == null}" class="cell-chip chip-dim" title="Защита не назначена">🛡—</span>
|
||||
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode == null}" class="cell-chip chip-warn" title="Задание для защиты выдано, ещё без кода">🛡⌛</span>
|
||||
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode != null}" class="cell-chip chip-warn" title="Защита: код студенту выдан, ждёт входа">🛡🔑</span>
|
||||
<span th:if="${cell.defenseStatus == 'started'}" class="cell-chip chip-warn" title="Защита: студент пишет работу">🛡⏳</span>
|
||||
<span th:if="${cell.defenseStatus == 'ready'}" class="cell-chip chip-ok" title="Защита: работа сохранена, можно принимать">🛡✓</span>
|
||||
<a th:if="${cell.submissionId}" class="cell-chip chip-link"
|
||||
th:href="'/admin/submissions/' + ${cell.submissionId} + '/download'"
|
||||
title="Скачать последнее решение студента">↓<span class="font-mono" th:text="${cell.submissionSeq}">1</span></a>
|
||||
<span th:unless="${cell.submissionId}" class="cell-chip chip-dim" title="Решений пока не сдавалось">↓—</span>
|
||||
<button class="cell-chip" type="button"
|
||||
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-score=${cell.score},data-comments=${row.commentsJson},data-ci=${iterStat.index}"
|
||||
onclick="openGradeEditor(this)"
|
||||
th:title="${cell.score} ? 'Оценка ' + ${cell.score} + ' — открыть правку оценки и комментариев' : 'Поставить оценку и комментарии'"
|
||||
>✎<span class="font-mono" th:if="${cell.score}" th:text="' ' + ${cell.score}"></span></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + 1}">В группе нет студентов.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||
<h2 class="section-title m-0">Индивидуальные задания (защита)</h2>
|
||||
<div class="flex flex-wrap items-center gap-2" th:if="${!#lists.isEmpty(labs)}">
|
||||
<label class="label m-0" for="defense-lab-filter">Лабораторная:</label>
|
||||
<select class="input input-sm" id="defense-lab-filter" onchange="filterDefense()">
|
||||
<option value="">Все</option>
|
||||
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}|">—</option>
|
||||
</select>
|
||||
<button class="btn btn-primary btn-sm" type="button" onclick="openDefenseForm(this)">Назначить задание</button>
|
||||
<button class="btn btn-secondary btn-sm" type="button" onclick="openModal('modal-defense-import')">Импорт CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card card-flush mb-8">
|
||||
<table id="defense-table">
|
||||
<thead><tr><th>Студент</th><th>ЛР</th><th>Задание</th><th>Статус</th><th class="text-right">Действия</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="d : ${defenseRows}" th:attr="data-lab=${d.labId}">
|
||||
<td>
|
||||
<div th:text="${d.fullName}">—</div>
|
||||
<div class="text-sm text-muted font-mono" th:text="${d.studentNo}">—</div>
|
||||
</td>
|
||||
<td class="font-mono" th:text="|ЛР ${d.labNumber}|">—</td>
|
||||
<td class="text-sm text-muted" th:text="${#strings.abbreviate(d.assignmentMd, 60)}">—</td>
|
||||
<td>
|
||||
<span class="badge badge-warn" th:if="${d.status == 'waiting' and d.code == null}">ждёт кода</span>
|
||||
<span class="badge badge-warn" th:if="${d.status == 'waiting' and d.code != null}">код выдан</span>
|
||||
<span class="badge badge-warn" th:if="${d.status == 'started'}">пишет (<span th:text="${d.secondsLeft}"></span> c)</span>
|
||||
<span class="badge badge-ok" th:if="${d.status == 'ready'}">готов</span>
|
||||
</td>
|
||||
<td class="text-right defense-cell-actions">
|
||||
<form class="inline-form" th:if="${d.sessionId != null}"
|
||||
th:action="'/admin/defenses/' + ${d.sessionId} + '/issue-code'" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input class="input input-sm input-score" type="number" step="0.1" min="0" max="5" th:value="${score}" readonly>
|
||||
<button class="btn btn-primary btn-sm" type="button" onclick="openGradeEditor(this)"
|
||||
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${labs[iter.index].id},data-lab-name='ЛР ' + ${labs[iter.index].number},data-score=${score},data-comments=${row.commentsJson},data-ci=${iter.index}">✎</button>
|
||||
<input type="hidden" name="labId" th:value="${d.labId}">
|
||||
<input type="hidden" name="groupId" th:value="${group.id}">
|
||||
<button class="btn btn-secondary btn-sm" type="submit"
|
||||
th:text="${d.status == 'waiting' and d.code != null ? 'новый код' : 'выдать код'}">выдать код</button>
|
||||
</form>
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:attr="data-student=${d.studentId},data-student-name=${d.fullName},data-lab=${d.labId},data-lab-name='ЛР ' + ${d.labNumber},data-md=${d.assignmentMd}"
|
||||
onclick="openDefenseForm(this)">изменить</button>
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:attr="data-student-name=${d.fullName},data-lab-name='ЛР ' + ${d.labNumber},data-md=${d.assignmentMd}"
|
||||
onclick="showDefenseText(this)">текст</button>
|
||||
<form class="inline-form" th:if="${d.status == 'ready'}"
|
||||
th:action="'/admin/defenses/' + ${d.sessionId} + '/reset'" method="post"
|
||||
onsubmit="return confirm('Открыть защиту заново для нового (дополнительного) задания?')">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="labId" th:value="${d.labId}">
|
||||
<input type="hidden" name="groupId" th:value="${group.id}">
|
||||
<button class="btn btn-danger btn-sm" type="submit">доп. задание: начать заново</button>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<span th:each="c : ${row.comments[iter.index]}" class="chip"
|
||||
th:classappend="${c.second == 'positive'} ? chip-pos : (${c.second == 'negative'} ? chip-neg : chip-neu)"
|
||||
th:text="${c.first}">коммент</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center font-semibold">
|
||||
<span th:if="${row.average != null}" th:text="${#numbers.formatDecimal(row.average, 1, 2)}">—</span>
|
||||
<span th:if="${row.average == null}" class="text-muted">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(group.students)}">
|
||||
<td class="empty" th:colspan="${#lists.size(labs) + 2}">В группе нет студентов.</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(defenseRows)}"><td colspan="5" class="empty">Заданий для защиты пока нет.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -93,37 +165,131 @@
|
||||
<h2 class="section-title">Студенты</h2>
|
||||
<div class="card card-flush">
|
||||
<table>
|
||||
<thead><tr><th>№</th><th>ФИО</th><th>Логин</th><th>Основа</th><th>Пароль</th><th></th></tr></thead>
|
||||
<thead><tr><th>№</th><th>ФИО</th><th>Логин</th><th>Основа</th><th>Варианты</th><th>Пароль</th><th class="text-right">Действия</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="student, iter : ${group.students}">
|
||||
<tr th:each="row, iter : ${studentGrades}">
|
||||
<td class="text-muted" th:text="${iter.count}">1</td>
|
||||
<td th:text="${student.fullName}">—</td>
|
||||
<td class="font-mono" th:text="${student.studentNo}">—</td>
|
||||
<td class="text-muted" th:text="${student.basis}">—</td>
|
||||
<td><span th:if="${student.mustChangePassword}" class="badge badge-warn">временный</span>
|
||||
<span th:unless="${student.mustChangePassword}" class="badge badge-ok">изменён</span></td>
|
||||
<td class="text-center">
|
||||
<form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${student.id})}" method="post">
|
||||
<button class="btn btn-danger btn-sm" type="submit">Сбросить</button>
|
||||
<td th:text="${row.fullName}">—</td>
|
||||
<td class="font-mono" th:text="${row.studentNo}">—</td>
|
||||
<td class="text-muted" th:text="${row.basis}">—</td>
|
||||
<td class="text-sm">
|
||||
<span th:if="${!#strings.isEmpty(row.variantsSummary)}" th:text="${row.variantsSummary}">—</span>
|
||||
<span th:if="${#strings.isEmpty(row.variantsSummary)}" class="text-muted">не распределены</span>
|
||||
</td>
|
||||
<td><span th:if="${row.mustChangePassword}" class="badge badge-warn">временный</span>
|
||||
<span th:unless="${row.mustChangePassword}" class="badge badge-ok">изменён</span></td>
|
||||
<td class="text-right actions-cell">
|
||||
<form class="inline-form" th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<button class="btn btn-secondary btn-sm" type="submit"
|
||||
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить пароль</button>
|
||||
</form>
|
||||
<form class="inline-form"
|
||||
th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
|
||||
method="post"
|
||||
onsubmit="return confirm('Отчислить этого студента из группы? Аккаунт, сдача и оценки сохранятся; студент сможет войти и будет без группы.')">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<button class="btn btn-danger btn-sm" type="submit">Отчислить из группы</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(group.students)}"><td colspan="6" class="empty">В группе пока нет студентов.</td></tr>
|
||||
<tr th:if="${#lists.isEmpty(group.students)}"><td colspan="7" class="empty">В группе пока нет студентов.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div th:if="${createdStudent}" class="card import-card toast">
|
||||
<div class="card-body">
|
||||
<h2 class="section-title">Студент добавлен</h2>
|
||||
<p class="text-sm" th:text="${createdStudent.fullName}">—</p>
|
||||
<p class="text-sm">Логин: <code class="code font-mono" th:text="${createdStudent.studentNo}">—</code></p>
|
||||
<p class="text-sm">Пароль: <code class="code font-mono font-semibold" th:text="${createdStudent.password}">—</code></p>
|
||||
<!-- Диалог: оценка + комментарии -->
|
||||
<dialog id="modal-grade" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="grade-title">Оценка и комментарии</h2>
|
||||
<button class="modal-close" type="button" onclick="closeModal('modal-grade')">✕</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" id="grade-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="studentId" id="grade-student">
|
||||
<input type="hidden" name="labId" id="grade-lab">
|
||||
<input type="hidden" name="commentsJson" id="grade-comments" value="[]">
|
||||
<div><label class="label">Студент</label><input class="input" id="grade-who" readonly></div>
|
||||
<div><label class="label" for="grade-score">Балл (0–5)</label>
|
||||
<input class="input" id="grade-score" name="score" type="number" min="0" max="5" step="0.1"></div>
|
||||
<div id="cs-rows"></div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary btn-sm" type="button" onclick="csRow('','neutral')">+ комментарий</button>
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Диалог: назначение индивидуального задания -->
|
||||
<dialog id="modal-defense" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Индивидуальное задание</h2>
|
||||
<button class="modal-close" type="button" onclick="closeModal('modal-defense')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" id="defense-form" th:action="@{/admin/defenses/save}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="groupId" th:value="${group.id}">
|
||||
<div><label class="label" for="defense-student-select">Студент</label>
|
||||
<select class="input" id="defense-student-select" name="studentId" required>
|
||||
<option value="">— выберите студента —</option>
|
||||
<option th:each="s : ${group.students}" th:value="${s.id}"
|
||||
th:text="${s.fullName} + ' (' + ${s.studentNo} + ')'">—</option>
|
||||
</select></div>
|
||||
<div><label class="label" for="defense-lab-select">Лабораторная</label>
|
||||
<select class="input" id="defense-lab-select" name="labId" required>
|
||||
<option value="">— выберите лабораторную —</option>
|
||||
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}. ${lab.title}|">—</option>
|
||||
</select></div>
|
||||
<div><label class="label" for="defense-md">Задание (markdown)</label>
|
||||
<textarea class="input" id="defense-md" name="assignmentMd" rows="10" required></textarea></div>
|
||||
<button class="btn btn-primary" type="submit">Сохранить задание</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Диалог просмотра текста задания -->
|
||||
<dialog id="modal-defense-text" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="defense-text-title">Текст задания</h2>
|
||||
<button class="modal-close" type="button" onclick="closeModal('modal-defense-text')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre id="defense-text-body" class="pre-wrap"></pre>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Диалог импорта CSV -->
|
||||
<dialog id="modal-defense-import" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Импорт заданий (CSV)</h2>
|
||||
<button class="modal-close" type="button" onclick="closeModal('modal-defense-import')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="@{/admin/defenses/import}" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="groupId" th:value="${group.id}">
|
||||
<div><label class="label" for="cimport-lab">Лабораторная</label>
|
||||
<select class="input" id="cimport-lab" name="labId">
|
||||
<option value="">— определить из файла (3-й столбец) —</option>
|
||||
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}. ${lab.title}|">—</option>
|
||||
</select></div>
|
||||
<div><label class="label" for="cimport-file">Файл CSV</label>
|
||||
<input class="input" id="cimport-file" type="file" name="file" accept=".csv,.txt" required></div>
|
||||
<p class="hint">
|
||||
Разделитель <code class="code">;</code>, <code class="code">,</code> или таб; заголовок не обязателен; кавычки поддерживаются.<br>
|
||||
<b>2 столбца</b> (нужна выбранная лаба): <code class="code">студенческий_номер;задание</code>.<br>
|
||||
<b>3 столбца</b>: <code class="code">студенческий_номер;номер_ЛР;задание</code> — лабораторная берётся из файла.<br>
|
||||
Переносы внутри задания — как <code class="code">\n</code>.
|
||||
</p>
|
||||
<button class="btn btn-primary" type="submit">Импортировать</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Диалог добавления студента -->
|
||||
<dialog id="modal-add-student" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Добавить студента</h2>
|
||||
@@ -151,98 +317,41 @@
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="modal-grade" class="modal">
|
||||
|
||||
<dialog id="modal-enroll-student" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="grade-title">Оценка и комментарии</h2>
|
||||
<button class="modal-close" type="button" onclick="closeModal('modal-grade')">✕</button>
|
||||
<h2 class="modal-title">Добавить существующего студента</h2>
|
||||
<button class="modal-close" type="button" onclick="closeModal('modal-enroll-student')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" id="grade-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post"
|
||||
onsubmit="return csSerialize()">
|
||||
<form class="form-stack" id="enroll-form" method="post"
|
||||
th:action="@{/admin/groups/{gid}/students/enroll(gid=${group.id})}">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="studentId" id="grade-student">
|
||||
<input type="hidden" name="labId" id="grade-lab">
|
||||
<input type="hidden" name="commentsJson" id="grade-comments">
|
||||
<div><label class="label">Студент</label><input class="input" id="grade-who" readonly></div>
|
||||
<div><label class="label" for="grade-score">Балл (0–5)</label>
|
||||
<input class="input" id="grade-score" name="score" type="number" min="0" max="5" step="0.1"></div>
|
||||
<div id="cs-rows"></div>
|
||||
<button class="btn btn-secondary btn-sm" type="button" onclick="csRow('','neutral')">+ комментарий</button>
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
<div><label class="label" for="enroll-select">Студент</label>
|
||||
<select class="input" id="enroll-select" name="studentId" required>
|
||||
<option value="">— выберите студента —</option>
|
||||
<option th:each="c : ${candidates}" th:value="${c.id}"
|
||||
th:text="${c.fullName} + ' (' + ${c.studentNo} + ')' + (${c.currentGroupName} ? ' — из ' + ${c.currentGroupName} : ' — без группы')">
|
||||
</option>
|
||||
</select></div>
|
||||
<p class="hint">Если студент уже в другой группе — он будет переведён в эту.
|
||||
Отчисление не удаляет аккаунт, студент сможет войти и после перевода.</p>
|
||||
<button class="btn btn-primary" type="submit">Зачислить</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
<datalist id="cs-list"></datalist>
|
||||
<script>
|
||||
var CS_TEMPLATES = [];
|
||||
</script>
|
||||
<script th:inline="javascript">
|
||||
CS_TEMPLATES = /*[[${commentOptions}]]*/ [];
|
||||
|
||||
function openGradeEditor(btn) {
|
||||
var all = JSON.parse(btn.getAttribute('data-comments') || '[]');
|
||||
var list = all[parseInt(btn.getAttribute('data-ci') || '0', 10)] || [];
|
||||
document.getElementById('grade-student').value = btn.getAttribute('data-student');
|
||||
document.getElementById('grade-lab').value = btn.getAttribute('data-lab');
|
||||
document.getElementById('grade-who').value = btn.getAttribute('data-student-name');
|
||||
document.getElementById('grade-title').textContent = btn.getAttribute('data-lab-name') + ' — ' + btn.getAttribute('data-student-name');
|
||||
var sc = document.getElementById('grade-score');
|
||||
sc.value = btn.getAttribute('data-score') || '';
|
||||
var box = document.getElementById('cs-rows');
|
||||
box.innerHTML = '';
|
||||
list.forEach(function (c) { csRow(c.text, c.tone); });
|
||||
if (!list.length) csRow('', 'neutral');
|
||||
openModal('modal-grade');
|
||||
}
|
||||
|
||||
function csRow(text, tone) {
|
||||
var box = document.getElementById('cs-rows');
|
||||
var row = document.createElement('div');
|
||||
row.className = 'cs-row';
|
||||
var inp = document.createElement('input');
|
||||
inp.className = 'input cs-text'; inp.placeholder = 'Комментарий (подскажем частые)';
|
||||
inp.setAttribute('list', 'cs-list');
|
||||
inp.value = text;
|
||||
inp.addEventListener('input', csFilter);
|
||||
var sel = document.createElement('select');
|
||||
sel.className = 'input cs-tone'
|
||||
[['positive', '+'], ['neutral', 'o'], ['negative', '−']].forEach(function (o) {
|
||||
var op = document.createElement('option'); op.value = o[0]; op.textContent = o[1]; sel.appendChild(op);
|
||||
});
|
||||
sel.value = tone || 'neutral';
|
||||
var rm = document.createElement('button'); rm.type = 'button'; rm.className = 'btn btn-danger btn-sm cs-del'; rm.textContent = '✕';
|
||||
rm.addEventListener('click', function () { row.remove(); });
|
||||
row.appendChild(inp); row.appendChild(sel); row.appendChild(rm);
|
||||
box.appendChild(row);
|
||||
}
|
||||
|
||||
function csFilter(ta) {
|
||||
var list = document.getElementById('cs-list');
|
||||
list.innerHTML = '';
|
||||
var q = (ta.target.value || '').toLowerCase().trim();
|
||||
CS_TEMPLATES
|
||||
.filter(function (t) { return !q || t.text.toLowerCase().indexOf(q) !== -1; })
|
||||
.slice(0, 8)
|
||||
.forEach(function (t) {
|
||||
var op = document.createElement('option');
|
||||
op.value = t.text;
|
||||
list.appendChild(op);
|
||||
});
|
||||
}
|
||||
|
||||
function csSerialize() {
|
||||
var out = [];
|
||||
document.querySelectorAll('#cs-rows .cs-row').forEach(function (r) {
|
||||
var text = r.querySelector('.cs-text').value.trim();
|
||||
if (text) out.push({ text: text, tone: r.querySelector('.cs-tone').value });
|
||||
});
|
||||
document.getElementById('grade-comments').value = JSON.stringify(out);
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div th:if="${createdStudent}" class="card import-card toast">
|
||||
<div class="card-body">
|
||||
<h2 class="section-title">Студент добавлен</h2>
|
||||
<p class="text-sm" th:text="${createdStudent.fullName}">—</p>
|
||||
<p class="text-sm">Логин: <code class="code font-mono" th:text="${createdStudent.studentNo}">—</code></p>
|
||||
<p class="text-sm">Пароль: <code class="code font-mono font-semibold" th:text="${createdStudent.password}">—</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script src="/js/admin-group.js" defer></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,84 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
||||
<head><title th:text="|Правка: ${lab.title} — LMS|">Правка лабы</title></head>
|
||||
<head><title th:text="|Настройка: ${lab.title} — LMS|">Настройка лабы</title></head>
|
||||
<body>
|
||||
<div id="content">
|
||||
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" class="nav-link">← К лабораторной</a>
|
||||
<h1 class="page-title mt-4">Правка лабораторной</h1>
|
||||
<h1 class="page-title mt-4">Настройка лабораторной</h1>
|
||||
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
|
||||
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
|
||||
|
||||
<form class="card form-card form-stack" th:action="@{/admin/labs/{id}/edit(id=${lab.id})}" method="post">
|
||||
<form class="form-stack" method="post" th:action="@{/admin/labs/{id}/edit(id=${lab.id})}">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
|
||||
<h2 class="section-title">Параметры</h2>
|
||||
<div class="card form-card form-stack">
|
||||
<div><label class="label" for="title">Название</label>
|
||||
<input class="input" id="title" name="title" th:value="${lab.title}" required></div>
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label" for="deadlineAt">Срок сдачи</label>
|
||||
<input class="input" id="deadlineAt" name="deadlineAt" type="datetime-local"
|
||||
th:value="${deadlineLocal}"></div>
|
||||
<div><label class="label" for="defenseAt">Дата защиты</label>
|
||||
<input class="input" id="defenseAt" name="defenseAt" type="datetime-local"
|
||||
th:value="${defenseLocal}"></div>
|
||||
<div><label class="label" for="deadlineAt">Срок сдачи (сейчас: <span th:text="${deadlineMsk}" th:if="${deadlineMsk}">—</span> МСК)</label>
|
||||
<input class="input" id="deadlineAt" name="deadlineAt" type="datetime-local" th:value="${deadlineLocal}"></div>
|
||||
<div><label class="label" for="defenseAt">Дата защиты (сейчас: <span th:text="${defenseMsk}" th:if="${defenseMsk}">—</span> МСК)</label>
|
||||
<input class="input" id="defenseAt" name="defenseAt" type="datetime-local" th:value="${defenseLocal}"></div>
|
||||
</div>
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label" for="pointsMax">Максимум баллов</label>
|
||||
<input class="input" id="pointsMax" name="pointsMax" type="number" step="0.1" min="0"
|
||||
th:value="${lab.pointsMax}" required></div>
|
||||
<input class="input" id="pointsMax" name="pointsMax" type="number" step="0.1" min="0" th:value="${lab.pointsMax}" required></div>
|
||||
<div><label class="label" for="status">Статус</label>
|
||||
<select class="input" id="status" name="status">
|
||||
<option value="draft" th:selected="${lab.status == 'draft'}">draft</option>
|
||||
<option value="published" th:selected="${lab.status == 'published'}">published</option>
|
||||
<option value="draft" th:selected="${lab.status == 'draft'}">draft (черновик)</option>
|
||||
<option value="published" th:selected="${lab.status == 'published'}">published (опубликована)</option>
|
||||
<option value="archived" th:selected="${lab.status == 'archived'}">archived</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<div><label class="label" for="descriptionMd">Условие (markdown)</label>
|
||||
<textarea class="input" id="descriptionMd" name="descriptionMd" rows="14"
|
||||
th:text="${lab.descriptionMd}"></textarea></div>
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
<textarea class="input" id="descriptionMd" name="descriptionMd" rows="14" th:text="${lab.descriptionMd}"></textarea></div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title mt-8">Варианты</h2>
|
||||
<div th:each="level : ${lab.levels}" class="mb-6">
|
||||
<h3 class="group-title" th:text="${level.title}">Уровень</h3>
|
||||
<div th:each="v : ${level.variants}" class="card form-card form-stack mb-4">
|
||||
<form th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
|
||||
<input type="hidden" name="variantId" th:value="${v.id}">
|
||||
<input type="hidden" name="labLevelId" th:value="${level.id}">
|
||||
<h2 class="section-title">Уровни</h2>
|
||||
<div class="card card-flush">
|
||||
<table>
|
||||
<thead><tr><th class="th-w-sm">Код</th><th>Название</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="level : ${lab.levels}">
|
||||
<td class="font-mono" th:text="${level.code}">basic</td>
|
||||
<td>
|
||||
<input type="hidden" name="levelIds" th:value="${level.id}">
|
||||
<input class="input" name="levelTitles" th:value="${level.title}" required>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<button class="btn btn-danger btn-sm" type="submit" name="action"
|
||||
th:value="|delete-level-${level.id}|"
|
||||
onclick="return confirm('Удалить уровень? Варианты на нём должны быть пустыми.')">
|
||||
удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(lab.levels)}"><td colspan="3" class="empty">Уровней нет — добавьте ниже.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<details class="card card-body mb-4">
|
||||
<summary class="acc-head text-sm">+ Новый уровень</summary>
|
||||
<div class="grid grid-2 mt-4">
|
||||
<div><label class="label" for="nlCode">Код (латиницей)</label>
|
||||
<input class="input" id="nlCode" name="newLevelCode" placeholder="pro"></div>
|
||||
<div><label class="label" for="nlTitle">Название</label>
|
||||
<input class="input" id="nlTitle" name="newLevelTitle" placeholder="Профессиональный"></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<h2 class="section-title">Варианты</h2>
|
||||
<div th:if="${#lists.isEmpty(lab.levels)}" class="card card-body text-muted">Сначала создайте уровень — затем варианты.</div>
|
||||
|
||||
<div th:each="level : ${lab.levels}" class="mb-4">
|
||||
<h3 class="group-title" th:text="|${level.title} (${#lists.size(level.variants)})|">Уровень</h3>
|
||||
<div th:each="v : ${level.variants}" class="card form-card mb-4">
|
||||
<input type="hidden" name="variantIds" th:value="${v.id}">
|
||||
<input type="hidden" name="variantLevelIds" th:value="${level.id}">
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label">Номер</label><input class="input" name="number" th:value="${v.number}" required></div>
|
||||
<div><label class="label">Название</label><input class="input" name="title" th:value="${v.title}"></div>
|
||||
<div><label class="label">Номер</label>
|
||||
<input class="input" name="variantNumbers" th:value="${v.number}" required></div>
|
||||
<div><label class="label">Название</label>
|
||||
<input class="input" name="variantTitles" th:value="${v.title}"></div>
|
||||
</div>
|
||||
<div><label class="label">Условие (markdown)</label>
|
||||
<textarea class="input" name="statementMd" rows="6" th:text="${v.statementMd}"></textarea></div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
</div>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
<form th:action="@{/admin/labs/{labId}/variants/{vid}/delete(labId=${lab.id}, vid=${v.id})}" method="post"
|
||||
onsubmit="return confirm('Удалить вариант?')">
|
||||
<button class="btn btn-secondary btn-sm" type="submit">Удалить вариант</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
<textarea class="input" name="variantStatements" rows="6" th:text="${v.statementMd}"></textarea></div>
|
||||
<button class="btn btn-danger btn-sm" type="submit" name="action"
|
||||
th:value="|delete-variant-${v.id}|"
|
||||
onclick="return confirm('Удалить вариант?')">Удалить вариант</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title mt-8">Добавить вариант</h2>
|
||||
<form class="card form-card form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label" for="newLevel">Уровень</label>
|
||||
<select class="input" id="newLevel" name="labLevelId">
|
||||
<details class="card card-body mb-4" th:if="${!#lists.isEmpty(lab.levels)}">
|
||||
<summary class="acc-head text-sm">+ Добавить вариант</summary>
|
||||
<div class="grid grid-2 mt-4">
|
||||
<div><label class="label" for="avLevel">Уровень</label>
|
||||
<select class="input" id="avLevel" name="addLevelId">
|
||||
<option th:each="level : ${lab.levels}" th:value="${level.id}" th:text="${level.title}">—</option>
|
||||
</select></div>
|
||||
<div><label class="label" for="newNumber">Номер</label>
|
||||
<input class="input" id="newNumber" name="number" required></div>
|
||||
<div><label class="label" for="avNumber">Номер</label>
|
||||
<input class="input" id="avNumber" name="addNumber" placeholder="21"></div>
|
||||
</div>
|
||||
<div><label class="label" for="avTitle">Название</label>
|
||||
<input class="input" id="avTitle" name="addTitle"></div>
|
||||
<div><label class="label" for="avStatement">Условие (markdown)</label>
|
||||
<textarea class="input" id="avStatement" name="addStatementMd" rows="6"></textarea></div>
|
||||
</details>
|
||||
|
||||
<div class="flex gap-2 mb-8">
|
||||
<button class="btn btn-primary" type="submit" name="action" value="save">Сохранить всё</button>
|
||||
<button class="btn btn-secondary" type="submit" name="action" value="add-level">Добавить уровень</button>
|
||||
<button class="btn btn-secondary" type="submit" name="action" value="add-variant">Добавить вариант</button>
|
||||
</div>
|
||||
<div><label class="label" for="newTitle">Название</label>
|
||||
<input class="input" id="newTitle" name="title"></div>
|
||||
<div><label class="label" for="newStatement">Условие (markdown)</label>
|
||||
<textarea class="input" id="newStatement" name="statementMd" rows="6"></textarea></div>
|
||||
<button class="btn btn-primary" type="submit">Добавить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -8,309 +8,189 @@
|
||||
<div>
|
||||
<h1 class="page-title" th:text="|ЛР ${lab.number}. ${lab.title}|">ЛР 1</h1>
|
||||
<p class="text-sm text-muted">
|
||||
Статус: <form class="inline-form" th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post">
|
||||
Статус:
|
||||
<form class="inline-form" th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<button class="badge badge-btn" type="submit" onclick="return confirm('Сменить статус лабораторной?')"
|
||||
th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</button>
|
||||
th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'"
|
||||
th:text="${lab.status}">draft</button>
|
||||
</form>
|
||||
<span th:if="${deadlineMsk != null}"> · Срок: <span th:text="${deadlineMsk} + ' МСК'"></span></span>
|
||||
<span th:if="${defenseMsk != null}"> · Защита: <span th:text="${defenseMsk} + ' МСК'"></span></span>
|
||||
· Максимум: <span th:text="${lab.pointsMax}"></span> б.
|
||||
<span th:if="${assignedCount > 0}" class="chip chip-neu">Вариантов назначено: <span th:text="${assignedCount}"></span></span>
|
||||
<span th:if="${totalVariants > 0}" class="chip chip-neu">Занято вариантов: <span th:text="${occupiedVariants}"></span> из <span th:text="${totalVariants}"></span></span>
|
||||
<span th:if="${totalVariants > 0}" class="chip chip-pos">Свободно: <span th:text="${freeVariants}"></span></span>
|
||||
<span th:if="${submissionCount > 0}" class="chip chip-pos">Сдано: <span th:text="${submissionCount}"></span></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary" type="button" onclick="openModal('modal-meta')">Править описание</button>
|
||||
<form th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post">
|
||||
<button class="btn btn-primary" type="submit" name="force" value="false">Распределить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
<form th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post">
|
||||
<button class="btn btn-secondary" type="submit" name="force" value="true">Перераспределить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
<a class="btn btn-secondary" th:href="@{/admin/labs/{id}/edit(id=${lab.id})}">Редактировать</a>
|
||||
<button class="btn btn-primary" type="button" onclick="openModal('modal-distribute')">Распределить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
|
||||
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
|
||||
|
||||
<h2 class="section-title">Условие</h2>
|
||||
<h2 class="section-title">Условие лабораторной</h2>
|
||||
<div class="card card-body md-body mb-6" th:utext="${descriptionHtml}"></div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 mb-4">
|
||||
<h2 class="section-title">Уровни и варианты</h2>
|
||||
<button class="btn btn-secondary" type="button" onclick="openModal('modal-add-level')">+ Уровень</button>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||
<h2 class="section-title m-0">Назначения студентов</h2>
|
||||
<p class="text-sm text-muted m-0">
|
||||
Смена уровня сразу подбирает вариант; конкретный вариант можно закрепить вручную.
|
||||
</p>
|
||||
</div>
|
||||
<div th:if="${#lists.isEmpty(assignmentGroups)}" class="card card-body mb-6 text-muted">Нет студентов для назначения.</div>
|
||||
|
||||
<div th:each="level : ${lab.levels}" class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-4">
|
||||
<h3 class="group-title">
|
||||
<span class="font-mono text-muted" th:text="${level.code}">basic</span> —
|
||||
<span th:text="${level.title}">Уровень</span>
|
||||
(<span th:text="${#lists.size(level.variants)}">0</span> вар.)
|
||||
</h3>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:attr="data-modal=${'modal-rename-level-' + level.id}" onclick="openModalData(this)">Переименовать</button>
|
||||
<form th:action="@{/admin/labs/{labId}/levels/{lid}/delete(labId=${lab.id}, lid=${level.id})}" method="post"
|
||||
onsubmit="return confirm('Удалить уровень без вариантов?')">
|
||||
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
|
||||
<div class="card card-body mb-4" th:if="${!#lists.isEmpty(assignmentGroups)}">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<label class="tone-label"><input type="checkbox" id="batch-all"
|
||||
onchange="toggleAllStudents(this)"> Выбрать всех</label>
|
||||
<form id="batch-level-form" class="inline-form" method="post"
|
||||
th:action="@{/admin/labs/{id}/batch-level(id=${lab.id})}"
|
||||
onsubmit="return collectPicked(this)">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
<button class="btn btn-primary btn-sm" type="button"
|
||||
th:attr="data-modal=${'modal-add-variant-' + level.id}" onclick="openModalData(this)">+ Вариант</button>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>№</th><th>Название</th><th>Условие</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="v : ${level.variants}">
|
||||
<td class="font-mono" th:text="${v.number}">—</td>
|
||||
<td th:text="${v.title}">—</td>
|
||||
<td class="text-muted" th:text="${#strings.abbreviate(v.statementMd, 60)}">—</td>
|
||||
<td class="text-center">
|
||||
<button class="btn btn-secondary btn-sm" type="button"
|
||||
th:attr="data-modal=${'modal-edit-variant-' + v.id}" onclick="openModalData(this)">Править</button>
|
||||
<form class="inline-form" th:action="@{/admin/labs/{labId}/variants/{vid}/delete(labId=${lab.id}, vid=${v.id})}" method="post"
|
||||
onsubmit="return confirm('Удалить вариант?')">
|
||||
<button class="btn btn-danger btn-sm" type="submit">Удалить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(level.variants)}"><td colspan="4" class="empty">Вариантов пока нет.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Студенты и варианты</h2>
|
||||
<div class="flex items-center justify-between gap-4 mb-4">
|
||||
<p class="text-sm text-muted">Уровень по умолчанию — самый простой. Селекты: выдать вариант посложнее или назначить вручную.</p>
|
||||
<label class="text-sm text-muted">Группа:
|
||||
<select id="group-filter" class="input filter-select" onchange="filterGroups(this.value)">
|
||||
<option value="">Все</option>
|
||||
<option th:each="g : ${groups}" th:value="${g.groupName}" th:text="${g.groupName}">—</option>
|
||||
<select name="labLevelId" class="input input-sm" th:disabled="${#lists.isEmpty(lab.levels)}">
|
||||
<option th:each="lv : ${lab.levels}" th:value="${lv.id}" th:text="${lv.title}">Уровень</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn btn-secondary btn-sm" type="submit">Назначить уровень выбранным</button>
|
||||
</form>
|
||||
<form id="batch-variant-form" class="inline-form" method="post"
|
||||
th:action="@{/admin/labs/{id}/batch-variant(id=${lab.id})}"
|
||||
onsubmit="return collectPicked(this)">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<select name="variantId" class="input input-sm">
|
||||
<optgroup th:each="lv : ${lab.levels}" th:label="${lv.title}">
|
||||
<option th:each="v : ${lv.variants}" th:value="${v.id}"
|
||||
th:text="${(variantUsage[v.id] ?: 0) > 0 ? v.number + ' — ' + v.title + ' (занят ' + (variantUsage[v.id] ?: 0) + ')' : v.number + ' — ' + v.title}">1</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<button class="btn btn-secondary btn-sm" type="submit">Закрепить вариант выбранным</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="group-section" th:each="g : ${groups}" th:data-group="${g.groupName}">
|
||||
</div>
|
||||
|
||||
<div th:each="g : ${assignmentGroups}" class="card mb-4" th:if="${!#lists.isEmpty(g.students)}">
|
||||
<div class="card-body">
|
||||
<h3 class="group-title" th:text="${g.groupName}">Группа</h3>
|
||||
<div class="card card-flush mb-6">
|
||||
<div class="scroll-x">
|
||||
<table>
|
||||
<thead><tr><th>Студент</th><th>Логин</th><th>Уровень</th><th>Вариант</th><th>Источник</th><th>Сдача</th><th>Защита</th></tr></thead>
|
||||
<thead><tr>
|
||||
<th class="th-w-sm">Выбор</th><th>Студент</th><th class="th-w-md">Уровень</th><th class="th-w-md">Вариант</th>
|
||||
<th class="th-w-sm">Источник</th><th>Сдача</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="s : ${g.students}">
|
||||
<td th:text="${s.fullName}">—</td>
|
||||
<td class="font-mono" th:text="${s.studentNo}">—</td>
|
||||
<td><input type="checkbox" class="pick-student" th:value="${s.studentId}"></td>
|
||||
<td>
|
||||
<form class="inline-form" th:action="@{/admin/labs/{labId}/set-level(labId=${lab.id})}" method="post">
|
||||
<input type="hidden" name="studentId" th:value="${s.studentId}">
|
||||
<select class="input input-sm" name="labLevelId">
|
||||
<option th:each="level : ${lab.levels}" th:value="${level.id}" th:text="${level.title}"
|
||||
th:selected="${level.id == s.levelId}">—</option>
|
||||
</select>
|
||||
<button class="btn btn-secondary btn-sm" type="submit">OK</button>
|
||||
<div th:text="${s.fullName}">—</div>
|
||||
<div class="text-sm text-muted font-mono" th:text="${s.studentNo}">—</div>
|
||||
</td>
|
||||
<td>
|
||||
<form class="inline-form" method="post"
|
||||
th:action="@{/admin/labs/{id}/set-level(id=${lab.id})}">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="studentId" th:value="${s.studentId}">
|
||||
<input type="hidden" name="labId" th:value="${lab.id}">
|
||||
<select name="labLevelId" class="input input-sm" onchange="this.form.submit()"
|
||||
th:disabled="${#lists.isEmpty(lab.levels)}">
|
||||
<option th:each="lv : ${lab.levels}" th:value="${lv.id}"
|
||||
th:selected="${lv.id == s.levelId}" th:text="${lv.title}">Уровень</option>
|
||||
</select>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form class="inline-form" th:action="@{/admin/labs/{labId}/assign(labId=${lab.id})}" method="post">
|
||||
<form class="inline-form" method="post"
|
||||
th:action="@{/admin/labs/{id}/assign(id=${lab.id})}">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="studentId" th:value="${s.studentId}">
|
||||
<select class="input input-sm" name="variantId">
|
||||
<optgroup th:each="level : ${lab.levels}" th:label="${level.title}">
|
||||
<option th:each="v : ${level.variants}" th:value="${v.id}"
|
||||
th:text="|${v.number} — ${v.title}|"
|
||||
th:selected="${v.id == s.variantId}">—</option>
|
||||
<input type="hidden" name="labId" th:value="${lab.id}">
|
||||
<select name="variantId" class="input input-sm pick-variant" onchange="confirmOccupied(this)">
|
||||
<option value="">— авто —</option>
|
||||
<optgroup th:each="lv : ${lab.levels}" th:label="${lv.title}">
|
||||
<option th:each="v : ${lv.variants}" th:value="${v.id}"
|
||||
th:attr="data-taken=${variantUsage[v.id] ?: 0}"
|
||||
th:selected="${v.id == s.variantId}"
|
||||
th:text="${(variantUsage[v.id] ?: 0) > 1 ? v.number + ' — ' + v.title + ' (занят ' + (variantUsage[v.id] ?: 0) + ')' : v.number + ' — ' + v.title}">1</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<button class="btn btn-secondary btn-sm" type="submit">OK</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</td>
|
||||
<td><span th:if="${s.variantNumber != null}" class="font-mono" th:text="${s.variantNumber}">—</span></td>
|
||||
<td><span class="badge" th:classappend="${s.source == 'вручную'} ? 'badge-warn' : 'badge-ok'" th:text="${s.source}">—</span></td>
|
||||
<td><a th:if="${s.submissionId}" class="badge badge-ok"
|
||||
th:href="'/admin/submissions/' + ${s.submissionId} + '/download'"
|
||||
th:text="|попытка ${s.submissionSeq}|">↓</a>
|
||||
<span th:unless="${s.submissionId}" class="text-muted">не сдано</span></td>
|
||||
<td>
|
||||
<span th:if="${s.defenseStatus == null}" class="text-muted">не назначено</span>
|
||||
<span th:if="${s.defenseStatus == 'waiting' and s.defenseCode != null}" class="badge badge-warn">код <span class="font-mono" th:text="${s.defenseCode}"></span></span>
|
||||
<span th:if="${s.defenseStatus == 'waiting' and s.defenseCode == null}" class="badge badge-warn">ждёт задания</span>
|
||||
<span th:if="${s.defenseStatus == 'started'}" class="badge badge-warn">на защите (<span th:text="${s.defenseSecondsLeft}"></span>с)</span>
|
||||
<span th:if="${s.defenseStatus == 'ready'}" class="badge badge-ok">готов к защите</span>
|
||||
<form class="inline-form" th:if="${s.defenseSessionId != null}" th:action="'/admin/defenses/' + ${s.defenseSessionId} + '/issue-code'" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="labId" th:value="${lab.id}">
|
||||
<button class="btn btn-secondary btn-sm" type="submit" th:if="${s.defenseStatus != 'ready'}">выдать код</button>
|
||||
<button class="btn btn-secondary btn-sm" type="submit" th:if="${s.defenseStatus == 'ready'}">заново</button>
|
||||
</form>
|
||||
<span class="badge" th:classappend="${s.source == 'вручную'} ? 'badge-ok' : 'badge-warn'"
|
||||
th:text="${s.source}">авто</span>
|
||||
</td>
|
||||
<td>
|
||||
<a th:if="${s.submissionId}" class="cell-link"
|
||||
th:href="'/admin/submissions/' + ${s.submissionId} + '/download'">
|
||||
попытка <span th:text="${s.submissionSeq}">1</span> ↓</a>
|
||||
<span th:unless="${s.submissionId}" class="text-muted">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(g.students)}"><td colspan="8" class="empty">В группе нет студентов.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<h2 class="section-title mt-8">Индивидуальные задания для защиты</h2>
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<button class="btn btn-primary" type="button" onclick="openModal('modal-defense-assign')">Выдать / изменить задание</button>
|
||||
<form class="inline-form" th:action="'/admin/labs/' + ${lab.id} + '/defense/import'" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input class="input input-sm" type="file" name="file" accept=".csv" required>
|
||||
<button class="btn btn-secondary btn-sm" type="submit">Импорт CSV</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="text-sm text-muted mb-4">CSV: строки «студенческий номер;текст задания»; переносы внутри задания — как \n. Первая строка с заголовком игнорируется.</p>
|
||||
|
||||
<dialog id="modal-defense-assign" class="modal">
|
||||
<h2 class="section-title">Уровни и варианты</h2>
|
||||
<div th:if="${#lists.isEmpty(lab.levels)}" class="card card-body text-muted">Уровней пока нет — добавьте на экране настройки.</div>
|
||||
<div th:each="level : ${lab.levels}" class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-2">
|
||||
<h3 class="section-title" th:text="${level.title}">Уровень</h3>
|
||||
<span class="text-sm text-muted" th:text="|Вариантов: ${#lists.size(level.variants)}|">0</span>
|
||||
</div>
|
||||
<div class="scroll-x">
|
||||
<table>
|
||||
<thead><tr><th class="th-w-sm">№</th><th class="th-w-md">Название</th><th>Условие</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="v : ${level.variants}">
|
||||
<td class="font-mono" th:text="${v.number}">1</td>
|
||||
<td th:text="${v.title}">—</td>
|
||||
<td class="text-muted preview" th:text="${#strings.abbreviate(v.statementMd, 70)}">—</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(level.variants)}"><td colspan="3" class="empty">Вариантов нет.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="modal-distribute" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Индивидуальное задание</h2>
|
||||
<button class="modal-close" type="button" onclick="closeModal('modal-defense-assign')">✕</button>
|
||||
<h2 class="modal-title">Распределение вариантов</h2>
|
||||
<button class="modal-close" type="button" onclick="closeModal('modal-distribute')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="'/admin/labs/' + ${lab.id} + '/defense/assign'" method="post">
|
||||
<form class="form-stack" th:action="@{/admin/labs/{id}/distribute(id=${lab.id})}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<div><label class="label">Студент</label>
|
||||
<select class="input" id="defense-student" name="studentId" required onchange="fillAssignment(this)">
|
||||
<option value="">— выберите студента —</option>
|
||||
<optgroup th:each="g : ${groups}" th:label="${g.groupName}">
|
||||
<option th:each="s : ${g.students}" th:value="${s.studentId}"
|
||||
th:attr="data-md=${s.adminAssignmentMd}"
|
||||
th:text="${s.fullName}">—</option>
|
||||
</optgroup>
|
||||
</select></div>
|
||||
<div><label class="label">Задание (markdown)</label>
|
||||
<textarea class="input" id="defense-md" name="assignmentMd" rows="8" required></textarea></div>
|
||||
<button class="btn btn-primary" type="submit">Сохранить задание</button>
|
||||
<p class="text-sm">
|
||||
Студенты выбранных групп раздаются одной общей очерёдностью. Каждый получает
|
||||
вариант <b>со своего уровня</b> (без уровня — с первого уровня лабораторной):
|
||||
пока внутри уровня есть не выданные никому варианты — они выдаются без повторов,
|
||||
затем вариант берётся из наименее занятых. Ручные закрепления сохраняются всегда.
|
||||
</p>
|
||||
<div th:if="${!#lists.isEmpty(groups)}">
|
||||
<label class="label">Группы (ничего не выбрано — все группы)</label>
|
||||
<div class="check-list">
|
||||
<label class="tone-label" th:each="g : ${groups}">
|
||||
<input type="checkbox" name="groupIds" th:value="${g.id}">
|
||||
<span th:text="${g.name}">—</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<label class="tone-label">
|
||||
<input type="checkbox" name="force" value="true">
|
||||
Пересоздать заново (сбросить авто-назначения у выбранных; вручную закреплённые останутся)
|
||||
</label>
|
||||
<button class="btn btn-primary" type="submit">Распределить</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
|
||||
<dialog id="modal-meta" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Правка лабораторной</h2>
|
||||
<button class="modal-close" type="button" onclick="closeModal('modal-meta')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="@{/admin/labs/{id}/edit(id=${lab.id})}" method="post">
|
||||
<div><label class="label" for="metaTitle">Название</label>
|
||||
<input class="input" id="metaTitle" name="title" th:value="${lab.title}" required></div>
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label" for="metaDeadline">Срок сдачи</label>
|
||||
<input class="input" id="metaDeadline" name="deadlineAt" type="datetime-local" th:value="${deadlineLocal}"></div>
|
||||
<div><label class="label" for="metaDefense">Дата защиты</label>
|
||||
<input class="input" id="metaDefense" name="defenseAt" type="datetime-local" th:value="${defenseLocal}"></div>
|
||||
</div>
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label" for="metaPoints">Максимум баллов</label>
|
||||
<input class="input" id="metaPoints" name="pointsMax" type="number" step="0.1" min="0" th:value="${lab.pointsMax}" required></div>
|
||||
<div><label class="label" for="metaStatus">Статус</label>
|
||||
<select class="input" id="metaStatus" name="status">
|
||||
<option value="draft" th:selected="${lab.status == 'draft'}">draft</option>
|
||||
<option value="published" th:selected="${lab.status == 'published'}">published</option>
|
||||
<option value="archived" th:selected="${lab.status == 'archived'}">archived</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<div><label class="label" for="metaDesc">Условие (markdown)</label>
|
||||
<textarea class="input" id="metaDesc" name="descriptionMd" rows="12" th:text="${lab.descriptionMd}"></textarea></div>
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="modal-add-level" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Добавить уровень</h2>
|
||||
<button class="modal-close" type="button" onclick="closeModal('modal-add-level')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="@{/admin/labs/{labId}/levels/save(labId=${lab.id})}" method="post">
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label" for="lvlCode">Код</label>
|
||||
<input class="input" id="lvlCode" name="code" placeholder="pro" required></div>
|
||||
<div><label class="label" for="lvlTitle">Название</label>
|
||||
<input class="input" id="lvlTitle" name="title" placeholder="Профессиональный" required></div>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Добавить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog th:each="level : ${lab.levels}" th:id="|modal-rename-level-${level.id}|" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Переименовать уровень</h2>
|
||||
<button class="modal-close" type="button" th:attr="data-modal=${'modal-rename-level-' + level.id}" onclick="closeModalData(this)">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="@{/admin/labs/{labId}/levels/save(labId=${lab.id})}" method="post">
|
||||
<input type="hidden" name="levelId" th:value="${level.id}">
|
||||
<input type="hidden" name="code" th:value="${level.code}">
|
||||
<div><label class="label">Название</label>
|
||||
<input class="input" name="title" th:value="${level.title}" required></div>
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog th:each="level : ${lab.levels}" th:id="|modal-add-variant-${level.id}|" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Новый вариант — <span th:text="${level.title}">уровень</span></h2>
|
||||
<button class="modal-close" type="button" th:attr="data-modal=${'modal-add-variant-' + level.id}" onclick="closeModalData(this)">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
|
||||
<input type="hidden" name="labLevelId" th:value="${level.id}">
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label">Номер</label><input class="input" name="number" required></div>
|
||||
<div><label class="label">Название</label><input class="input" name="title"></div>
|
||||
</div>
|
||||
<div><label class="label">Условие (markdown)</label>
|
||||
<textarea class="input" name="statementMd" rows="8"></textarea></div>
|
||||
<button class="btn btn-primary" type="submit">Добавить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<th:block th:each="level : ${lab.levels}">
|
||||
<dialog th:each="v : ${level.variants}" th:id="|modal-edit-variant-${v.id}|" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Правка варианта</h2>
|
||||
<button class="modal-close" type="button" th:attr="data-modal=${'modal-edit-variant-' + v.id}" onclick="closeModalData(this)">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
|
||||
<input type="hidden" name="variantId" th:value="${v.id}">
|
||||
<input type="hidden" name="labLevelId" th:value="${level.id}">
|
||||
<div class="grid grid-2">
|
||||
<div><label class="label">Номер</label><input class="input" name="number" th:value="${v.number}" required></div>
|
||||
<div><label class="label">Название</label><input class="input" name="title" th:value="${v.title}"></div>
|
||||
</div>
|
||||
<div><label class="label">Условие (markdown)</label>
|
||||
<textarea class="input" name="statementMd" rows="8" th:text="${v.statementMd}"></textarea></div>
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
</th:block>
|
||||
|
||||
<script>
|
||||
function fillAssignment(sel) { document.getElementById("defense-md").value = sel.options[sel.selectedIndex].getAttribute("data-md") || ""; }
|
||||
function filterGroups(name) {
|
||||
document.querySelectorAll('.group-section').forEach(function (el) {
|
||||
el.classList.toggle('hidden', !!name && el.getAttribute('data-group') !== name);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script src="/js/admin-lab.js" defer></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,8 +5,11 @@
|
||||
<div id="content">
|
||||
<div class="flex items-center justify-between gap-4 mb-6">
|
||||
<h1 class="page-title">Лабораторные</h1>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary" type="button" onclick="openModal('modal-import-lab')">Импорт</button>
|
||||
<button class="btn btn-primary" type="button" onclick="openModal('modal-create-lab')">+ Создать</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-muted mb-4">Статус <b>draft</b> — черновик (редактирование, студентам не видна).
|
||||
<b>published</b> — опубликована (доступна студентам, видны распределённые варианты). Нажмите на бейдж статуса, чтобы переключить.</p>
|
||||
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
|
||||
@@ -36,6 +39,29 @@
|
||||
</div>
|
||||
|
||||
|
||||
<dialog id="modal-import-lab" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Импорт лабораторной</h2>
|
||||
<button class="modal-close" type="button" onclick="closeModal('modal-import-lab')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-stack" th:action="@{/admin/labs/import}" method="post" enctype="multipart/form-data">
|
||||
<div><label class="label" for="labFile">Файл</label>
|
||||
<input class="input" id="labFile" type="file" name="file" accept=".zip" required></div>
|
||||
<p class="hint">
|
||||
Формат — ZIP: <code class="code">lab.yaml</code> + <code class="code">README.md</code> +
|
||||
<code class="code">variants/*.md</code> (front-matter: <code class="code">level</code>,
|
||||
<code class="code">number</code>, <code class="code">title</code>) +
|
||||
<code class="code">assets/</code> (картинки). Пример структуры — в
|
||||
<code class="code">deploy/IMPORT.md</code>.
|
||||
</p>
|
||||
<p class="hint">Даты сдачи и защиты после импорта задаются на карточке лабораторной.</p>
|
||||
<button class="btn btn-primary" type="submit">Импортировать</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="modal-create-lab" class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">Новая лабораторная</h2>
|
||||
|
||||
@@ -5,13 +5,21 @@
|
||||
<div id="content">
|
||||
<div class="container-sm">
|
||||
<h1 class="page-title">Смена пароля</h1>
|
||||
<p class="text-sm text-muted mb-4">При первом входе пароль нужно сменить.</p>
|
||||
<p class="text-sm text-muted mb-4" th:if="${currentUser != null and currentUser.mustChangePassword}">
|
||||
Это временный пароль. Пока вы его не смените, другие страницы недоступны.
|
||||
Смена потребуется один раз — после этого система больше не попросит.
|
||||
</p>
|
||||
<p class="text-sm text-muted mb-4" th:if="${currentUser == null or !currentUser.mustChangePassword}">
|
||||
Смените пароль, если хотите. После сохранения вы останетесь в системе.
|
||||
</p>
|
||||
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
|
||||
<form class="card form-card form-stack" th:action="@{/change-password}" method="post">
|
||||
<div><label class="label" for="old">Текущий пароль</label><input class="input" id="old" name="old" type="password" required></div>
|
||||
<div><label class="label" for="new">Новый пароль</label><input class="input" id="new" name="new" type="password" required><p class="hint">Минимум 8 символов.</p></div>
|
||||
<div><label class="label" for="new2">Повторите новый пароль</label><input class="input" id="new2" name="new2" type="password" required></div>
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
<button class="btn btn-primary" type="submit">Сменить пароль</button>
|
||||
<a th:if="${currentUser != null and !currentUser.mustChangePassword}"
|
||||
class="btn btn-secondary" th:href="${currentUser.admin} ? @{/admin} : @{/student}">Отмена</a>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<body>
|
||||
<div id="content">
|
||||
<div class="container-sm text-center">
|
||||
<p class="stat-value text-muted opacity-30" th:text="${status}">500</p>
|
||||
<p class="mt-4" th:text="${message}">Что-то пошло не так</p>
|
||||
<p class="stat-value text-muted opacity-30" th:if="${status != null and status >= 400}" th:text="${status}">500</p>
|
||||
<p class="mt-4" th:text="${message != null ? message : 'Что-то пошло не так'}">Что-то пошло не так</p>
|
||||
<a class="btn btn-secondary mt-6" th:href="@{/}">На главную</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,10 +6,28 @@
|
||||
<title th:replace="${title}">LMS</title>
|
||||
<link rel="icon" type="image/svg+xml" th:href="@{/favicon.svg}">
|
||||
<link rel="stylesheet" th:href="@{/css/app.css}">
|
||||
<link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css}">
|
||||
<link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css}" disabled>
|
||||
<script th:src="@{/js/htmx.min.js}" defer></script>
|
||||
<script th:src="@{/js/highlight.min.js}" defer></script>
|
||||
<script>
|
||||
(function(){ var t = localStorage.getItem('lms-theme') || 'light'; document.documentElement.setAttribute('data-theme', t); })();
|
||||
function toggleTheme(){ var e=document.documentElement; var t=e.getAttribute('data-theme')==='dark'?'light':'dark'; e.setAttribute('data-theme',t); localStorage.setItem('lms-theme',t); }
|
||||
(function(){
|
||||
var t = localStorage.getItem('lms-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
window.__hljsSet = function(theme){
|
||||
var l = document.getElementById('hljs-light'), d = document.getElementById('hljs-dark');
|
||||
if (l && d) { l.disabled = theme !== 'light'; d.disabled = theme !== 'dark'; }
|
||||
};
|
||||
window.__hljsAll = function(){
|
||||
if (!window.hljs) return;
|
||||
document.querySelectorAll('pre code').forEach(function(el){ window.hljs.highlightElement(el); });
|
||||
};
|
||||
})();
|
||||
document.addEventListener('DOMContentLoaded', function(){ window.__hljsSet(document.documentElement.getAttribute('data-theme')); window.__hljsAll(); });
|
||||
function toggleTheme(){
|
||||
var e=document.documentElement; var t=e.getAttribute('data-theme')==='dark'?'light':'dark';
|
||||
e.setAttribute('data-theme',t); localStorage.setItem('lms-theme',t); window.__hljsSet(t);
|
||||
}
|
||||
function openModal(id){ var d=document.getElementById(id); if(d) d.showModal(); }
|
||||
function closeModal(id){ var d=document.getElementById(id); if(d) d.close(); }
|
||||
function openModalData(btn){ openModal(btn.dataset.modal); }
|
||||
@@ -22,14 +40,32 @@
|
||||
<div class="page-header-inner">
|
||||
<a th:href="@{/}" class="logo">LMS</a>
|
||||
<nav class="nav">
|
||||
<a th:href="@{/learn}" class="nav-link">Материалы</a>
|
||||
<th:block th:if="${currentUser != null}">
|
||||
<a th:if="${currentUser.admin}" th:href="@{/admin/groups}" class="nav-link">Группы</a>
|
||||
<a th:if="${currentUser.admin}" th:href="@{/admin/labs}" class="nav-link">Лабораторные</a>
|
||||
<a th:unless="${currentUser.admin}" th:href="@{/student/labs}" class="nav-link">Лабораторные</a>
|
||||
<span class="nav-sep" th:text="${currentUser.name}">—</span>
|
||||
<a th:href="@{/change-password}" class="nav-link">Смена пароля</a>
|
||||
<a th:if="${currentUser.admin}" th:href="@{/admin}" class="nav-link nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
|
||||
<a th:unless="${currentUser.admin}" th:href="@{/student}" class="nav-link nav-user" th:text="${currentUser.name}" title="Личный кабинет">—</a>
|
||||
</th:block>
|
||||
<button onclick="toggleTheme()" class="btn btn-secondary btn-sm">☀/🌙</button>
|
||||
<a th:if="${currentUser == null}" th:href="@{/login}" class="btn btn-secondary btn-sm">Войти</a>
|
||||
<button type="button" onclick="toggleTheme()" class="btn btn-secondary btn-sm theme-btn"
|
||||
aria-label="Сменить тему" title="Сменить тему">
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="1.6"/>
|
||||
<path d="M12 7 a5 5 0 0 1 0 10 z" fill="currentColor"/>
|
||||
<g stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
|
||||
<line x1="12" y1="1.4" x2="12" y2="3.2"/>
|
||||
<line x1="12" y1="20.8" x2="12" y2="22.6"/>
|
||||
<line x1="1.4" y1="12" x2="3.2" y2="12"/>
|
||||
<line x1="20.8" y1="12" x2="22.6" y2="12"/>
|
||||
<line x1="4.5" y1="4.5" x2="5.8" y2="5.8"/>
|
||||
<line x1="18.2" y1="18.2" x2="19.5" y2="19.5"/>
|
||||
<line x1="4.5" y1="19.5" x2="5.8" y2="18.2"/>
|
||||
<line x1="18.2" y1="5.8" x2="19.5" y2="4.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
<form th:if="${currentUser != null}" th:action="@{/logout}" method="post">
|
||||
<button class="btn btn-secondary" type="submit">Выйти</button>
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
<head><title th:text="${article.title} + ' — LMS'">Статья</title></head>
|
||||
<body>
|
||||
<div id="content">
|
||||
<nav class="text-sm mb-4">
|
||||
<nav class="text-sm mb-4 breadcrumb">
|
||||
<a th:href="@{/learn}" class="nav-link">Материалы</a>
|
||||
<span class="nav-sep mx-2">/</span>
|
||||
<span class="nav-sep">/</span>
|
||||
<a th:href="@{/learn/{c}(c=${article.chapterSlug})}" class="nav-link" th:text="${article.chapterTitle}">Глава</a>
|
||||
<span class="nav-sep mx-2">/</span>
|
||||
<span class="nav-sep">/</span>
|
||||
<a th:href="@{/learn/{c}/{s}(c=${article.chapterSlug}, s=${article.sectionSlug})}" class="nav-link"
|
||||
th:text="${article.sectionTitle}">Раздел</a>
|
||||
<span class="nav-sep">/</span>
|
||||
<span th:text="${article.title}">Статья</span>
|
||||
</nav>
|
||||
<h1 class="page-title" th:text="${article.title}">Статья</h1>
|
||||
<div class="card card-body md-body" th:utext="${article.bodyHtml}"></div>
|
||||
|
||||
31
web/src/main/resources/templates/learn/chapter.html
Normal file
31
web/src/main/resources/templates/learn/chapter.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
|
||||
<head><title th:text="${chapter.title} + ' — LMS'">Глава</title></head>
|
||||
<body>
|
||||
<div id="content">
|
||||
<nav class="text-sm mb-4 breadcrumb">
|
||||
<a th:href="@{/learn}" class="nav-link">Материалы</a>
|
||||
<span class="nav-sep">/</span>
|
||||
<span th:text="${chapter.title}">Глава</span>
|
||||
</nav>
|
||||
<h1 class="page-title" th:text="${chapter.title}">Глава</h1>
|
||||
|
||||
<div th:if="${#lists.isEmpty(chapter.sections)}" class="card card-body text-muted">
|
||||
В этой главе пока нет разделов.
|
||||
</div>
|
||||
|
||||
<div th:each="section : ${chapter.sections}" class="card card-body mb-4">
|
||||
<h2 class="group-title">
|
||||
<a th:href="@{/learn/{c}/{s}(c=${chapter.slug}, s=${section.slug})}" th:text="${section.title}">Раздел</a>
|
||||
</h2>
|
||||
<ul class="list-pad text-sm">
|
||||
<li th:each="article : ${section.articles}">
|
||||
<a th:href="@{/learn/{c}/{s}/{a}(c=${chapter.slug}, s=${section.slug}, a=${article.slug})}"
|
||||
th:text="${article.title}">Статья</a>
|
||||
</li>
|
||||
<li th:if="${#lists.isEmpty(section.articles)}" class="text-muted">Статей пока нет</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -18,7 +18,7 @@
|
||||
Материалы ещё не добавлены.
|
||||
</div>
|
||||
|
||||
<details th:each="chapter : ${tree?.chapters}" class="card mb-4 accordion" open>
|
||||
<details th:each="chapter, chStat : ${tree?.chapters}" class="card mb-4 accordion" th:attr="open=${chStat.last}">
|
||||
<summary class="card-body acc-head">
|
||||
<h2 class="section-title acc-title" th:text="${chapter.title}">Глава</h2>
|
||||
<span class="acc-arrow" aria-hidden="true"></span>
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
<p th:if="${#lists.isEmpty(chapter.sections)}" class="text-sm text-muted">Разделов пока нет</p>
|
||||
|
||||
<details th:each="section : ${chapter.sections}" class="mt-4 sub-acc" open>
|
||||
<details th:each="section : ${chapter.sections}" class="mt-4 sub-acc">
|
||||
<summary class="sub-head">
|
||||
<h3 class="group-title m-0">
|
||||
<span class="sub-arrow" aria-hidden="true"></span>
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
<head><title th:text="${section.title} + ' — LMS'">Раздел</title></head>
|
||||
<body>
|
||||
<div id="content">
|
||||
<a th:href="@{/learn}" class="nav-link">← Все материалы</a>
|
||||
<h1 class="page-title mt-4" th:text="${section.title}">Раздел</h1>
|
||||
<p class="text-sm text-muted mb-6">Глава: <span th:text="${chapter.title}">—</span></p>
|
||||
<nav class="text-sm mb-4 breadcrumb">
|
||||
<a th:href="@{/learn}" class="nav-link">Материалы</a>
|
||||
<span class="nav-sep">/</span>
|
||||
<a th:href="@{/learn/{c}(c=${chapter.slug})}" class="nav-link" th:text="${chapter.title}">Глава</a>
|
||||
<span class="nav-sep">/</span>
|
||||
<span th:text="${section.title}">Раздел</span>
|
||||
</nav>
|
||||
<h1 class="page-title" th:text="${section.title}">Раздел</h1>
|
||||
<div class="card card-flush">
|
||||
<table>
|
||||
<thead><tr><th>Статья</th></tr></thead>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<span th:if="${c.variantNumber}" class="badge badge-warn">вариант <span class="font-mono" th:text="${c.variantNumber}"></span></span>
|
||||
<span th:if="${c.submissionSeq}" class="badge badge-ok">сдано (попытка <span th:text="${c.submissionSeq}">1</span>)</span>
|
||||
<span th:unless="${c.submissionSeq}" class="badge badge-warn">не сдано</span>
|
||||
<span th:if="${c.grade}" class="badge badge-ok">оценка <span th:text="${c.grade}"></span></span>
|
||||
<span th:if="${c.grade}" class="badge badge-ok">оценка: <span th:text="${c.grade}"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,14 +6,8 @@
|
||||
<a th:href="@{/student/labs}" class="nav-link">← Лабораторные</a>
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mt-4 mb-2">
|
||||
<h1 class="page-title" th:text="|Лабораторная работа ${lab.number}. ${lab.title}|">ЛР</h1>
|
||||
<div class="flex gap-2 text-sm">
|
||||
<span th:if="${grade != null}" class="badge badge-ok">Оценка: <span th:text="${grade}"></span></span>
|
||||
<span th:each="c : ${gradeComments}" class="chip"
|
||||
th:classappend="${c.tone == 'positive'} ? chip-pos : (${c.tone == 'negative'} ? chip-neg : chip-neu)"
|
||||
th:text="${c.text}">коммент</span>
|
||||
<span th:if="${assignment != null}" class="badge badge-warn">Вариант <span class="font-mono" th:text="${assignment.variantNumber}"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-muted mb-6" th:if="${deadlineMsk}">Срок сдачи: <span th:text="${deadlineMsk}"></span> МСК</p>
|
||||
|
||||
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
|
||||
@@ -31,6 +25,58 @@
|
||||
<p class="text-muted">Ваш вариант ещё не распределён. Дождитесь, пока преподаватель распределит варианты.</p>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Сдача работы</h2>
|
||||
<div class="card form-card form-stack mb-4">
|
||||
<p class="text-sm text-muted" th:if="${!canSubmit}">Приём решений закрыт (истёк срок сдачи или уже назначено индивидуальное задание на защиту). Вы по-прежнему можете скачать отправленные работы ниже.</p>
|
||||
<form th:if="${canSubmit}" th:action="@{/student/labs/{id}/submit(id=${lab.id})}" method="post" enctype="multipart/form-data" class="form-stack">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<p class="text-sm text-muted">zip‑архив или исходники (.c/.h). Можно пересдавать сколько угодно раз до истечения срока — засчитывается последняя попытка.</p>
|
||||
<div><input class="input" name="files" type="file" multiple required></div>
|
||||
<button class="btn btn-primary" type="submit">Отправить решение</button>
|
||||
</form>
|
||||
<div th:if="${canSubmit and #lists.isEmpty(submissions)}" class="text-muted text-sm">Вы ещё ничего не сдавали.</div>
|
||||
</div>
|
||||
|
||||
<h3 class="group-title">История сдач</h3>
|
||||
<div class="card card-flush mb-6">
|
||||
<table>
|
||||
<thead><tr><th>Попытка</th><th>Когда (МСК)</th><th>Файлы</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="s : ${submissions}">
|
||||
<td th:text="${s.seq}">1</td>
|
||||
<td class="text-muted" th:text="${s.createdAt}"></td>
|
||||
<td class="font-mono text-sm"><span th:each="f : ${s.files}" th:text="${f}"></span></td>
|
||||
<td><a class="btn btn-secondary btn-sm" th:href="'/student/submissions/' + ${s.id} + '/download'">Скачать</a></td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(submissions)}"><td colspan="4" class="empty">Нет сдач.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<th:block th:if="${canSeeGrade}">
|
||||
<h2 class="section-title">Оценка и комментарии</h2>
|
||||
<div class="card card-body mb-6">
|
||||
<p class="mb-4">Ваша оценка за лабораторную: <span class="badge badge-ok font-mono" th:text="${grade}"></span></p>
|
||||
<div class="review-cols">
|
||||
<div class="review-col" th:if="${!#lists.isEmpty(commentsPositive)}">
|
||||
<h3 class="group-title tone-positive-text">Позитивный</h3>
|
||||
<ul class="review-list"><li th:each="c : ${commentsPositive}" th:text="${c.text}">—</li></ul>
|
||||
</div>
|
||||
<div class="review-col" th:if="${!#lists.isEmpty(commentsNegative)}">
|
||||
<h3 class="group-title tone-negative-text">Негативный</h3>
|
||||
<ul class="review-list"><li th:each="c : ${commentsNegative}" th:text="${c.text}">—</li></ul>
|
||||
</div>
|
||||
<div class="review-col" th:if="${!#lists.isEmpty(commentsNeutral)}">
|
||||
<h3 class="group-title">Нейтральный</h3>
|
||||
<ul class="review-list"><li th:each="c : ${commentsNeutral}" th:text="${c.text}">—</li></ul>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-muted"
|
||||
th:if="${#lists.isEmpty(commentsPositive) and #lists.isEmpty(commentsNegative) and #lists.isEmpty(commentsNeutral)}">
|
||||
Комментариев к оценке нет.
|
||||
</p>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<h2 class="section-title">Защита лабораторной</h2>
|
||||
<div class="card card-body form-stack">
|
||||
@@ -38,7 +84,7 @@
|
||||
Индивидуальное задание для защиты ещё не назначено. Преподаватель сообщит, когда можно приходить.
|
||||
</div>
|
||||
<th:block th:if="${defense != null and defense.status == 'waiting'}">
|
||||
<p class="text-sm">Подойдите к преподавателю: он выдаст вам одноразовый код для начала защиты.</p>
|
||||
<p class="text-sm">Преподаватель уже выдал вам одноразовый код. Подойдите на защиту и введите его:</p>
|
||||
<form class="inline-form" th:action="@{/student/defenses/start}" method="post">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="labId" th:value="${lab.id}">
|
||||
@@ -47,7 +93,7 @@
|
||||
</form>
|
||||
</th:block>
|
||||
<div th:if="${defense != null and defense.status == 'started'}">
|
||||
<p class="text-sm defense-timer" th:data-seconds="${defense.secondsLeft}" data-labid="${lab.id}"></p>
|
||||
<p class="text-sm defense-timer" th:data-seconds="${defense.secondsLeft}"></p>
|
||||
<h3 class="group-title">Ваше индивидуальное задание</h3>
|
||||
<div class="md-body" th:utext="${defense.assignmentHtml}"></div>
|
||||
<form class="mt-4" th:action="'/student/defenses/' + ${defense.id} + '/finish?labId=' + ${lab.id}" method="post">
|
||||
@@ -55,52 +101,12 @@
|
||||
<button class="btn btn-primary" type="submit">Готов к сдаче</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
document.querySelectorAll('.defense-timer').forEach(function (el) {
|
||||
var left = parseInt(el.getAttribute('data-seconds') || '0', 10);
|
||||
var tick = setInterval(function () {
|
||||
left -= 1;
|
||||
if (left <= 0) { clearInterval(tick); window.location.reload(); return; }
|
||||
var m = Math.floor(left / 60), s = left % 60;
|
||||
el.textContent = 'Осталось времени: ' + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
}, 1000);
|
||||
var m = Math.floor(left / 60), s = left % 60;
|
||||
el.textContent = 'Осталось времени: ' + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
});
|
||||
</script>
|
||||
<div th:if="${defense != null and defense.status == 'ready'}">
|
||||
<p class="badge badge-ok defense-note">Завершено (<span th:text="${defense.readyReason == 'timeout' ? 'истекло время' : 'по кнопке'}"></span>).</p>
|
||||
<p class="badge badge-ok">Завершено (<span th:text="${defense.readyReason == 'timeout' ? 'истекло время' : 'по кнопке'}"></span>).</p>
|
||||
<p class="text-sm text-muted">Ожидайте преподавателя с проверкой.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Сдача работы</h2>
|
||||
<div class="card form-card form-stack mb-4">
|
||||
<form th:action="@{/student/labs/{id}/submit(id=${lab.id})}" method="post" enctype="multipart/form-data" class="form-stack">
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<p class="text-sm text-muted">zip‑архив или исходники (.c/.h). Пока не истёк срок, можно пересывать столько раз, сколько нужно — засчитывается последняя попытка.</p>
|
||||
<div><input class="input" name="files" type="file" multiple required></div>
|
||||
<div th:if="${canSubmit}">
|
||||
<button class="btn btn-primary" type="submit">Отправить решение</button>
|
||||
<script src="/js/defense-timer.js"></script>
|
||||
</div>
|
||||
<div th:unless="${canSubmit}" class="alert alert-warning">Срок сдачи истёк — отправка закрыта.</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h3 class="group-title">История сдач</h3>
|
||||
<div class="card card-flush">
|
||||
<table>
|
||||
<thead><tr><th>Попытка</th><th>Когда (МСК)</th><th>Файлы</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="s : ${submissions}">
|
||||
<td th:text="${s.seq}">1</td>
|
||||
<td class="text-muted" th:text="${#strings.replace(s.createdAt, 'T', ' ')}"></td>
|
||||
<td class="font-mono text-sm"><span th:each="f : ${s.files}" th:text="${f}"></span></td>
|
||||
<td><a class="btn btn-secondary btn-sm" th:href="'/student/submissions/' + ${s.id} + '/download'">Скачать</a></td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(submissions)}"><td colspan="4" class="empty">Вы ещё ничего не сдавали.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div></body>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<span th:if="${c.variantNumber}" class="badge badge-warn">Вариант <span class="font-mono" th:text="${c.variantNumber}"></span></span>
|
||||
<span th:if="${c.submissionSeq}" class="badge badge-ok">сдано (попытка <span th:text="${c.submissionSeq}"></span>)</span>
|
||||
<span th:unless="${c.submissionSeq}" class="badge badge-warn">не сдано</span>
|
||||
<span th:if="${c.grade}" class="badge badge-ok">оценка <span th:text="${c.grade}"></span></span>
|
||||
<span th:if="${c.grade}" class="badge badge-ok">оценка: <span th:text="${c.grade}"></span></span>
|
||||
</div>
|
||||
<p class="text-sm text-muted mt-2" th:if="${c.deadlineMsk}" th:text="|Срок сдачи: ${c.deadlineMsk} МСК|">—</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user