diff --git a/.env.example b/.env.example index 38080cd..472537c 100644 --- a/.env.example +++ b/.env.example @@ -19,5 +19,24 @@ SECRET_KEY=your_random_secret_key_here # Google Sheets credentials file path (inside container) CREDENTIALS_FILE=/app/google-credentials/credentials.json +# --- Автоматическое создание репозитория студента (/join/...) --- +# См. docs/REPO_GENERATION_PLAN.md. Не обязательны, если фича не используется - +# без них /join/* эндпоинты отвечают понятной ошибкой 503 вместо падения приложения. + +# OAuth App регистрируется ПРЕПОДАВАТЕЛЕМ на github.com: +# Settings -> Developer settings -> OAuth Apps -> New OAuth App +# Authorization callback URL должен быть <ваш-хост>/join/callback +GITHUB_OAUTH_CLIENT_ID= +GITHUB_OAUTH_CLIENT_SECRET= + +# Публичный URL frontend'а - куда перенаправляется браузер студента +# после прохождения OAuth (страница /join/:courseId/:labId) +FRONTEND_URL=http://localhost:8080 + +# Необязательно: явно задать Authorization callback URL, если backend работает +# за reverse-proxy и request.base_url не совпадает с адресом, зарегистрированным +# в OAuth App. По умолчанию вычисляется как "<адрес запроса>/join/callback" +# GITHUB_OAUTH_CALLBACK_URL=https://your-host/join/callback + # Optional: Branch to deploy (if using variable-based docker-compose.yaml) # BRANCH=main diff --git a/CLAUDE.md b/CLAUDE.md index ea6b75a..219fa2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,11 @@ ADMIN_PASSWORD=... # Admin panel password SECRET_KEY=... # Cookie signing key LOG_DIR=/app/logs # Log directory (optional) LOG_LEVEL=INFO # Logging level (optional) + +# Optional: only needed for /join/... (student repo auto-creation, see docs/REPO_GENERATION_PLAN.md) +GITHUB_OAUTH_CLIENT_ID=... +GITHUB_OAUTH_CLIENT_SECRET=... +FRONTEND_URL=http://localhost:8080 ``` ## Key Conventions @@ -96,6 +101,23 @@ labs: - If score patterns configured but not found in logs → error - If score patterns not configured → no score extraction (backward compatible) +## Student Repo Auto-Creation (`/join/...`) + +Replaces GitHub Classroom's "assignment link" flow (see `docs/REPO_GENERATION_PLAN.md` for full design): +`GET /join/{course_id}/{lab_id}` lands the student on a page with a "Sign in with GitHub" button; +`/join/{course_id}/{lab_id}/start` and `/join/callback` drive a GitHub OAuth Web Application Flow +(username is only ever taken from the verified `GET /user` response, never from the frontend) and then +create the student's repo from the lab's `template-repo` and fix up collaborator access, using the +server's `GITHUB_TOKEN` - not the student's OAuth token. Orchestration lives in +`grading/repo_provisioning.py` (`RepoProvisioner`), mirroring `grading/grader.py`'s `LabGrader`. +Requires a GitHub OAuth App registered by the teacher (not the student) - see `.env.example`. + +Labs with `repo-provisioning: fork` (see `docs/COURSE_CONFIG.md`) can later have template updates propagated +to every student fork as pull requests, from the admin lab list page (`/admin/courses/{course_id}/labs`). +Orchestration lives in `grading/propagate.py` (in-memory job state, single-worker backend required - see +`docs/PROJECT_DESCRIPTION.md`). All `/admin/...` and course-management routes require the `require_admin` +FastAPI dependency in `main.py`, not just the frontend's `ProtectedRoute`. + ## CI/CD - **Tests**: Run on every push via `.github/workflows/tests.yml` diff --git a/courses/index.yaml b/courses/index.yaml index 3b22118..034611b 100644 --- a/courses/index.yaml +++ b/courses/index.yaml @@ -20,7 +20,14 @@ version: "1.0" courses: - - id: "os-2026-spring" + - id: "ml-2026-autumn" + file: "machine-learning-2026.yaml" + status: "active" + priority: 100 + featured: true + logo: "/courses/logos/itmo_ml_logo.png" + + - id: "os-2026" file: "operating-systems-2026.yaml" status: "active" priority: 100 @@ -29,19 +36,19 @@ courses: - id: "mlb-2026-spring" file: "machine-learning-basics-2026.yaml" - status: "active" + status: "archived" priority: 90 logo: "/courses/logos/mlb-2026_logo.png" - id: "rts-2026-spring" file: "random-processes-and-time-series-2026.yaml" - status: "active" + status: "archived" priority: 90 logo: "/courses/logos/itmo_random_processes_time_series.png" - id: "nn-2026-spring" file: "neural-networks-2026.yaml" - status: "active" + status: "archived" priority: 80 logo: "/courses/logos/nn-2026_logo.png" @@ -62,7 +69,7 @@ courses: file: "machine-learning-2025.yaml" status: "archived" priority: 90 - logo: "/courses/logos/ml-2025_logo.png" + logo: "/courses/logos/itmo_ml_logo.png" - id: "fs-2025-autumn" file: "fundamental-statistics-2025.yaml" diff --git a/courses/logos/ml-2025_logo.png b/courses/logos/itmo_ml_logo.png similarity index 100% rename from courses/logos/ml-2025_logo.png rename to courses/logos/itmo_ml_logo.png diff --git a/courses/machine-learning-2026.yaml b/courses/machine-learning-2026.yaml new file mode 100644 index 0000000..de77b84 --- /dev/null +++ b/courses/machine-learning-2026.yaml @@ -0,0 +1,175 @@ +--- +# Configuration file for lab-grader + +course: + name: Машинное обучение + alt-names: + - Машинное обучение + - МО + - Machine learning + - ML + semester: Осень 2026 + university: ИТМО + email: mdpoliak@itmo.ru + timezone: UTC+3 + github: + organization: itmo-ml-2026 + teachers: + - "Mark Polyak" + - markpolyak + google: + spreadsheet: 1zIZOW35vWIV0DkoJtpxy_R0o3PudONXlkDxeRNyVzv0 + info-sheet: График + task-id-column: 0 + student-name-column: 1 + lab-column-offset: 1 + staff: + - name: Поляк Марк Дмитриевич + title: преп. практики + status: лектор + - name: Поляк Марк Дмитриевич + title: преп. практики + status: лабораторные работы + - name: Шаврин Андрей Андреевич + title: ассистент + status: лабораторные работы + - name: Сениченкова Яна Олеговна + title: инженер + status: лабораторные работы + labs: + "0": + github-prefix: ml-lab0 + template-repo: itmo-ml-2026/github-starter-course + repo-provisioning: fork + short-name: ЛР0 + penalty-max: 5 + ignore-task-id: True + ci: + - workflows + files: + - goals.md + - info.md + - report.pdf + moss: + language: c + report: + - Задание + - Результат + validation: + commits: + - + filter: console + min-count: 1 + "1": + github-prefix: ml-lab1 + template-repo: itmo-ml-2026/lab1-eda + repo-provisioning: fork + short-name: ЛР1 + taskid-max: 8 + # ignore-task-id: True + penalty-max: 2 + ci: + workflows: + - run-autograding-tests + - Test python scripts + files: + - exercises.ipynb + moss: + language: python + max-matches: 1000 + local-path: lab1 + report: + - Цель работы + - Индивидуальное задание + - Описание входных данных + - Результат выполнения работы + - Исходный код программы с комментариями + - Выводы + "2": + github-prefix: ml-lab2 + template-repo: itmo-ml-2026/lab2-linreg-sgd + repo-provisioning: fork + short-name: ЛР2 + taskid-max: 7 + # ignore-task-id: True + penalty-max: 2 + ci: + - workflows + files: + - exercises.ipynb + moss: + language: python + max-matches: 1000 + local-path: lab2 + report: + - Цель работы + - Задание на лабораторную работу + - Результат выполнения работы + - Выводы + "3": + github-prefix: ml-lab3 + template-repo: itmo-ml-2026/lab3-binary-classification + repo-provisioning: fork + short-name: ЛР3 + taskid-max: 5 + # ignore-task-id: True + penalty-max: 2 + ci: + - workflows + files: + - exercises.ipynb + moss: + language: python + max-matches: 1000 + local-path: lab3 + report: + - Цель работы + - Задание на лабораторную работу + - Результат выполнения работы + - Выводы + "4": + github-prefix: ml-lab4 + template-repo: + repo-provisioning: fork + short-name: ЛР4 + # taskid-max: 20 + # ignore-task-id: True + penalty-max: 2 + ci: + - workflows + files: + - exercises.ipynb + moss: + language: python + max-matches: 1000 + local-path: lab4 + report: + - Цель работы + - Задание на лабораторную работу + - Результат выполнения работы + - Выводы + "5": + github-prefix: ml-lab5 + template-repo: itmo-ml-2026/lab5-backprop + repo-provisioning: fork + short-name: ЛР5 + taskid-max: 5 + # ignore-task-id: True + penalty-max: 2 + ci: + - workflows + files: + - exercises.ipynb + moss: + language: python + max-matches: 1000 + local-path: lab5 + report: + - Цель работы + - Задание на лабораторную работу + - Результат выполнения работы + - Выводы +misc: + requests-timeout: 5 + + diff --git a/courses/operating-systems-2026.yaml b/courses/operating-systems-2026.yaml index 6fa7a10..4ea0057 100644 --- a/courses/operating-systems-2026.yaml +++ b/courses/operating-systems-2026.yaml @@ -7,7 +7,7 @@ course: - OS - Operating systems - ОС - semester: Весна 2026 + semester: Весна 2026, Осень 2026 university: ГУАП email: k43guap@ya.ru timezone: UTC+3 @@ -32,6 +32,8 @@ course: labs: "0": github-prefix: os-task0 + template-repo: suai-os-2026/github-starter-course + repo-provisioning: fork short-name: ЛР0 penalty-max: 5 ignore-task-id: True @@ -53,6 +55,8 @@ course: min-count: 1 "01": github-prefix: os-task-I-1 + template-repo: suai-os-2026/os-course-task1-1 + repo-provisioning: fork short-name: ЛР0.1 taskid-max: 20 penalty-max: 6 @@ -72,6 +76,8 @@ course: - Выводы "1": github-prefix: os-task1 + template-repo: suai-os-2026/os-course-task1-new + repo-provisioning: fork short-name: ЛР1 taskid-max: 25 penalty-max: 6 @@ -104,6 +110,8 @@ course: - Выводы "2": github-prefix: os-task2 + template-repo: suai-os-2026/os-course-task2 + repo-provisioning: fork short-name: ЛР2 taskid-max: 20 taskid-shift: 4 @@ -140,6 +148,8 @@ course: - Выводы "3": github-prefix: os-task3 + template-repo: suai-os-2026/os-course-task3 + repo-provisioning: fork short-name: ЛР3 taskid-max: 20 penalty-max: 7 @@ -175,6 +185,8 @@ course: - Выводы "4": github-prefix: os-task4 + template-repo: suai-os-2026/os-course-task4 + repo-provisioning: fork short-name: ЛР4 taskid-max: 30 penalty-max: 7 @@ -206,6 +218,8 @@ course: - Выводы "5": github-prefix: os-task5 + template-repo: suai-os-2026/os-course-task5 + repo-provisioning: fork short-name: ЛР5 taskid-max: 30 penalty-max: 8 diff --git a/docs/COURSE_CONFIG.md b/docs/COURSE_CONFIG.md index b7e6f26..00d6e4d 100644 --- a/docs/COURSE_CONFIG.md +++ b/docs/COURSE_CONFIG.md @@ -199,6 +199,47 @@ labs: short-name: ЛР1 ``` +### `template-repo` +**Тип:** `string` +**Описание:** Репозиторий-шаблон в формате `owner/repo`, из которого автоматически создаётся репозиторий студента при переходе по ссылке `/join/{course_id}/{lab_id}` (замена GitHub Classroom, см. `docs/REPO_GENERATION_PLAN.md`). Репозиторий должен быть отмечен как Template repository в настройках GitHub. Обязательно только для использования этой фичи - без него ссылка `/join/...` для лабы отдаёт понятную ошибку конфигурации. +**Пример:** +```yaml +labs: + "1": + github-prefix: os-task1 + template-repo: suai-os-2025/os-task1-template +``` + +### `repo-provisioning` +**Тип:** `string` (`template` | `fork`) +**По умолчанию:** `template` +**Описание:** Способ создания репозитория студента при переходе по ссылке `/join/{course_id}/{lab_id}`. + +- `template` (по умолчанию) - репозиторий создаётся через GitHub API `generate` (template repository). Независимый репозиторий, без связи с шаблоном: нет баннера «forked from», нельзя открыть кросс-репо PR из шаблона в репозиторий студента. +- `fork` - репозиторий создаётся как настоящий fork шаблона: баннер «forked from», сравнение веток, возможность позже открыть PR из шаблона в репозиторий студента. Сразу после создания сервис приводит форк в рабочий вид: включает GitHub Actions (на форках она по умолчанию выключена, без этого CI не запускается и оценивание не работает), снимает унаследованный флаг «template repository» и запрещает форк самого репозитория студента (`allow_forking: false`). Последнее — чтобы студент случайно не форкнул свою работу в личный аккаунт и не начал пушить туда: проверка смотрит только на репозиторий в организации курса, и работа выглядела бы несданной. Настройка действует для приватных репозиториев — в fork-режиме шаблон обязан быть приватным, а форк наследует видимость. + +Неизвестное значение (например, опечатка `forks`) не откатывается молча на `template` - ссылка `/join/...` для такой лабы отдаёт ошибку конфигурации, как и при отсутствующем `template-repo`. + +**Переключение не ретроактивно:** влияет только на репозитории, создаваемые *после* смены режима. Уже созданный через `generate` репозиторий нельзя задним числом связать с форк-сетью шаблона, и наоборот. + +**Требования режима `fork`:** +- Шаблон обязан быть **приватным** (`private: true` в настройках репозитория). Режим `fork` наследует видимость шаблона (в отличие от `template`, который всегда создаёт приватный репозиторий независимо от видимости шаблона) - если он публичный, `/join/...` отдаёт ошибку конфигурации вместо того, чтобы молча создать публичные репозитории студентов. +- На репозитории-шаблоне: `Settings → General → Allow forking`. +- В организации, где создаются репозитории студентов: `Settings → Member privileges → Repository forking policy` - разрешить форки приватных репозиториев внутри организации. + +Без этих настроек GitHub режим `fork` предсказуемо вернёт ошибку при создании репозитория; переключение обратно на `template` в конфиге лабы работает без изменений на стороне GitHub. + +**Пример:** +```yaml +labs: + "1": + github-prefix: os-task1 + template-repo: suai-os-2026/github-starter-course + repo-provisioning: fork +``` + +**Обновление стартового кода уже созданных репозиториев.** Только для `repo-provisioning: fork` - в админке (`/admin/courses/{course_id}/labs`) появляется кнопка «Обновить репозитории студентов», которая рассылает предложение обновления (pull request) во все репозитории-форки этой лабы. Слияние PR остаётся на усмотрение студента - это не принудительный push. Репозитории, созданные до переключения на `fork` (через `generate` или в GitHub Classroom), в рассылку не попадают - для них нет форк-связи с шаблоном. Подробности - в `docs/PROJECT_DESCRIPTION.md`, раздел «Рассылка обновлений стартового кода». + --- ## CI/CD опции @@ -506,6 +547,7 @@ course: labs: "1": github-prefix: os-task1 + template-repo: suai-os-2025/os-task1-template short-name: ЛР1 taskid-max: 25 taskid-shift: 0 diff --git a/docs/PROJECT_DESCRIPTION.md b/docs/PROJECT_DESCRIPTION.md index 3a6f66d..3feb4d8 100644 --- a/docs/PROJECT_DESCRIPTION.md +++ b/docs/PROJECT_DESCRIPTION.md @@ -176,6 +176,9 @@ lab_grader_web/ | GET | `/courses/{course_id}/groups/{group_id}/labs` | Список лабораторных работ | | POST | `/courses/{course_id}/groups/{group_id}/register` | Регистрация студента | | POST | `/courses/{course_id}/groups/{group_id}/labs/{lab_id}/grade` | Проверка лабораторной работы | +| GET | `/join/{course_id}/{lab_id}` | Публичная информация для страницы присоединения к лабе | +| GET | `/join/{course_id}/{lab_id}/start` | Начало GitHub OAuth Flow для создания репозитория студента | +| GET | `/join/callback` | Колбэк GitHub OAuth: создание репозитория и починка доступа (см. `docs/REPO_GENERATION_PLAN.md`) | ### Административные маршруты @@ -188,6 +191,23 @@ lab_grader_web/ | GET | `/courses/{course_id}/edit` | Получение YAML конфигурации | | PUT | `/courses/{course_id}/edit` | Сохранение изменений курса | | POST | `/courses/upload` | Загрузка нового курса | +| GET | `/admin/courses/{course_id}/labs` | Список лаб курса для админки (номер, github-prefix, template-repo, repo-provisioning) | +| POST | `/admin/courses/{course_id}/labs/{lab_id}/propagate-template-update` | Рассылка обновлений шаблона в репозитории студентов через fork PR (только `repo-provisioning: fork`, см. ниже) | +| GET | `/admin/propagate-jobs/{job_id}` | Статус фоновой рассылки обновлений | + +Все административные маршруты (включая перечисленные выше) защищены зависимостью `require_admin` - её отсутствие раньше означало, что `POST /courses/upload`, `DELETE /courses/{course_id}` и `GET/PUT /courses/{course_id}/edit` были доступны без аутентификации на уровне API (защита была только на уровне фронтенда). + +### Рассылка обновлений стартового кода (`propagate-template-update`) + +Замена механизма GitHub Classroom «разослать обновление шаблона всем репозиториям студентов». Работает только для лаб с `repo-provisioning: fork` (см. `docs/COURSE_CONFIG.md`) - только в этом режиме репозитории студентов реально являются форками шаблона, и GitHub может построить кросс-репо PR между ними. + +- `POST .../propagate-template-update` с `{"dry_run": true}` (по умолчанию) синхронно возвращает сводку: сколько репозиториев получат PR и сколько репозиториев с тем же префиксом не являются форками шаблона (`not_a_fork` - обычно те, что созданы до перехода на `fork`, или через старый GitHub Classroom). +- С `{"dry_run": false}` запрос возвращает `202` и `job_id`; рассылка выполняется в фоне (`BackgroundTasks`), прогресс опрашивается через `GET /admin/propagate-jobs/{job_id}`. Повторный запуск для той же пары курс/лаба, пока работа выполняется, отклоняется с `409`. +- Открытие PR - это **предложение** изменений, а не принудительный push: слияние остаётся на усмотрение студента (или преподавателя, если у него есть доступ к репозиторию). Автор PR - владелец сервисного `GITHUB_TOKEN`, а не преподаватель лично. +- Репозитории, созданные до переключения лабы на `repo-provisioning: fork` (через `generate` или ещё в GitHub Classroom), обновлением не охватываются - они показываются в сводке со статусом `not_a_fork`, задним числом связать их с форк-сетью шаблона нельзя. +- Состояние фоновых работ хранится в памяти процесса (`grading/propagate.py`, последние 20 работ). При рестарте бэкенда статус незавершённой работы теряется, но сами уже созданные PR не пропадают - повторный запуск безопасен, для уже открытых PR вернётся статус `pr_exists`. Это требует, чтобы бэкенд запускался **одним uvicorn-воркером** (`CMD` в `backend.Dockerfile` без `--workers`) - при нескольких воркерах опрос статуса работы может попасть в другой процесс и вернуть 404. +- Технически PR открывается не «из шаблона в форк»: GitHub не даёт создать кросс-репо pull request, когда шаблон и репозитории студентов принадлежат одной организации (`head` вида `owner:branch` в этом случае разрешается в сам целевой репозиторий). Вместо этого сервис пользуется тем, что форк и шаблон делят хранилище объектов: в репозиторий студента создаётся служебная ветка `template-update`, указывающая на текущий коммит шаблона, и PR открывается из неё в ветку по умолчанию. При каждой новой рассылке ветка перемещается на актуальный коммит - уже открытый PR при этом обновляется, а не дублируется. Студенту работать с этой веткой не нужно, но она видна в списке веток его репозитория. +- Прогон CI после мержа PR зависит от `paths`-фильтров workflow конкретной лабы в репозитории-шаблоне - обновление файлов вне этих фильтров не запустит проверку автоматически. ## Конфигурация курса diff --git a/frontend/courses-front/package-lock.json b/frontend/courses-front/package-lock.json index 051900d..5906cc6 100644 --- a/frontend/courses-front/package-lock.json +++ b/frontend/courses-front/package-lock.json @@ -15,8 +15,10 @@ "@uiw/react-codemirror": "^4.23.12", "antd": "^5.24.4", "axios": "^1.9.0", + "i18next": "^23.0.1", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-i18next": "^13.0.1", "react-router-dom": "^6.23.0", "rollup": "^4.34.7", "styled-components": "^6.1.16" @@ -2166,6 +2168,7 @@ "version": "19.1.4", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.4.tgz", "integrity": "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g==", + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -4266,6 +4269,15 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.1.0.tgz", + "integrity": "sha512-E0oAXcELOtsXe+BmpJ2EZyedbldPpriV5vICzEuo6xjC/D1lDukOI7KrpfQGF2Qc4wWEy0nk3bFORS2K5ZAhFQ==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -4283,6 +4295,29 @@ "node": ">= 0.8" } }, + "node_modules/i18next": { + "version": "23.16.8", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz", + "integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -6151,6 +6186,28 @@ "react": "^19.1.0" } }, + "node_modules/react-i18next": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-13.5.0.tgz", + "integrity": "sha512-CFJ5NDGJ2MUyBohEHxljOq/39NQ972rh1ajnadG9BjTk+UXbHLq4z5DKEbEQBDoIhUmmbuS/fIMJKo6VOax1HA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", @@ -7233,6 +7290,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -7368,21 +7434,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/courses-front/package.json b/frontend/courses-front/package.json index 8635d64..cb5f8fc 100644 --- a/frontend/courses-front/package.json +++ b/frontend/courses-front/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", + "test": "node --test src/**/*.test.js", "preview": "vite preview" }, "dependencies": { diff --git a/frontend/courses-front/src/App.jsx b/frontend/courses-front/src/App.jsx index 7e830e6..71744fd 100644 --- a/frontend/courses-front/src/App.jsx +++ b/frontend/courses-front/src/App.jsx @@ -6,6 +6,8 @@ import { CourseListWrapper } from "./components/course-list/courseListWrapper"; import { GroupListWrapper } from "./components/group-list/groupListWrapper"; import { LabListWrapper } from "./components/lab-list/labListWrapper"; import { RegistrationFormWrapper } from "./components/registration-form/registrationFormWrapper"; +import { JoinLab } from "./components/JoinLab"; +import { LabListWrapper as AdminLabListWrapper } from "./components/admin/LabList/labListWrapper"; function App() { return ( @@ -20,6 +22,14 @@ function App() { } /> + + + + } + /> } /> } /> } /> + } /> + } /> ); diff --git a/frontend/courses-front/src/api/index.js b/frontend/courses-front/src/api/index.js index c7a265c..97d7a28 100644 --- a/frontend/courses-front/src/api/index.js +++ b/frontend/courses-front/src/api/index.js @@ -1,4 +1,46 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; +const JOIN_REQUEST_TIMEOUT_MS = 10000; + +// Публичные данные для страницы создания репозитория. Сам OAuth намеренно не +// выполняется через fetch: браузер должен перейти на github.com и вернуться +// через callback backend. +export const fetchJoinLab = async (courseId, labId) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), JOIN_REQUEST_TIMEOUT_MS); + let response; + try { + response = await fetch( + `${API_BASE_URL}/join/${encodeURIComponent(courseId)}/${encodeURIComponent(labId)}`, + { signal: controller.signal } + ); + } catch (cause) { + const error = new Error("Unable to load repository-generation settings", { + cause, + }); + error.code = cause?.name === "AbortError" ? "request_timeout" : "unknown"; + throw error; + } finally { + clearTimeout(timeoutId); + } + + if (!response.ok) { + const error = new Error("Unable to load repository-generation settings"); + // Стабильные коды позволяют компоненту переводить ожидаемые ошибки, не + // показывая русскоязычный detail backend во всех поддерживаемых языках UI. + if (response.status === 404) error.code = "join_not_found"; + else if (response.status === 400) error.code = "join_not_configured"; + else if (response.status === 429) error.code = "rate_limit"; + else error.code = "unknown"; + throw error; + } + + return response.json(); +}; + +// GitHub OAuth flow is a full-page redirect, not a fetch - the caller navigates +// the browser to this URL (window.location.href = getJoinStartUrl(...)). +export const getJoinStartUrl = (courseId, labId) => + `${API_BASE_URL}/join/${encodeURIComponent(courseId)}/${encodeURIComponent(labId)}/start`; // Маппинг полей на русские названия для сообщений об ошибках const fieldLabels = { @@ -183,3 +225,4 @@ export async function gradeLab(courseId, groupId, labId, github) { return data; } + diff --git a/frontend/courses-front/src/components/JoinLab/index.jsx b/frontend/courses-front/src/components/JoinLab/index.jsx new file mode 100644 index 0000000..6f23bf0 --- /dev/null +++ b/frontend/courses-front/src/components/JoinLab/index.jsx @@ -0,0 +1,187 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useParams, useSearchParams, useNavigate } from "react-router-dom"; + +import { fetchJoinLab, getJoinStartUrl } from "../../api"; +import { SUPPORTED_LANGUAGES } from "../../language"; +import { ButtonBack } from "../course-list/styled"; +import { + ActionButton, + Description, + Details, + ErrorPanel, + JoinCard, + JoinPage, + Label, + LanguageControl, + LanguageSelect, + RepositoryLink, + Spinner, + SuccessPanel, + Title, + Value, +} from "./styled"; +import { + ERROR_TRANSLATION_KEYS, + getSafeRepositoryUrl, + shouldShowJoinAction, +} from "./state"; + + +export function JoinLab() { + const { courseId, labId } = useParams(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { t, i18n } = useTranslation(); + const [lab, setLab] = useState(null); + const [loadError, setLoadError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isRedirecting, setIsRedirecting] = useState(false); + + const callbackStatus = searchParams.get("status"); + // main.py передаёт код ошибки в query-параметре `reason` (не `error` - тот + // зарезервирован под сырой код ошибки GitHub OAuth, см. join_callback). + const callbackReason = searchParams.get("reason"); + const username = searchParams.get("username"); + const hasLabContext = Boolean(courseId && labId); + const isStandaloneError = !hasLabContext && callbackStatus === "error"; + const repositoryUrl = useMemo( + () => getSafeRepositoryUrl(searchParams.get("repo_url")), + [searchParams] + ); + + useEffect(() => { + let isCurrentRequest = true; + setIsLoading(true); + setLoadError(null); + + if (!hasLabContext) { + setLab(null); + setLoadError(isStandaloneError ? null : "join_not_found"); + setIsLoading(false); + return () => { + isCurrentRequest = false; + }; + } + + fetchJoinLab(courseId, labId) + .then((data) => { + if (isCurrentRequest) setLab(data); + }) + .catch((error) => { + if (isCurrentRequest) setLoadError(error.code || "unknown"); + }) + .finally(() => { + if (isCurrentRequest) setIsLoading(false); + }); + + // React может размонтировать route до завершения запроса. Флаг исключает + // обновление state, относящегося к предыдущей странице курса или лабы. + return () => { + isCurrentRequest = false; + }; + }, [courseId, labId, hasLabContext, isStandaloneError]); + + const beginOAuth = () => { + setIsRedirecting(true); + window.location.assign(getJoinStartUrl(courseId, labId)); + }; + + const translatedError = (code) => + t(ERROR_TRANSLATION_KEYS[code] || "join.errors.unknown"); + + return ( + + navigate("/")}>{t("join.back")} + + + + i18n.changeLanguage(event.target.value)} + > + {SUPPORTED_LANGUAGES.map(({ code, label }) => ( + + ))} + + + + {t("join.title")} + + {isLoading && ( + + + )} + + {!isLoading && loadError && ( + + {t("join.errorTitle")} + {translatedError(loadError)} + + )} + + {!isLoading && isStandaloneError && ( + + {t("join.errorTitle")} + {translatedError(callbackReason)} + + )} + + {!isLoading && lab && ( + <> +
+
+ + {lab.course_name} +
+
+ + {lab.lab_short_name} +
+
+ + {callbackStatus === "success" && repositoryUrl ? ( + + {t("join.successTitle")} + {t("join.successDescription")} + {username && ( + + {t("join.usernameLabel")}: {username} + + )} + + {t("join.openRepository")} + + + ) : callbackStatus === "success" ? ( + + {t("join.errorTitle")} + {t("join.errors.invalidRepositoryLink")} + + ) : callbackStatus === "error" ? ( + + {t("join.errorTitle")} + {translatedError(callbackReason)} + + ) : ( + {t("join.description")} + )} + + {shouldShowJoinAction(callbackStatus, repositoryUrl) && ( + + {isRedirecting ? t("join.redirecting") : t("join.signIn")} + + )} + + )} +
+
+ ); +} diff --git a/frontend/courses-front/src/components/JoinLab/state.js b/frontend/courses-front/src/components/JoinLab/state.js new file mode 100644 index 0000000..e61c1de --- /dev/null +++ b/frontend/courses-front/src/components/JoinLab/state.js @@ -0,0 +1,71 @@ +// Маппинг кодов ошибок бэкенда (main.py `_join_result_redirect` reason=..., и +// `result.error_code` из grading/repo_provisioning.py) на ключи локализации. +// См. таблицу в задаче о переносе UI страницы /join из #48 в #49. +export const ERROR_TRANSLATION_KEYS = { + // main.py: /join/callback redirect reasons + access_denied: "join.errors.oauthDenied", + missing_code: "join.errors.oauthFailed", + oauth_exchange_failed: "join.errors.oauthFailed", + oauth_not_configured: "join.errors.oauthNotConfigured", + invalid_state: "join.errors.oauthStateExpired", + config: "join.errors.notConfigured", + provision_failed: "join.errors.unknown", + + // grading/repo_provisioning.py: RepoProvisioner result.error_code + INVALID_TEMPLATE_CONFIG: "join.errors.notConfigured", + CREATE_VALIDATION_FAILED: "join.errors.repositoryFailed", + CREATE_FORBIDDEN: "join.errors.createForbidden", + RATE_LIMITED: "join.errors.rateLimit", + TEMPLATE_NOT_FOUND: "join.errors.templateUnavailable", + CREATE_FAILED: "join.errors.repositoryFailed", + INVITATIONS_FETCH_FAILED: "join.errors.accessFailed", + REINVITE_DELETE_FAILED: "join.errors.accessFailed", + INVITE_FAILED: "join.errors.invitationFailed", + TEMPLATE_MUST_BE_PRIVATE: "join.errors.templateMustBePrivate", + FORK_TIMEOUT: "join.errors.forkTimeout", + ACTIONS_ENABLE_FAILED: "join.errors.actionsEnableFailed", + NAME_TAKEN_BY_FOREIGN_REPO: "join.errors.nameTaken", + FORK_CHECK_FAILED: "join.errors.forkCheckFailed", + + // src/api/index.js: fetchJoinLab error.code + join_not_found: "join.errors.notFound", + join_not_configured: "join.errors.notConfigured", + rate_limit: "join.errors.rateLimit", + request_timeout: "join.errors.githubUnavailable", + unknown: "join.errors.unknown", +}; + + +export function shouldShowJoinAction(callbackStatus, repositoryUrl) { + // Успех считается завершённым только после проверки безопасной GitHub-ссылки. + // Повреждённый query-параметр не должен оставлять пользователя без повтора. + return callbackStatus !== "success" || !repositoryUrl; +} + + +export function getSafeRepositoryUrl(rawUrl) { + try { + const url = new URL(rawUrl); + const pathParts = url.pathname.split("/").filter(Boolean); + // Параметры результата остаются изменяемым вводом из адресной строки. + // Разрешаем только обычный URL репозитория github.com из двух сегментов, + // чтобы подделанный query string не превратил страницу в открытый redirect. + if ( + url.protocol !== "https:" || + url.hostname !== "github.com" || + url.username !== "" || + url.password !== "" || + url.port !== "" || + url.search !== "" || + url.hash !== "" || + pathParts.length !== 2 || + !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(pathParts[0]) || + !/^[A-Za-z0-9_.-]{1,100}$/.test(pathParts[1]) + ) { + return null; + } + return `https://github.com/${pathParts[0]}/${pathParts[1]}`; + } catch { + return null; + } +} diff --git a/frontend/courses-front/src/components/JoinLab/state.test.js b/frontend/courses-front/src/components/JoinLab/state.test.js new file mode 100644 index 0000000..2d1e96c --- /dev/null +++ b/frontend/courses-front/src/components/JoinLab/state.test.js @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + ERROR_TRANSLATION_KEYS, + getSafeRepositoryUrl, + shouldShowJoinAction, +} from "./state.js"; + + +test("каждый код ошибки бэкенда имеет ключ локализации", () => { + const codes = [ + "access_denied", + "missing_code", + "oauth_exchange_failed", + "oauth_not_configured", + "invalid_state", + "config", + "provision_failed", + "INVALID_TEMPLATE_CONFIG", + "CREATE_VALIDATION_FAILED", + "CREATE_FORBIDDEN", + "RATE_LIMITED", + "TEMPLATE_NOT_FOUND", + "CREATE_FAILED", + "INVITATIONS_FETCH_FAILED", + "REINVITE_DELETE_FAILED", + "INVITE_FAILED", + "TEMPLATE_MUST_BE_PRIVATE", + "FORK_TIMEOUT", + "ACTIONS_ENABLE_FAILED", + "NAME_TAKEN_BY_FOREIGN_REPO", + "FORK_CHECK_FAILED", + "join_not_found", + "join_not_configured", + "rate_limit", + "request_timeout", + "unknown", + ]; + + for (const code of codes) { + assert.equal( + typeof ERROR_TRANSLATION_KEYS[code], + "string", + `код ${code} должен иметь ключ локализации` + ); + } + + // invalid_state - протухший/подделанный state, семантически совпадает с + // истечением времени входа через GitHub. + assert.equal(ERROR_TRANSLATION_KEYS.invalid_state, "join.errors.oauthStateExpired"); + // CREATE_FORBIDDEN - код, специфичный для #49, отдельный от общих ошибок + // создания репозитория. + assert.equal(ERROR_TRANSLATION_KEYS.CREATE_FORBIDDEN, "join.errors.createForbidden"); +}); + + +test("страница принимает только обычную ссылку репозитория github.com", () => { + assert.equal( + getSafeRepositoryUrl("https://github.com/test-org/test-repository"), + "https://github.com/test-org/test-repository" + ); + assert.equal(getSafeRepositoryUrl("https://example.com/test-org/repository"), null); + assert.equal(getSafeRepositoryUrl("javascript:alert(1)"), null); + assert.equal(getSafeRepositoryUrl("https://github.com/test-org/repository/issues"), null); + assert.equal(getSafeRepositoryUrl("https://user:password@github.com/org/repo"), null); + assert.equal(getSafeRepositoryUrl("https://github.com:444/org/repo"), null); + assert.equal(getSafeRepositoryUrl("https://github.com/org/repo?tab=readme"), null); + assert.equal(getSafeRepositoryUrl("https://github.com/org/repo#readme"), null); + assert.equal(getSafeRepositoryUrl("https://github.com/org%2Frepo/other"), null); +}); + + +test("повреждённая ссылка успеха оставляет кнопку повторного входа", () => { + assert.equal(shouldShowJoinAction("success", null), true); + assert.equal( + shouldShowJoinAction("success", "https://github.com/org/repository"), + false + ); + assert.equal(shouldShowJoinAction("error", null), true); +}); diff --git a/frontend/courses-front/src/components/JoinLab/styled.js b/frontend/courses-front/src/components/JoinLab/styled.js new file mode 100644 index 0000000..74a7671 --- /dev/null +++ b/frontend/courses-front/src/components/JoinLab/styled.js @@ -0,0 +1,143 @@ +import styled, { keyframes } from "styled-components"; + +import { buttonStyles, colors, textStyles } from "../../../theme"; + + +const rotate = keyframes` + to { transform: rotate(360deg); } +`; + +export const JoinPage = styled.main` + width: min(1200px, calc(100vw - 2em)); + min-height: calc(100vh - 2em); + box-sizing: border-box; + display: grid; + place-items: center; + padding: 24px; + background: #f6f7f9; +`; + +export const JoinCard = styled.section` + ${textStyles} + width: min(520px, 100%); + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 20px; + padding: 32px; + border-radius: 16px; + background: #fff; + box-shadow: 0 12px 36px rgba(24, 24, 24, 0.1); +`; + +export const LanguageControl = styled.div` + align-self: flex-end; + min-width: 140px; +`; + +export const LanguageSelect = styled.select` + ${textStyles} + width: 100%; + box-sizing: border-box; + padding: 8px 10px; + border: 1px solid ${colors.buttonHover}; + border-radius: 8px; + background: #fff; + color: ${colors.textPrimary}; + cursor: pointer; + + &:focus-visible { + outline: 2px solid ${colors.buttonBackground}; + outline-offset: 2px; + } +`; + +export const Title = styled.h1` + margin: 0; + color: ${colors.textPrimary}; + font-size: 24px; + line-height: 1.35; +`; + +export const Description = styled.p` + display: flex; + align-items: center; + gap: 10px; + margin: 0; + color: ${colors.textSecondary}; + font-size: 14px; + line-height: 1.6; +`; + +export const Details = styled.div` + display: grid; + gap: 14px; + padding: 18px; + border: 1px solid ${colors.buttonHover}; + border-radius: 12px; +`; + +export const Label = styled.div` + margin-bottom: 4px; + color: ${colors.textSecondary}; + font-size: 12px; +`; + +export const Value = styled.div` + color: ${colors.textPrimary}; + font-size: 15px; + font-weight: 600; +`; + +export const ActionButton = styled.button` + ${buttonStyles} + width: 100%; + align-items: center; + padding: 12px 18px; + background: ${colors.buttonBackground}; + color: ${colors.buttonText}; + font-size: 14px; + cursor: pointer; + + &:disabled { + cursor: wait; + opacity: 0.65; + } +`; + +const ResultPanel = styled.div` + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; + border-radius: 12px; + font-size: 14px; + line-height: 1.5; +`; + +export const SuccessPanel = styled(ResultPanel)` + border: 1px solid ${colors.save}; + background: rgba(34, 195, 142, 0.08); +`; + +export const ErrorPanel = styled(ResultPanel)` + border: 1px solid ${colors.error}; + background: rgba(235, 87, 87, 0.08); +`; + +export const RepositoryLink = styled.a` + color: ${colors.textPrimary}; + font-weight: 600; + text-decoration: underline; + text-underline-offset: 3px; +`; + +export const Spinner = styled.span` + width: 16px; + height: 16px; + flex: 0 0 16px; + border: 2px solid ${colors.buttonHover}; + border-top-color: ${colors.buttonBackground}; + border-radius: 50%; + animation: ${rotate} 0.8s linear infinite; +`; diff --git a/frontend/courses-front/src/components/admin/LabList/index.jsx b/frontend/courses-front/src/components/admin/LabList/index.jsx new file mode 100644 index 0000000..5c55d53 --- /dev/null +++ b/frontend/courses-front/src/components/admin/LabList/index.jsx @@ -0,0 +1,420 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { + Table, + TableHead, + TableBody, + TableRow, + TableCell, + Tooltip, + Chip, + LinearProgress, + Snackbar, + Alert, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button as MuiButton, + Checkbox, +} from "@mui/material"; +import { + Container, + Panel, + PageTitle, + BackButton, + TableWrapper, + SelectableTableWrapper, + HintText, +} from "./styled"; + +// Опрос статуса фоновой работы - см. main.py GET /admin/propagate-jobs/{job_id} +const JOB_POLL_INTERVAL_MS = 2000; + +const RESULT_STATUS_COLOR = { + will_process: "default", + pr_created: "success", + up_to_date: "info", + pr_exists: "info", + not_a_fork: "warning", + error: "error", +}; + +async function fetchJson(url, options) { + const response = await fetch(url, { credentials: "include", ...options }); + let data = null; + try { + data = await response.json(); + } catch { + // no body + } + if (!response.ok) { + const error = new Error((data && data.detail) || `HTTP ${response.status}`); + error.status = response.status; + throw error; + } + return data; +} + +export const LabList = ({ courseId, onBack }) => { + const { t } = useTranslation(); + + const [labs, setLabs] = useState([]); + const [loading, setLoading] = useState(true); + const [snackbar, setSnackbar] = useState({ open: false, message: "", severity: "info" }); + + const [selectedLab, setSelectedLab] = useState(null); + const [dryRunOpen, setDryRunOpen] = useState(false); + const [dryRunLoading, setDryRunLoading] = useState(false); + const [dryRunResult, setDryRunResult] = useState(null); + // Имена репозиториев, которым уйдёт обновление. Заполняется всеми + // will_process при получении предпросмотра; выбор живёт только пока + // открыт диалог - при повторном открытии список перезапрашивается. + const [selectedRepos, setSelectedRepos] = useState([]); + const [starting, setStarting] = useState(false); + + const [job, setJob] = useState(null); + const pollRef = useRef(null); + + const showSnackbar = (message, severity = "info") => setSnackbar({ open: true, message, severity }); + + const loadLabs = useCallback(() => { + setLoading(true); + fetchJson(`/api/v1/admin/courses/${courseId}/labs`) + .then((data) => { + setLabs(data); + setLoading(false); + }) + .catch((err) => { + setLoading(false); + showSnackbar(err.message || t("adminLabs.errorLoadingLabs"), "error"); + }); + }, [courseId, t]); + + useEffect(() => { + loadLabs(); + }, [loadLabs]); + + const stopPolling = () => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }; + + useEffect(() => stopPolling, []); + + const pollJob = useCallback((jobId) => { + stopPolling(); + const poll = () => { + fetchJson(`/api/v1/admin/propagate-jobs/${jobId}`) + .then((data) => { + setJob(data); + if (data.status !== "running") { + stopPolling(); + } + }) + .catch(() => { + stopPolling(); + }); + }; + poll(); + pollRef.current = setInterval(poll, JOB_POLL_INTERVAL_MS); + }, []); + + const handleUpdateClick = (lab) => { + setSelectedLab(lab); + setDryRunResult(null); + setDryRunOpen(true); + setDryRunLoading(true); + fetchJson(`/api/v1/admin/courses/${courseId}/labs/${lab.id}/propagate-template-update`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dry_run: true }), + }) + .then((data) => { + setDryRunResult(data); + setSelectedRepos( + data.results.filter((r) => r.status === "will_process").map((r) => r.repo) + ); + setDryRunLoading(false); + }) + .catch((err) => { + setDryRunLoading(false); + setDryRunOpen(false); + showSnackbar(err.message || t("adminLabs.errors.dryRunFailed"), "error"); + }); + }; + + const handleCloseDryRun = () => { + setDryRunOpen(false); + setSelectedLab(null); + setDryRunResult(null); + setSelectedRepos([]); + }; + + const toggleRepo = (repo) => + setSelectedRepos((current) => + current.includes(repo) ? current.filter((r) => r !== repo) : [...current, repo] + ); + + const handleConfirmRun = () => { + if (!selectedLab) return; + setStarting(true); + fetchJson(`/api/v1/admin/courses/${courseId}/labs/${selectedLab.id}/propagate-template-update`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dry_run: false, repos: selectedRepos }), + }) + .then((data) => { + setStarting(false); + setDryRunOpen(false); + setJob({ + job_id: data.job_id, + status: "running", + total: selectedRepos.length, + processed: 0, + results: [], + }); + pollJob(data.job_id); + }) + .catch((err) => { + setStarting(false); + if (err.status === 409) { + showSnackbar(t("adminLabs.errors.alreadyRunning"), "error"); + } else { + showSnackbar(err.message || t("adminLabs.errors.startFailed"), "error"); + } + }); + }; + + const handleCloseJob = () => { + stopPolling(); + setJob(null); + setSelectedLab(null); + }; + + const willProcess = dryRunResult + ? dryRunResult.results.filter((r) => r.status === "will_process") + : []; + const notAFork = dryRunResult + ? dryRunResult.results.filter((r) => r.status === "not_a_fork") + : []; + const allSelected = willProcess.length > 0 && selectedRepos.length === willProcess.length; + const someSelected = selectedRepos.length > 0 && !allSelected; + const toggleAll = () => + setSelectedRepos(allSelected ? [] : willProcess.map((r) => r.repo)); + + return ( + + + {t("adminLabs.back")} + {t("adminLabs.title")} + + {loading ? ( + {t("adminLabs.loading")} + ) : ( + + + + + {t("adminLabs.columns.number")} + {t("adminLabs.columns.shortName")} + {t("adminLabs.columns.githubPrefix")} + {t("adminLabs.columns.templateRepo")} + {t("adminLabs.columns.provisioning")} + {t("adminLabs.columns.actions")} + + + + {labs.map((lab) => ( + + {lab.id} + {lab.short_name} + {lab.github_prefix || "—"} + {lab.template_repo || "—"} + + {lab.repo_provisioning === "fork" + ? t("adminLabs.provisioningFork") + : t("adminLabs.provisioningTemplate")} + + + {lab.can_propagate ? ( + handleUpdateClick(lab)}> + {t("adminLabs.updateButton")} + + ) : ( + + + + {t("adminLabs.updateButton")} + + + + )} + + + ))} + +
+
+ )} +
+ + {/* Диалог предпросмотра (dry-run) перед реальной рассылкой */} + + {t("adminLabs.dryRun.title")} + + {dryRunLoading ? ( + + ) : dryRunResult ? ( + <> +

{t("adminLabs.dryRun.summary", { count: willProcess.length })}

+

{t("adminLabs.dryRun.selectedCount", { selected: selectedRepos.length, total: willProcess.length })}

+ {notAFork.length > 0 && ( +

{t("adminLabs.dryRun.notAFork", { count: notAFork.length })}

+ )} + + + + + + + + {t("adminLabs.dryRun.selectAll")} + + + + + {willProcess.map((r) => ( + toggleRepo(r.repo)}> + + + + {r.repo} + + + + + ))} + {notAFork.map((r) => ( + + {/* Форк-связи нет, рассылать нечего - строка без чекбокса */} + + {r.repo} + + + + + ))} + +
+
+ + ) : null} +
+ + {t("adminLabs.dryRun.cancel")} + + {t("adminLabs.dryRun.confirm", { count: selectedRepos.length })} + + +
+ + {/* Прогресс и итоги фоновой рассылки */} + + {t("adminLabs.progress.title")} + + {job && ( + <> +

+ {job.status === "running" && t("adminLabs.progress.inProgress", { processed: job.processed, total: job.total })} + {job.status === "done" && t("adminLabs.progress.done")} + {job.status === "failed" && `${t("adminLabs.progress.failed")}${job.error ? `: ${job.error}` : ""}`} +

+ {job.status === "running" && ( + + )} + {job.results && job.results.length > 0 && ( + + + + + {t("adminLabs.progress.columns.repo")} + {t("adminLabs.progress.columns.status")} + {t("adminLabs.progress.columns.link")} + + + + {job.results.map((r) => ( + + {r.repo} + + + + + {r.pr_url && ( + + {t("adminLabs.progress.openPr")} + + )} + + + ))} + +
+
+ )} + + )} +
+ + + {t("adminLabs.progress.close")} + + +
+ + setSnackbar((prev) => ({ ...prev, open: false }))} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + > + setSnackbar((prev) => ({ ...prev, open: false }))} + severity={snackbar.severity} + sx={{ width: "100%" }} + > + {snackbar.message} + + +
+ ); +}; diff --git a/frontend/courses-front/src/components/admin/LabList/labListWrapper.jsx b/frontend/courses-front/src/components/admin/LabList/labListWrapper.jsx new file mode 100644 index 0000000..05a6270 --- /dev/null +++ b/frontend/courses-front/src/components/admin/LabList/labListWrapper.jsx @@ -0,0 +1,8 @@ +import { useParams, useNavigate } from "react-router-dom"; +import { LabList } from "."; + +export function LabListWrapper() { + const { courseId } = useParams(); + const navigate = useNavigate(); + return navigate("/admin/courses")} />; +} diff --git a/frontend/courses-front/src/components/admin/LabList/styled.js b/frontend/courses-front/src/components/admin/LabList/styled.js new file mode 100644 index 0000000..45fa318 --- /dev/null +++ b/frontend/courses-front/src/components/admin/LabList/styled.js @@ -0,0 +1,75 @@ +import styled from "styled-components"; +import { colors, sizes, textStyles, breakpoints } from "../../../../theme"; + +export const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; + padding: 16px; +`; + +export const Panel = styled.div` + display: flex; + width: 100%; + max-width: 960px; + flex-direction: column; + border-radius: 12px; + background: #fff; + box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.1); + padding: 24px; + gap: 16px; + + @media (max-width: ${breakpoints.tablet}) { + padding: 16px; + } +`; + +export const PageTitle = styled.h1` + ${textStyles} + color: ${colors.textPrimary}; + font-size: ${sizes.fontSizeLarge}; + font-weight: 500; + margin: 0; +`; + +export const BackButton = styled.button` + align-self: flex-start; + ${textStyles} + color: ${colors.buttonBorder}; + border: none; + background: none; + font-size: ${sizes.fontSizeMedium}; + padding: 4px 0; + + &:hover { + color: ${colors.cancel}; + cursor: pointer; + } +`; + +export const TableWrapper = styled.div` + width: 100%; + overflow-x: auto; +`; + +// Таблица предпросмотра прокручивается сама, а не вместе с содержимым диалога: +// sticky-шапка держится за ближайшего прокручиваемого предка, и без этого +// строка "Выбрать все" уезжает вверх на курсе в две сотни студентов. +export const SelectableTableWrapper = styled(TableWrapper)` + max-height: 50vh; + overflow-y: auto; +`; + +export const StatusChipRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 6px; +`; + +export const HintText = styled.p` + ${textStyles} + color: ${colors.textSecondary}; + font-size: ${sizes.fontSizeSmall}; + margin: 0; +`; diff --git a/frontend/courses-front/src/components/course-list/index.jsx b/frontend/courses-front/src/components/course-list/index.jsx index f514f21..0622db9 100644 --- a/frontend/courses-front/src/components/course-list/index.jsx +++ b/frontend/courses-front/src/components/course-list/index.jsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from "react"; +import { useNavigate } from "react-router-dom"; import CodeMirror from "@uiw/react-codemirror"; import { yaml } from "@codemirror/lang-yaml"; import { Select, MenuItem } from "@mui/material"; @@ -33,6 +34,7 @@ import { export const CourseList = ({ onSelectCourse, isAdmin = false }) => { const { t, i18n } = useTranslation(); + const navigate = useNavigate(); const [courses, setCourses] = useState([]); const [courseStatus, setCourseStatus] = useState("active"); // "active", "archived", "all" @@ -472,6 +474,13 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { {t("edit")} + +