diff --git a/CLAUDE.md b/CLAUDE.md index ca4303c..ea6b75a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ See `docs/PROJECT_DESCRIPTION.md` for full project documentation. - **Backend**: Single file `main.py` (FastAPI monolith, ~750 LOC) - **Frontend**: `frontend/courses-front/` (React 19 + Vite) -- **Courses**: `courses/index.yaml` + individual YAML files +- **Courses**: `courses/index.yaml` + individual YAML files (see `docs/COURSE_CONFIG.md` for all options) - **Tests**: `tests/` (pytest) ### Development Commands @@ -60,7 +60,10 @@ LOG_LEVEL=INFO # Logging level (optional) - **Success**: `v` written to Google Sheets - **Failure**: `x` written to Google Sheets - **With penalty**: `v-{n}` where n = penalty points +- **With score**: `v@{score}` where score = points earned (e.g., `v@10.5` or `v@10,5`) +- **With score and penalty**: `v@{score}-{n}` (e.g., `v@10.5-3` or `v@10,5-3`) - **Protection**: Can only overwrite empty cells, `x`, or cells starting with `?` +- **Decimal separator**: Automatically detected from Google Sheets locale settings ### Lab Config Structure (course YAML) @@ -79,8 +82,20 @@ labs: - cpplint files: # Required files in repo - lab2.cpp + score: # Optional: Extract score from logs + patterns: # List of regex patterns (tried in order) + - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' # e.g., "##[notice]Points 10/10" -> 10 + - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' # e.g., "Score is 10.5" -> 10.5 + - 'Total:\s+(\d+(?:[.,]\d+)?)' # e.g., "Total: 10" -> 10 ``` +**Score extraction notes:** +- Patterns are regex with first capturing group = score value +- Accepts both `.` and `,` as decimal separator in logs +- Output format matches Google Sheets locale (e.g., `10.5` for en_US, `10,5` for ru_RU) +- If score patterns configured but not found in logs → error +- If score patterns not configured → no score extraction (backward compatible) + ## CI/CD - **Tests**: Run on every push via `.github/workflows/tests.yml` diff --git a/courses/fundamental-statistics-2025.yaml b/courses/fundamental-statistics-2025.yaml index 99f8e56..2c72e7e 100644 --- a/courses/fundamental-statistics-2025.yaml +++ b/courses/fundamental-statistics-2025.yaml @@ -9,7 +9,7 @@ course: - ОснСтат semester: Осень 2025 university: ИТМО - email: k43guap@ya.ru + email: mdpoliak@itmo.ru timezone: UTC+3 github: organization: itmo-fs-2025 @@ -35,6 +35,11 @@ course: short-name: ДЗ0 penalty-max: 5 ignore-task-id: True + # score: + # patterns: + # - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' # "##[notice]Points 10/10" -> 10 + # - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' # "Score is 10.5" -> 10.5 + # - 'Total:\s+(\d+(?:[.,]\d+)?)' # "Total: 10" -> 10 ci: - workflows files: @@ -54,9 +59,16 @@ course: "1": github-prefix: fs-lab1 short-name: ДЗ1 - # taskid-max: 25 - ignore-task-id: True - penalty-max: 6 + taskid-max: 10 + # ignore-task-id: True + penalty-max: 5 + score: + patterns: + - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' # Гибкий паттерн для "ПРЕДВАРИТЕЛЬНАЯ ОЦЕНКА В ЖУРНАЛ: 10.0" + # - 'ИТОГО:\s*(\d+(?:[.,]\d+)?)\s*баллов' # "ИТОГО: 100 баллов" -> 100 + # - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' # "##[notice]Points 10/10" -> 10 + # - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' # "Score is 10.5" -> 10.5 + # - 'Total:\s+(\d+(?:[.,]\d+)?)' # "Total: 10" -> 10 ci: - workflows files: @@ -72,5 +84,53 @@ course: - Результат выполнения работы - Исходный код программы с комментариями - Выводы + "2": + github-prefix: fs-lab2 + short-name: ДЗ2 + taskid-max: 10 + penalty-max: 5 + score: + patterns: + - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' # Гибкий паттерн для "ПРЕДВАРИТЕЛЬНАЯ ОЦЕНКА В ЖУРНАЛ: 10.0" + ci: + - workflows + files: + - lab2_distributions.ipynb + moss: + language: python + max-matches: 100 + local-path: lab2 + "3": + github-prefix: fs-lab3 + short-name: ДЗ3 + taskid-max: 10 + penalty-max: 5 + score: + patterns: + - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' # Гибкий паттерн для "ПРЕДВАРИТЕЛЬНАЯ ОЦЕНКА В ЖУРНАЛ: 10.0" + ci: + - workflows + files: + - exercises.ipynb + moss: + language: python + max-matches: 100 + local-path: lab3 + "4": + github-prefix: fs-lab4 + short-name: ДЗ4 + taskid-max: 10 + penalty-max: 5 + score: + patterns: + - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' # Гибкий паттерн для "ПРЕДВАРИТЕЛЬНАЯ ОЦЕНКА В ЖУРНАЛ: 10.0" + ci: + - workflows + files: + - exercises.ipynb + moss: + language: python + max-matches: 100 + local-path: lab4 misc: requests-timeout: 5 diff --git a/courses/index.yaml b/courses/index.yaml index c288be0..ed0cb5d 100644 --- a/courses/index.yaml +++ b/courses/index.yaml @@ -27,12 +27,18 @@ courses: featured: true logo: "/courses/logos/os-2025_logo.png" - - id: "ml-2025-spring" + - id: "mlb-2025-spring" file: "machine-learning-basics-2025.yaml" - status: "active" + status: "archived" priority: 90 logo: "/courses/logos/mlb-2025_logo.png" + - id: "ml-2025-autumn" + file: "machine-learning-2025.yaml" + status: "active" + priority: 90 + logo: "/courses/logos/ml-2025_logo.png" + - id: "fs-2025-autumn" file: "fundamental-statistics-2025.yaml" status: "active" diff --git a/courses/logos/ml-2025_logo.png b/courses/logos/ml-2025_logo.png new file mode 100644 index 0000000..ef386a2 Binary files /dev/null and b/courses/logos/ml-2025_logo.png differ diff --git a/courses/machine-learning-2025.yaml b/courses/machine-learning-2025.yaml new file mode 100644 index 0000000..35ad451 --- /dev/null +++ b/courses/machine-learning-2025.yaml @@ -0,0 +1,157 @@ +--- +# Configuration file for lab-grader + +course: + name: Машинное обучение + alt-names: + - Машинное обучение + - МО + - Machine learning + - ML + semester: Осень 2025 + university: ИТМО + email: mdpoliak@itmo.ru + timezone: UTC+3 + github: + organization: itmo-ml-2025 + teachers: + - "Mark Polyak" + - markpolyak + google: + spreadsheet: 13AEYDlW989lUwhIF6p3KrWlw2Iofoy2YuL55V2urIaw + info-sheet: График + task-id-column: 0 + student-name-column: 1 + lab-column-offset: 1 + staff: + - name: Поляк Марк Дмитриевич + title: преп. практики + status: лектор + - name: Поляк Марк Дмитриевич + title: преп. практики + status: лабораторные работы + labs: + "0": + github-prefix: ml-lab0 + 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 + short-name: ЛР1 + # taskid-max: 40 + 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 + short-name: ЛР2 + # taskid-max: 20 + 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 + short-name: ЛР3 + # taskid-max: 20 + 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 + short-name: ЛР4 + # taskid-max: 20 + ignore-task-id: True + penalty-max: 2 + ci: + - workflows + files: + - models_train.ipynb + moss: + language: python + max-matches: 1000 + local-path: lab4 + report: + - Цель работы + - Задание на лабораторную работу + - Результат выполнения работы + - Выводы + "5": + github-prefix: ml-lab5 + short-name: ЛР5 + # taskid-max: 20 + ignore-task-id: True + penalty-max: 2 + ci: + - workflows + files: + - models_train.ipynb + moss: + language: python + max-matches: 1000 + local-path: lab5 + report: + - Цель работы + - Задание на лабораторную работу + - Результат выполнения работы + - Выводы +misc: + requests-timeout: 5 + + diff --git a/courses/operating-systems-2025.yaml b/courses/operating-systems-2025.yaml index f9f10be..3e89675 100644 --- a/courses/operating-systems-2025.yaml +++ b/courses/operating-systems-2025.yaml @@ -106,7 +106,7 @@ course: short-name: ЛР2 taskid-max: 20 taskid-shift: 4 - penalty-max: 9 + penalty-max: 8 ci: - workflows files: @@ -174,7 +174,7 @@ course: github-prefix: os-task4 short-name: ЛР4 taskid-max: 30 - penalty-max: 8 + penalty-max: 7 ci: - workflows files: @@ -204,7 +204,7 @@ course: github-prefix: os-task5 short-name: ЛР5 taskid-max: 30 - penalty-max: 10 + penalty-max: 8 # ignore-completion-date: True ci: workflows: diff --git a/docs/COURSE_CONFIG.md b/docs/COURSE_CONFIG.md new file mode 100644 index 0000000..b7e6f26 --- /dev/null +++ b/docs/COURSE_CONFIG.md @@ -0,0 +1,560 @@ +# Опции конфигурации курса + +Этот документ описывает все поддерживаемые опции в YAML конфигурации курса. + +## Структура файла + +```yaml +course: + # Общая информация о курсе + +labs: + "0": + # Конфигурация лабораторной работы 0 + "1": + # Конфигурация лабораторной работы 1 + +misc: + # Дополнительные настройки +``` + +--- + +## Секция `course` + +Общая информация о курсе. + +### `name` (обязательно) +**Тип:** `string` +**Описание:** Полное название курса +**Пример:** +```yaml +name: Операционные системы +``` + +### `alt-names` +**Тип:** `list[string]` +**Описание:** Альтернативные названия курса (для поиска/фильтрации) +**Пример:** +```yaml +alt-names: + - OS + - Operating systems + - ОС +``` + +### `semester` +**Тип:** `string` +**Описание:** Семестр проведения курса +**Пример:** +```yaml +semester: Осень 2025 +``` + +### `university` +**Тип:** `string` +**Описание:** Название университета +**Пример:** +```yaml +university: ИТМО +``` + +### `email` +**Тип:** `string` +**Описание:** Контактный email преподавателя +**Пример:** +```yaml +email: teacher@university.edu +``` + +### `timezone` +**Тип:** `string` +**Описание:** Часовой пояс для расчёта дедлайнов +**Пример:** +```yaml +timezone: UTC+3 +``` + +--- + +## Секция `course.github` + +Настройки интеграции с GitHub. + +### `organization` (обязательно) +**Тип:** `string` +**Описание:** Название GitHub организации с репозиториями студентов +**Пример:** +```yaml +github: + organization: suai-os-2025 +``` + +### `teachers` +**Тип:** `list[string]` +**Описание:** Список преподавателей (имена и GitHub username'ы) +**Пример:** +```yaml +github: + teachers: + - "Mark Polyak" + - markpolyak +``` + +--- + +## Секция `course.google` + +Настройки интеграции с Google Sheets. + +### `spreadsheet` (обязательно) +**Тип:** `string` +**Описание:** ID таблицы Google Sheets (из URL) +**Пример:** +```yaml +google: + spreadsheet: 1BoVLNZpP6zSc7DPSVkbxaQ_oJZcZ_f-zyMCfF1gw-PM +``` + +### `info-sheet` (обязательно) +**Тип:** `string` +**Описание:** Название листа с информацией о студентах и оценками +**Пример:** +```yaml +google: + info-sheet: График +``` + +### `task-id-column` (обязательно) +**Тип:** `integer` +**Описание:** Номер колонки с номерами вариантов (TASKID). Нумерация с 0. +**Примечание:** В конфиге указывается 0-based индекс, система автоматически конвертирует в 1-based для gspread API. +**Пример:** +```yaml +google: + task-id-column: 0 # Колонка A +``` + +### `student-name-column` (обязательно) +**Тип:** `integer` +**Описание:** Номер колонки с именами студентов. Нумерация с 0. +**Пример:** +```yaml +google: + student-name-column: 1 # Колонка B +``` + +### `lab-column-offset` +**Тип:** `integer` +**По умолчанию:** `0` +**Описание:** Смещение колонок с лабораторными работами относительно `student-name-column`. +**Пример:** +```yaml +google: + lab-column-offset: 1 # Лабы начинаются через 1 колонку после имени студента +``` + +--- + +## Секция `course.staff` + +Список преподавателей и ассистентов. + +**Тип:** `list[dict]` +**Описание:** Информация о преподавателях для отображения на frontend +**Пример:** +```yaml +staff: + - name: Поляк Марк Дмитриевич + title: ст. преп. + status: лектор + - name: Иванов Иван Иванович + title: ассистент + status: лабораторные работы +``` + +--- + +## Секция `course.labs` + +Конфигурация лабораторных работ. Ключ - номер лабораторной работы (строка). + +### `github-prefix` (обязательно) +**Тип:** `string` +**Описание:** Префикс названия репозитория. Полное имя репозитория: `{github-prefix}-{username}` +**Пример:** +```yaml +labs: + "1": + github-prefix: os-task1 # Репозиторий: os-task1-studentname +``` + +### `short-name` (обязательно) +**Тип:** `string` +**Описание:** Краткое название лабораторной работы (для заголовка колонки в Google Sheets) +**Пример:** +```yaml +labs: + "1": + short-name: ЛР1 +``` + +--- + +## CI/CD опции + +### `ci` +**Тип:** `list` или `dict` +**Описание:** Настройка проверки GitHub Actions workflows + +**Формат 1 (простой):** +```yaml +ci: + - workflows # Проверяет все найденные workflows +``` + +**Формат 2 (с указанием конкретных jobs):** +```yaml +ci: + workflows: + - run-autograding-tests + - cpplint + - build (MINGW64, MinGW Makefiles) +``` + +**Примечание:** Если указаны конкретные jobs, будут проверяться только они. Если не указаны, используются DEFAULT_JOB_NAMES из `ci_checker.py`. + +--- + +## TASKID опции + +### `taskid-max` +**Тип:** `integer` +**Описание:** Максимальный номер варианта для валидации +**Пример:** +```yaml +taskid-max: 25 +``` + +### `taskid-shift` +**Тип:** `integer` +**По умолчанию:** `0` +**Описание:** Смещение для расчёта ожидаемого TASKID. Формула: `expected = (taskid_from_sheet + shift - 1) % max + 1` +**Пример:** +```yaml +taskid-max: 20 +taskid-shift: 4 +# Студент с TASKID=1 → ожидается (1+4-1)%20+1 = 5 +``` + +### `ignore-task-id` +**Тип:** `boolean` +**По умолчанию:** `false` +**Описание:** Отключает проверку TASKID из логов +**Пример:** +```yaml +ignore-task-id: True +``` + +--- + +## Штрафы (Penalty) + +### `penalty-max` +**Тип:** `integer` +**По умолчанию:** `0` +**Описание:** Максимальное количество штрафных баллов +**Пример:** +```yaml +penalty-max: 9 +``` + +### `penalty-strategy` +**Тип:** `string` +**Возможные значения:** `"weekly"`, `"daily"` +**По умолчанию:** `"weekly"` +**Описание:** Стратегия начисления штрафов: +- `weekly`: 1 балл за каждую начатую неделю просрочки +- `daily`: 1 балл за каждый день просрочки + +**Пример:** +```yaml +penalty-strategy: weekly +``` + +--- + +## Извлечение баллов (Score) + +### `score.patterns` +**Тип:** `list[string]` +**Описание:** Список regex паттернов для извлечения баллов из логов CI. Паттерны пробуются по порядку, используется первый совпавший. Первая capturing group `()` должна содержать число баллов. + +**Важно:** +- В YAML используйте одинарные кавычки `'...'` для паттернов +- Backslash в regex НЕ нужно экранировать дважды (в одинарных кавычках YAML backslash литеральный) +- Паттерны выполняются с флагами `re.MULTILINE | re.IGNORECASE` +- Принимаются оба разделителя `.` и `,` в числах +- Если паттерны заданы, но баллы не найдены → ошибка + +**Пример:** +```yaml +score: + patterns: + - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' # "ПРЕДВАРИТЕЛЬНАЯ ОЦЕНКА В ЖУРНАЛ: 10.0" + - 'ИТОГО:\s*(\d+(?:[.,]\d+)?)\s*баллов' # "ИТОГО: 100 баллов" + - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' # "##[notice]Points 10/10" + - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' # "Score is 10.5" + - 'Total:\s+(\d+(?:[.,]\d+)?)' # "Total: 10" +``` + +**Формат вывода в Google Sheets:** +- `v@10.5` - балл с десятичным разделителем (автоопределение из locale таблицы) +- `v@10.5-3` - балл со штрафом +- `v-3` - только штраф (если score не настроен) +- `v` - просто зачёт (без score и штрафа) + +--- + +## Проверка файлов + +### `files` +**Тип:** `list[string]` +**Описание:** Список обязательных файлов в репозитории студента +**Пример:** +```yaml +files: + - lab1.sh + - README.md +``` + +### `forbidden-modifications` +**Тип:** `list[string]` +**Описание:** Список файлов/директорий, которые студент не может изменять +**По умолчанию:** Если не указано, автоматически запрещается изменение `test_main.py` и `tests/` (если они в `files`) +**Пример:** +```yaml +forbidden-modifications: + - test_main.py + - tests/ + - .github/workflows/ +``` + +--- + +## MOSS (Проверка плагиата) + +### `moss.language` (обязательно) +**Тип:** `string` +**Описание:** Язык программирования для проверки MOSS +**Возможные значения:** `c`, `cc`, `python`, `java`, и др. +**Пример:** +```yaml +moss: + language: cc +``` + +### `moss.max-matches` +**Тип:** `integer` +**По умолчанию:** `250` +**Описание:** Максимальное количество совпадений для отображения +**Пример:** +```yaml +moss: + max-matches: 1000 +``` + +### `moss.local-path` +**Тип:** `string` +**Описание:** Путь к директории с файлами в репозитории (если файлы не в корне) +**Пример:** +```yaml +moss: + local-path: lab3 +``` + +### `moss.additional` +**Тип:** `list[string]` +**Описание:** Список дополнительных GitHub организаций для проверки (старые годы обучения) +**Пример:** +```yaml +moss: + additional: + - suai-os-2023 + - suai-os-2024 +``` + +### `moss.basefiles` +**Тип:** `list[dict]` +**Описание:** Базовые файлы (шаблоны), которые исключаются из проверки на плагиат +**Пример:** +```yaml +moss: + basefiles: + - repo: teacher/template-repo + filename: lab3.cpp + - repo: teacher/template-repo + filename: examples/helper.cpp +``` + +--- + +## Требования к отчёту + +### `report` +**Тип:** `list[string]` +**Описание:** Обязательные разделы в отчёте (для проверки на frontend) +**Пример:** +```yaml +report: + - Цель работы + - Индивидуальное задание + - Результат выполнения работы + - Исходный код программы с комментариями + - Выводы +``` + +--- + +## Валидация (дополнительные проверки) + +### `validation.commits` +**Тип:** `list[dict]` +**Описание:** Правила проверки коммитов + +**Параметры правила:** +- `filter`: `"message"` (сообщение коммита) или `"console"` (изменённые файлы) +- `contains`: строка для поиска (опционально) +- `min-count`: минимальное количество коммитов + +**Пример:** +```yaml +validation: + commits: + - filter: message + contains: lab5 + min-count: 3 # Минимум 3 коммита с упоминанием "lab5" + - filter: console + min-count: 1 # Минимум 1 коммит с файлом "console" +``` + +### `validation.issues` +**Тип:** `list[dict]` +**Описание:** Правила проверки issues + +**Параметры правила:** +- `filter`: `"message"` (заголовок/тело issue) +- `contains`: строка для поиска +- `min-count`: минимальное количество issues + +**Пример:** +```yaml +validation: + issues: + - filter: message + contains: lab6 + min-count: 3 # Минимум 3 issues с упоминанием "lab6" +``` + +--- + +## Секция `misc` + +Дополнительные настройки системы. + +### `requests-timeout` +**Тип:** `integer` +**По умолчанию:** `5` +**Описание:** Таймаут для HTTP запросов к GitHub API (в секундах) +**Пример:** +```yaml +misc: + requests-timeout: 10 +``` + +--- + +## Полный пример конфигурации + +```yaml +--- +course: + name: Операционные системы + alt-names: + - OS + - ОС + semester: Осень 2025 + university: ГУАП + email: teacher@university.edu + timezone: UTC+3 + github: + organization: suai-os-2025 + teachers: + - "Mark Polyak" + - markpolyak + google: + spreadsheet: 1BoVLNZpP6zSc7DPSVkbxaQ_oJZcZ_f-zyMCfF1gw-PM + info-sheet: График + task-id-column: 0 + student-name-column: 1 + lab-column-offset: 1 + staff: + - name: Поляк Марк Дмитриевич + title: ст. преп. + status: лектор + + labs: + "1": + github-prefix: os-task1 + short-name: ЛР1 + taskid-max: 25 + taskid-shift: 0 + penalty-max: 6 + penalty-strategy: weekly + ci: + workflows: + - run-autograding-tests + - cpplint + files: + - lab1.sh + forbidden-modifications: + - test_main.py + - tests/ + score: + patterns: + - 'Score:\s*(\d+(?:[.,]\d+)?)' + - 'Total:\s*(\d+(?:[.,]\d+)?)\s*points' + moss: + language: c + max-matches: 1000 + local-path: lab1 + additional: + - suai-os-2024 + basefiles: + - repo: teacher/templates + filename: lab1.sh + report: + - Цель работы + - Индивидуальное задание + - Результат выполнения работы + - Исходный код программы + - Выводы + validation: + commits: + - filter: message + contains: lab1 + min-count: 2 + +misc: + requests-timeout: 5 +``` + +--- + +## Примечания + +1. **Кодировка:** Все YAML файлы должны быть в UTF-8 +2. **Regex паттерны:** В одинарных кавычках YAML backslash литеральный, экранирование не требуется +3. **Индексация колонок:** В конфиге 0-based (A=0, B=1), система автоматически конвертирует для Google Sheets API +4. **Локаль Google Sheets:** Десятичный разделитель для score автоматически определяется из настроек таблицы +5. **GitHub API encoding:** Система автоматически обрабатывает UTF-8 логи с Cyrillic символами diff --git a/docs/PROJECT_DESCRIPTION.md b/docs/PROJECT_DESCRIPTION.md index 74de49b..3a6f66d 100644 --- a/docs/PROJECT_DESCRIPTION.md +++ b/docs/PROJECT_DESCRIPTION.md @@ -102,12 +102,18 @@ - Получает последний коммит - Валидирует, что студент не модифицировал файл с тестами (`test_main.py`, `tests/`) - Получает статусы всех CI проверок (workflows) + - **Извлекает баллы из логов** (если настроено в конфиге) 3. **Защита от перезаписи**: Результат записывается только если текущее значение ячейки: - Пустое - Содержит "x" (предыдущая неудачная проверка) - Начинается с "?" (пометка преподавателя) -4. Результат (`v` = успех, `x` = ошибка) записывается в Google Sheets -5. Frontend отображает детальный результат проверки (персистентно, не всплывающее окно) +4. Результат записывается в Google Sheets: + - `v` = успех без баллов + - `x` = ошибка + - `v@10.5` = успех с баллами (разделитель зависит от locale таблицы) + - `v-3` = успех со штрафом + - `v@10.5-3` = успех с баллами и штрафом +5. Frontend отображает детальный результат проверки с информацией о баллах (персистентно, не всплывающее окно) ## Структура проекта @@ -239,6 +245,108 @@ course: **Примечание:** Метаданные отображения (статус, приоритет, логотип) хранятся в `courses/index.yaml`, а не в файлах курсов. +### Извлечение баллов из логов CI + +Система поддерживает автоматическое извлечение баллов из логов GitHub Actions для записи в Google Sheets. Эта функция полностью опциональна и активируется добавлением секции `score` в конфигурацию лабораторной работы. + +#### Конфигурация + +```yaml +labs: + "1": + github-prefix: "task1" + short-name: "ЛР1" + score: # Опциональная секция + patterns: # Список regex паттернов (пробуются по порядку) + - 'ИТОГО:\s*(\d+(?:[.,]\d+)?)\s*баллов' # "ИТОГО: 100 баллов" -> 100 + - 'ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' # "ОЦЕНКА В ЖУРНАЛ: 10.0" -> 10.0 + - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' # "##[notice]Points 10/10" -> 10 + - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' # "Score is 10.5" -> 10.5 +``` + +#### Правила работы + +1. **Множественные паттерны**: Паттерны пробуются по порядку. Используется первый найденный результат. + +2. **Первая capturing group**: Паттерн должен содержать захватывающую группу `(...)` — это значение будет извлечено как балл. + +3. **Разделители**: Система принимает оба разделителя (`10.5` и `10,5`) во входных данных. + +4. **Валидация**: Если балл найден в нескольких джобах, все значения должны совпадать. Иначе — ошибка. + +5. **Обязательность**: + - Если `score.patterns` **указан** → баллы **обязательны**. Если не найдены — ошибка. + - Если `score.patterns` **не указан** → баллы не ищутся (backward compatible). + +6. **Автоматический разделитель**: При записи в Google Sheets используется разделитель из locale таблицы: + - `ru_RU`, `de_DE`, `fr_FR` → запятая (`10,5`) + - `en_US`, `en_GB` → точка (`10.5`) + +#### Формат записи в таблицу + +| Ситуация | Формат записи | Пример | +|----------|--------------|---------| +| Только успех | `v` | `v` | +| Успех с баллами | `v@{score}` | `v@10.5` или `v@10,5` | +| Успех со штрафом | `v-{penalty}` | `v-3` | +| Успех с баллами и штрафом | `v@{score}-{penalty}` | `v@10.5-3` | +| Неудача | `x` | `x` | + +#### Примеры паттернов для разных форматов + +**Русский формат:** +```yaml +score: + patterns: + - 'ИТОГО:\s*(\d+(?:[.,]\d+)?)\s*баллов' + - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' +``` + +**GitHub Actions notice:** +```yaml +score: + patterns: + - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' +``` + +**Autograding format:** +```yaml +score: + patterns: + - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' + - 'Total:\s+(\d+(?:[.,]\d+)?)' +``` + +#### Советы по созданию паттернов + +1. **Используйте одинарные кавычки** в YAML для regex (`'...'`), чтобы избежать двойного экранирования. + +2. **Гибкие паттерны**: Используйте `.*?` для пропуска переменного количества символов: + ```yaml + - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' + ``` + +3. **Учитывайте пробелы**: После таймстемпа в логах могут быть дополнительные пробелы. Используйте `\s*` или `\s+`. + +4. **Тестируйте паттерны**: Используйте [regex101.com](https://regex101.com/) для проверки паттернов на реальных логах. + +#### Отображение на frontend + +При успешном извлечении баллов, информация отображается в результатах проверки: + +``` +✅ Результат CI: Все проверки пройдены (Баллы: 10.5) +Результат: v@10.5 +Баллы: 10.5 +``` + +#### Техническая реализация + +- **Модуль**: `grading/score.py` +- **Функция извлечения**: `extract_score_from_logs(logs, patterns)` +- **Определение разделителя**: `get_decimal_separator(spreadsheet)` +- **Форматирование**: `format_grade_with_score(base, score, penalty, separator)` + ## Интеграции ### GitHub API diff --git a/frontend/courses-front/src/api/index.js b/frontend/courses-front/src/api/index.js index 9889885..c7a265c 100644 --- a/frontend/courses-front/src/api/index.js +++ b/frontend/courses-front/src/api/index.js @@ -25,8 +25,8 @@ function formatValidationError(err) { return fieldLabel ? `${fieldLabel}: ${err.msg}` : err.msg; } -export const fetchCourses = async () => { - const response = await fetch(`${API_BASE_URL}/courses`); +export const fetchCourses = async (status = "active") => { + const response = await fetch(`${API_BASE_URL}/courses?status=${status}`); if (response.status === 429) { let errorMessage = "Превышен лимит запросов. Пожалуйста, подождите немного и попробуйте снова."; try { diff --git a/frontend/courses-front/src/components/course-list/index.jsx b/frontend/courses-front/src/components/course-list/index.jsx index 9fa7aba..f514f21 100644 --- a/frontend/courses-front/src/components/course-list/index.jsx +++ b/frontend/courses-front/src/components/course-list/index.jsx @@ -35,6 +35,7 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { const { t, i18n } = useTranslation(); const [courses, setCourses] = useState([]); + const [courseStatus, setCourseStatus] = useState("active"); // "active", "archived", "all" const [expandedCourse, setExpandedCourse] = useState(null); const [editingCourseId, setEditingCourseId] = useState(null); const [editContent, setEditContent] = useState(""); @@ -59,10 +60,10 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { }; useEffect(() => { - fetchCourses() + fetchCourses(courseStatus) .then(setCourses) .catch(() => showSnackbar(t("errorLoadingCourses"), "error")); - }, [t]); + }, [t, courseStatus]); const handleDeleteConfirmation = (courseId) => { setSelectedCourseId(courseId); @@ -78,7 +79,7 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { if (response.ok) { showSnackbar(t("courseDeleted"), "success"); - fetchCourses().then(setCourses); + fetchCourses(courseStatus).then(setCourses); } else { const data = await response.json(); showSnackbar(data.detail || t("errorDeletingCourse"), "error"); @@ -146,7 +147,7 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { showSnackbar(t("changesSaved"), "success"); setEditingCourseId(null); setIsFullscreen(false); - fetchCourses().then(setCourses); + fetchCourses(courseStatus).then(setCourses); } else { const data = await response.json(); showSnackbar(data.detail || t("errorSavingCourse"), "error"); @@ -183,7 +184,7 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { if (response.ok) { showSnackbar(t("courseUploaded"), "success"); - fetchCourses().then(setCourses); + fetchCourses(courseStatus).then(setCourses); } else { const data = await response.json(); showSnackbar(data.detail || t("errorUploadingCourse"), "error"); @@ -211,7 +212,7 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { position: "fixed", top: 16, right: 16, - zIndex: 3000, + zIndex: 3200, minWidth: 120, backgroundColor: "#555", color: "#fff", @@ -225,6 +226,17 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { }, ".MuiSvgIcon-root": { color: "#fff" }, }} + MenuProps={{ + disablePortal: true, + sx: { + zIndex: 3200, + }, + PaperProps: { + sx: { + zIndex: 3200, + }, + }, + }} > {languages.map(({ code, label }) => ( @@ -233,6 +245,66 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { ))} + {/* Переключатель статусов курсов - компактный, в углу */} + + + + {isAdmin && ( + + )} + + {isAdmin && ( <> diff --git a/frontend/courses-front/src/components/registration-form/index.jsx b/frontend/courses-front/src/components/registration-form/index.jsx index da38849..c5e5037 100644 --- a/frontend/courses-front/src/components/registration-form/index.jsx +++ b/frontend/courses-front/src/components/registration-form/index.jsx @@ -52,6 +52,7 @@ const handleSubmit = async () => { result: gradeResponse.result, passed: gradeResponse.passed, checks: gradeResponse.checks, + score: gradeResponse.score, }); } else if (gradeResponse.status === "rejected") { setCheckResult({ @@ -60,6 +61,7 @@ const handleSubmit = async () => { currentGrade: gradeResponse.current_grade, passed: gradeResponse.passed, checks: gradeResponse.checks, + score: gradeResponse.score, }); } else if (gradeResponse.status === "pending") { setCheckResult({ @@ -176,6 +178,16 @@ const handleSubmit = async () => { )} + {checkResult.score && ( +
+ Баллы: {checkResult.score} +
+ )} + {checkResult.currentGrade && (
str | None: if resp.status_code != 200: return None + # GitHub API returns logs in UTF-8, but without proper charset in headers. + # Force UTF-8 decoding to correctly handle Cyrillic and other non-ASCII characters. + resp.encoding = 'utf-8' return resp.text diff --git a/grading/grader.py b/grading/grader.py index 20fab32..85a4a31 100644 --- a/grading/grader.py +++ b/grading/grader.py @@ -26,6 +26,7 @@ from .sheets_client import can_overwrite_cell from .penalty import calculate_penalty, format_grade_with_penalty, PenaltyStrategy from .taskid import extract_taskid_from_logs, calculate_expected_taskid, validate_taskid +from .score import extract_score_from_logs logger = logging.getLogger(__name__) @@ -42,12 +43,13 @@ class GradeStatus(Enum): class GradeResult: """Result of a grading operation.""" status: GradeStatus - result: str | None # Grade value: "v", "x", "v-3", etc. + result: str | None # Grade value: "v", "x", "v-3", "v@10.5", etc. message: str # User-facing message passed: str | None # "3/4 тестов пройдено" checks: list[str] = field(default_factory=list) # CI check summaries current_grade: str | None = None # Existing grade if rejected error_code: str | None = None # For programmatic error handling + score: str | None = None # Score extracted from logs (e.g., "10.5") @dataclass @@ -55,8 +57,9 @@ class CIEvaluation: """Internal result of CI evaluation with full details.""" grade_result: GradeResult # The GradeResult to return ci_passed: bool # Whether all CI checks passed - successful_runs: list[CheckRun] = field(default_factory=list) # For TASKID extraction + successful_runs: list[CheckRun] = field(default_factory=list) # For TASKID/score extraction latest_success_time: datetime | None = None # For penalty calculation + score: str | None = None # Extracted score from logs class LabGrader: @@ -293,6 +296,95 @@ def check_taskid( logger.info(f"TASKID validated: {taskid_found} matches expected {expected_taskid}") return None + def check_score( + self, + org: str, + repo_name: str, + successful_runs: list[CheckRun], + score_patterns: list[str], + ) -> tuple[str | None, GradeResult | None]: + """ + Extract score from job logs using configured patterns. + + Reads logs from successful CI jobs and extracts score using pattern list. + If multiple occurrences found, they must all match (same value). + + Args: + org: GitHub organization + repo_name: Repository name + successful_runs: List of successful CheckRun objects + score_patterns: List of regex patterns to try + + Returns: + Tuple of (score_string, error_result) + - If successful: (score, None) + - If error: (None, GradeResult with error) + + Note: + Score patterns are tried in order. First matching pattern is used. + Score must be consistent across all successful jobs. + """ + logger.info(f"Score check for {repo_name}: checking {len(successful_runs)} successful job(s)") + logger.debug(f"Score patterns configured: {len(score_patterns)} pattern(s)") + + score_found = None + score_error = None + + # Try to get score from any successful job's logs + for idx, run in enumerate(successful_runs, 1): + logger.info(f"Checking job {idx}/{len(successful_runs)}: {run.name} (conclusion: {run.conclusion})") + + # Extract job ID from html_url (format: .../job/12345) + if "/job/" in run.html_url: + try: + job_id = int(run.html_url.split("/job/")[-1].split("?")[0]) + logger.info(f" Job ID: {job_id}, URL: {run.html_url}") + except (ValueError, IndexError): + logger.warning(f" Could not extract job_id from URL: {run.html_url}") + continue + + logs = self.github.get_job_logs(org, repo_name, job_id) + if logs: + logger.info(f" Logs fetched, size: {len(logs)} chars") + result = extract_score_from_logs(logs, score_patterns) + if result.found is not None: + logger.info(f" ✓ Score found in logs: {result.found}") + score_found = result.found + break + elif result.error: + if "несколько" in result.error: + # Multiple different scores - this is an error + logger.error(f" ✗ {result.error}") + score_error = result.error + break + else: + logger.info(f" ✗ Score not found in this job's logs: {result.error}") + else: + logger.warning(f" Could not fetch logs for job {job_id}") + else: + logger.warning(f" Job URL doesn't contain /job/: {run.html_url}") + + if score_error: + return None, GradeResult( + status=GradeStatus.ERROR, + result=None, + message=f"⚠️ {score_error}", + passed=None, + error_code="MULTIPLE_SCORES", + ) + + if score_found is None: + return None, GradeResult( + status=GradeStatus.ERROR, + result=None, + message="⚠️ Баллы не найдены в логах. Убедитесь, что программа выводит набранный балл.", + passed=None, + error_code="SCORE_NOT_FOUND", + ) + + logger.info(f"Score extracted successfully: {score_found}") + return score_found, None + def _evaluate_ci_internal( self, org: str, @@ -407,6 +499,26 @@ def _evaluate_ci_internal( else: message = "Результат CI: ❌ Обнаружены ошибки" + # Extract score if patterns are configured (only for passed CI) + score_value = None + if ci_result.passed: + score_patterns = lab_config.get("score", {}).get("patterns", []) + if score_patterns: + logger.info(f"Score patterns configured, attempting to extract score") + score_value, score_error = self.check_score( + org, repo_name, + successful_runs, + score_patterns, + ) + if score_error: + logger.warning(f"Score extraction failed: {score_error.message}") + # If score is required but not found, return error + return CIEvaluation( + grade_result=score_error, + ci_passed=False, + ) + logger.info(f"Score extracted: {score_value}") + return CIEvaluation( grade_result=GradeResult( status=GradeStatus.UPDATED, @@ -414,10 +526,12 @@ def _evaluate_ci_internal( message=message, passed=result_string, checks=ci_result.summary, + score=score_value, ), ci_passed=ci_result.passed, successful_runs=successful_runs, latest_success_time=ci_result.latest_success_time, + score=score_value, ) def evaluate_ci( @@ -448,6 +562,7 @@ def grade( current_cell_value: str | None = None, deadline: datetime | None = None, expected_taskid: int | None = None, + decimal_separator: str = '.', ) -> GradeResult: """ Perform full grading workflow. @@ -456,9 +571,11 @@ def grade( 1. Check repository (files, workflows, commits) 2. Check for forbidden modifications 3. Evaluate CI results - 4. Validate TASKID (if required) - 5. Calculate penalty (if deadline provided) - 6. Check if grade can be updated (cell protection) + 4. Extract score from logs (if configured) + 5. Validate TASKID (if required) + 6. Calculate penalty (if deadline provided) + 7. Format grade with score and penalty + 8. Check if grade can be updated (cell protection) Args: org: GitHub organization @@ -467,6 +584,7 @@ def grade( current_cell_value: Current value in grade cell (for protection check) deadline: Deadline datetime for penalty calculation (None = no penalty) expected_taskid: Expected TASKID for validation (None = skip validation) + decimal_separator: Decimal separator for score formatting ('.' or ',') Returns: GradeResult with final status and grade @@ -508,7 +626,6 @@ def grade( return taskid_error # Step 5: Calculate penalty (if deadline provided) - final_result = "v" # CI passed penalty = 0 penalty_max = lab_config.get("penalty-max", 0) @@ -528,10 +645,24 @@ def grade( ) if penalty > 0: - final_result = format_grade_with_penalty("v", penalty) - logger.info(f"Applied penalty {penalty} for late submission: {final_result}") + logger.info(f"Calculated penalty: {penalty}") + + # Step 6: Format grade with score and penalty + from .score import format_grade_with_score, format_score - # Step 6: Check cell protection (if current value provided) + score_value = ci_evaluation.score + final_result = "v" + + if score_value is not None: + # Format score with correct separator and add penalty if present + final_result = format_grade_with_score("v", score_value, penalty, decimal_separator) + logger.info(f"Formatted grade with score: {final_result}") + elif penalty > 0: + # No score, but penalty exists + final_result = format_grade_with_penalty("v", penalty) + logger.info(f"Formatted grade with penalty: {final_result}") + + # Step 7: Check cell protection (if current value provided) if current_cell_value is not None: if not can_overwrite_cell(current_cell_value): return GradeResult( @@ -541,11 +672,19 @@ def grade( passed=ci_evaluation.grade_result.passed, checks=ci_evaluation.grade_result.checks, current_grade=current_cell_value, + score=score_value, ) # Build final message + message_parts = [] + if score_value is not None: + formatted_score = format_score(score_value, decimal_separator) + message_parts.append(f"Баллы: {formatted_score}") if penalty > 0: - message = f"Результат CI: ✅ Все проверки пройдены (штраф: -{penalty})" + message_parts.append(f"штраф: -{penalty}") + + if message_parts: + message = f"Результат CI: ✅ Все проверки пройдены ({', '.join(message_parts)})" else: message = ci_evaluation.grade_result.message @@ -555,4 +694,5 @@ def grade( message=message, passed=ci_evaluation.grade_result.passed, checks=ci_evaluation.grade_result.checks, + score=score_value, ) diff --git a/grading/score.py b/grading/score.py new file mode 100644 index 0000000..8569590 --- /dev/null +++ b/grading/score.py @@ -0,0 +1,222 @@ +""" +Score extraction from GitHub Actions logs. + +This module contains functions for extracting student scores (points) +from GitHub Actions job logs using configurable regex patterns. +""" +import re +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation + + +@dataclass +class ScoreResult: + """Result of score extraction from logs.""" + found: str | None # Score as string (e.g., "10.5" or "10,5") + error: str | None = None + + +def normalize_score(score_str: str) -> str: + """ + Normalize score string to use consistent decimal separator. + + Accepts both comma and dot as decimal separator in input. + Returns normalized string with the original separator preserved. + + Args: + score_str: Score string (e.g., "10.5" or "10,5") + + Returns: + Normalized score string + + Examples: + >>> normalize_score("10.5") + '10.5' + >>> normalize_score("10,5") + '10,5' + >>> normalize_score("10") + '10' + """ + return score_str.strip() + + +def scores_equal(score1: str, score2: str) -> bool: + """ + Compare two score strings for equality. + + Treats "10.5" and "10,5" as equal values. + + Args: + score1: First score string + score2: Second score string + + Returns: + True if scores represent the same numeric value + + Examples: + >>> scores_equal("10.5", "10,5") + True + >>> scores_equal("10", "10.0") + True + >>> scores_equal("10.5", "10.6") + False + """ + try: + # Replace comma with dot for numeric comparison + val1 = Decimal(score1.replace(',', '.')) + val2 = Decimal(score2.replace(',', '.')) + return val1 == val2 + except (InvalidOperation, ValueError): + # Fallback to string comparison if not valid numbers + return score1 == score2 + + +def extract_score_from_logs(logs: str, patterns: list[str]) -> ScoreResult: + """ + Extract score from GitHub Actions job logs using multiple patterns. + + Tries each pattern in order until a match is found. + If multiple occurrences are found, they must all be the same value. + + GitHub Actions logs have timestamps at the beginning of each line like: + "2024-01-15T10:30:00.000Z ##[notice]Points 10/10" + + Args: + logs: Full text of the job logs + patterns: List of regex patterns to try (first capturing group = score) + + Returns: + ScoreResult with found score or error message + + Examples: + >>> logs = "2024-01-15T10:30:00.000Z Points 10.5\\n" + >>> patterns = [r'Points\\s+([\\d.,]+)'] + >>> result = extract_score_from_logs(logs, patterns) + >>> result.found + '10.5' + """ + import logging + logger = logging.getLogger(__name__) + + if not logs: + return ScoreResult(found=None, error="Логи пусты") + + if not patterns: + return ScoreResult(found=None, error="Паттерны для поиска баллов не указаны") + + logger.debug(f"Searching for score in logs (size: {len(logs)} chars, {len(logs.splitlines())} lines)") + + all_matches = [] + matched_pattern = None + + # Try each pattern until we find matches + for idx, pattern in enumerate(patterns, 1): + logger.debug(f"Trying pattern {idx}/{len(patterns)}: {pattern}") + try: + # Search across all lines + matches = re.findall(pattern, logs, re.MULTILINE | re.IGNORECASE) + + if matches: + logger.info(f"✓ Pattern {idx} matched {len(matches)} time(s)") + all_matches = matches + matched_pattern = pattern + break + except re.error as e: + logger.warning(f"Invalid regex pattern '{pattern}': {e}") + continue + + if not all_matches: + logger.debug(f"No matches found for any of {len(patterns)} pattern(s)") + return ScoreResult( + found=None, + error="Баллы не найдены в логах. Убедитесь, что программа выводит набранный балл." + ) + + # Normalize all matches + normalized_matches = [normalize_score(m) for m in all_matches] + + # Check all matches are the same value (allow different separators) + unique_scores = [] + for score in normalized_matches: + # Check if this score is already in unique list (considering "10.5" == "10,5") + is_duplicate = any(scores_equal(score, existing) for existing in unique_scores) + if not is_duplicate: + unique_scores.append(score) + + if len(unique_scores) > 1: + logger.warning(f"Multiple different scores found: {unique_scores}") + return ScoreResult( + found=None, + error=f"Найдено несколько разных значений баллов в логах: {', '.join(unique_scores)}. Обратитесь к преподавателю." + ) + + found_score = normalized_matches[0] + logger.info(f"Score extracted from logs: {found_score} (pattern: {matched_pattern}, {len(all_matches)} occurrence(s))") + + return ScoreResult(found=found_score) + + +def format_score(score: str, separator: str = '.') -> str: + """ + Format score with the specified decimal separator. + + Args: + score: Score string (e.g., "10.5" or "10,5") + separator: Desired decimal separator ('.' or ',') + + Returns: + Formatted score string + + Examples: + >>> format_score("10.5", ",") + '10,5' + >>> format_score("10,5", ".") + '10.5' + >>> format_score("10", ",") + '10' + """ + if separator not in ('.', ','): + raise ValueError(f"Invalid separator: {separator}") + + # Normalize to dot first + normalized = score.replace(',', '.') + + # Convert to desired separator + if separator == ',': + return normalized.replace('.', ',') + return normalized + + +def format_grade_with_score( + base_grade: str, + score: str, + penalty: int = 0, + separator: str = '.' +) -> str: + """ + Format grade string with score and optional penalty. + + Format: base_grade@score or base_grade@score-penalty + + Args: + base_grade: Base grade symbol (e.g., "v" for success) + score: Score value (e.g., "10.5") + penalty: Number of penalty points (default: 0) + separator: Decimal separator for score ('.' or ',') + + Returns: + Grade string, e.g., "v@10.5" or "v@10,5-3" + + Examples: + >>> format_grade_with_score("v", "10.5", 0, ".") + 'v@10.5' + >>> format_grade_with_score("v", "10.5", 3, ",") + 'v@10,5-3' + >>> format_grade_with_score("v", "10", 0, ".") + 'v@10' + """ + formatted_score = format_score(score, separator) + + if penalty > 0: + return f"{base_grade}@{formatted_score}-{penalty}" + return f"{base_grade}@{formatted_score}" diff --git a/grading/sheets_client.py b/grading/sheets_client.py index 4fcddcd..f8eef9a 100644 --- a/grading/sheets_client.py +++ b/grading/sheets_client.py @@ -280,6 +280,12 @@ def get_deadline_from_sheet( logger.warning(f"Could not parse deadline '{cell_value}' at row {deadline_row}, col {lab_col}") return None + # If date was parsed without time (midnight), set to end of day (23:59:59) + # This ensures deadlines like "19.11.2025" mean "until the end of that day" + if parsed_dt.hour == 0 and parsed_dt.minute == 0 and parsed_dt.second == 0: + parsed_dt = parsed_dt.replace(hour=23, minute=59, second=59) + logger.debug(f"Deadline parsed without time, set to end of day: {parsed_dt}") + # If datetime already has timezone info, return as-is if parsed_dt.tzinfo is not None: logger.debug(f"Deadline already has timezone: {parsed_dt}") @@ -335,3 +341,42 @@ def get_student_order( except Exception as e: logger.error(f"Error reading task ID: {e}") return None + + +def get_decimal_separator(spreadsheet) -> str: + """ + Get decimal separator used by the spreadsheet based on its locale. + + Args: + spreadsheet: gspread Spreadsheet object + + Returns: + Decimal separator: '.' or ',' + + Note: + - Locales like en_US, en_GB use '.' + - Locales like ru_RU, de_DE, fr_FR use ',' + - Defaults to '.' if locale cannot be determined + """ + try: + # Get spreadsheet metadata to access locale + metadata = spreadsheet.fetch_sheet_metadata() + locale = metadata.get('properties', {}).get('locale', 'en_US') + + logger.debug(f"Spreadsheet locale: {locale}") + + # Locales that use comma as decimal separator + comma_locales = { + 'ru_RU', 'ru', 'de_DE', 'de', 'fr_FR', 'fr', 'es_ES', 'es', + 'it_IT', 'it', 'pt_BR', 'pt', 'nl_NL', 'nl', 'pl_PL', 'pl', + 'cs_CZ', 'cs', 'sv_SE', 'sv', 'da_DK', 'da', 'fi_FI', 'fi', + 'no_NO', 'no', 'tr_TR', 'tr', 'el_GR', 'el', 'hu_HU', 'hu', + } + + separator = ',' if locale in comma_locales else '.' + logger.info(f"Using decimal separator '{separator}' for locale {locale}") + return separator + + except Exception as e: + logger.warning(f"Could not determine spreadsheet locale: {e}. Using default separator '.'") + return '.' diff --git a/main.py b/main.py index b42e326..e5be819 100644 --- a/main.py +++ b/main.py @@ -29,6 +29,9 @@ get_deadline_from_sheet, get_student_order, calculate_expected_taskid, + get_decimal_separator, + format_grade_with_score, + format_score, ) # Configure logging to both file and console @@ -668,12 +671,17 @@ def grade_lab(request: Request, course_id: str, group_id: str, lab_id: str, grad sheets_client = gspread.authorize(creds) try: - sheet = sheets_client.open_by_key(spreadsheet_id).worksheet(group_id) + spreadsheet = sheets_client.open_by_key(spreadsheet_id) + sheet = spreadsheet.worksheet(group_id) logger.info(f"Successfully opened worksheet '{group_id}'") except Exception as e: logger.error(f"Failed to open worksheet '{group_id}': {str(e)}") raise HTTPException(status_code=404, detail="Группа не найдена в Google Таблице") + # Get decimal separator from spreadsheet locale + decimal_separator = get_decimal_separator(spreadsheet) + logger.info(f"Using decimal separator: '{decimal_separator}'") + # Find GitHub column and student row header_row = sheet.row_values(1) try: @@ -711,6 +719,7 @@ def grade_lab(request: Request, course_id: str, group_id: str, lab_id: str, grad # Determine final grade final_result = ci_evaluation.grade_result.result # "v" or "x" final_message = ci_evaluation.grade_result.message + score_value = ci_evaluation.score # Extracted score from logs (if any) # Additional checks only if CI passed if ci_evaluation.ci_passed: @@ -741,6 +750,7 @@ def grade_lab(request: Request, course_id: str, group_id: str, lab_id: str, grad # Get timezone from course config to apply to deadline from sheet timezone_str = course_info.get("timezone") deadline = get_deadline_from_sheet(sheet, lab_col, deadline_row=1, timezone_str=timezone_str) + penalty = 0 if deadline and ci_evaluation.latest_success_time: from grading.penalty import calculate_penalty, format_grade_with_penalty, PenaltyStrategy penalty_max = lab_config_dict.get("penalty-max", 0) @@ -758,14 +768,31 @@ def grade_lab(request: Request, course_id: str, group_id: str, lab_id: str, grad ) if penalty > 0: - final_result = format_grade_with_penalty("v", penalty) - final_message = f"Результат CI: ✅ Все проверки пройдены (штраф: -{penalty})" - logger.info(f"Applied penalty {penalty} for late submission: {final_result}") + logger.info(f"Calculated penalty: {penalty}") + + # Format final result with score and penalty + if score_value is not None: + # Format grade with score (and penalty if present) + final_result = format_grade_with_score("v", score_value, penalty, decimal_separator) + logger.info(f"Formatted grade with score: {final_result}") + + # Build message + formatted_score = format_score(score_value, decimal_separator) + if penalty > 0: + final_message = f"Результат CI: ✅ Все проверки пройдены (Баллы: {formatted_score}, штраф: -{penalty})" + else: + final_message = f"Результат CI: ✅ Все проверки пройдены (Баллы: {formatted_score})" + elif penalty > 0: + # No score, but penalty exists + from grading.penalty import format_grade_with_penalty + final_result = format_grade_with_penalty("v", penalty) + final_message = f"Результат CI: ✅ Все проверки пройдены (штраф: -{penalty})" + logger.info(f"Applied penalty {penalty} for late submission: {final_result}") # Check cell protection if not can_overwrite_cell(current_value): logger.warning(f"Update rejected: cell already contains '{current_value}'") - return { + response = { "status": "rejected", "result": current_value, "message": "⚠️ Работа уже была проверена ранее. Обратитесь к преподавателю для пересдачи.", @@ -773,19 +800,25 @@ def grade_lab(request: Request, course_id: str, group_id: str, lab_id: str, grad "checks": ci_evaluation.grade_result.checks, "current_grade": current_value } + if score_value is not None: + response["score"] = format_score(score_value, decimal_separator) + return response # Update Google Sheets with new grade logger.info(f"Updating cell at row {row_idx}, column {lab_col} with result '{final_result}'") sheet.update_cell(row_idx, lab_col, final_result) logger.info(f"Successfully updated grade for '{username}' in lab {lab_id}") - return { + response = { "status": "updated", "result": final_result, "message": final_message, "passed": ci_evaluation.grade_result.passed, "checks": ci_evaluation.grade_result.checks } + if score_value is not None: + response["score"] = format_score(score_value, decimal_separator) + return response except HTTPException: raise except Exception as e: diff --git a/tests/test_score.py b/tests/test_score.py new file mode 100644 index 0000000..4c5fcf9 --- /dev/null +++ b/tests/test_score.py @@ -0,0 +1,183 @@ +""" +Tests for score extraction functionality. +""" +import pytest +from grading.score import ( + extract_score_from_logs, + format_score, + format_grade_with_score, + scores_equal, +) + + +class TestExtractScoreFromLogs: + """Tests for extract_score_from_logs function.""" + + def test_extract_score_with_notice_pattern(self): + """Test extracting score from GitHub notice format.""" + logs = "2024-01-15T10:30:00.000Z ##[notice]Points 10/10\n" + patterns = [r'##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+'] + + result = extract_score_from_logs(logs, patterns) + + assert result.found == "10" + assert result.error is None + + def test_extract_score_with_score_is_pattern(self): + """Test extracting score from 'Score is' format.""" + logs = "2024-01-15T10:30:00.000Z Score is 10.5\n" + patterns = [r'Score\s+is\s+(\d+(?:[.,]\d+)?)'] + + result = extract_score_from_logs(logs, patterns) + + assert result.found == "10.5" + assert result.error is None + + def test_extract_score_with_comma_separator(self): + """Test extracting score with comma decimal separator.""" + logs = "2024-01-15T10:30:00.000Z Total: 10,5\n" + patterns = [r'Total:\s+(\d+(?:[.,]\d+)?)'] + + result = extract_score_from_logs(logs, patterns) + + assert result.found == "10,5" + assert result.error is None + + def test_extract_score_tries_multiple_patterns(self): + """Test that multiple patterns are tried in order.""" + logs = "2024-01-15T10:30:00.000Z Score is 8.5\n" + patterns = [ + r'Points\s+(\d+(?:[.,]\d+)?)', # Won't match + r'Score\s+is\s+(\d+(?:[.,]\d+)?)', # Will match + ] + + result = extract_score_from_logs(logs, patterns) + + assert result.found == "8.5" + assert result.error is None + + def test_extract_score_multiple_occurrences_same_value(self): + """Test multiple occurrences of same score value.""" + logs = """2024-01-15T10:30:00.000Z Score is 10.5 +2024-01-15T10:30:01.000Z Score is 10.5 +2024-01-15T10:30:02.000Z Score is 10.5""" + patterns = [r'Score\s+is\s+(\d+(?:[.,]\d+)?)'] + + result = extract_score_from_logs(logs, patterns) + + assert result.found == "10.5" + assert result.error is None + + def test_extract_score_multiple_different_values_error(self): + """Test error when multiple different scores found.""" + logs = """2024-01-15T10:30:00.000Z Score is 10.5 +2024-01-15T10:30:01.000Z Score is 8.0""" + patterns = [r'Score\s+is\s+(\d+(?:[.,]\d+)?)'] + + result = extract_score_from_logs(logs, patterns) + + assert result.found is None + assert "несколько разных" in result.error.lower() + + def test_extract_score_not_found(self): + """Test error when score not found in logs.""" + logs = "2024-01-15T10:30:00.000Z No score here\n" + patterns = [r'Score\s+is\s+(\d+(?:[.,]\d+)?)'] + + result = extract_score_from_logs(logs, patterns) + + assert result.found is None + assert "не найдены" in result.error.lower() + + def test_extract_score_empty_logs(self): + """Test error with empty logs.""" + result = extract_score_from_logs("", [r'Score\s+is\s+(\d+)']) + + assert result.found is None + assert "пусты" in result.error.lower() + + def test_extract_score_empty_patterns(self): + """Test error with empty patterns list.""" + logs = "2024-01-15T10:30:00.000Z Score is 10\n" + + result = extract_score_from_logs(logs, []) + + assert result.found is None + assert "не указаны" in result.error.lower() + + +class TestScoresEqual: + """Tests for scores_equal function.""" + + def test_scores_equal_same_format(self): + """Test comparing scores with same format.""" + assert scores_equal("10.5", "10.5") is True + assert scores_equal("10,5", "10,5") is True + + def test_scores_equal_different_separators(self): + """Test comparing scores with different separators.""" + assert scores_equal("10.5", "10,5") is True + assert scores_equal("10,5", "10.5") is True + + def test_scores_equal_with_trailing_zeros(self): + """Test comparing scores with trailing zeros.""" + assert scores_equal("10", "10.0") is True + assert scores_equal("10.0", "10") is True + + def test_scores_not_equal(self): + """Test scores that are not equal.""" + assert scores_equal("10.5", "10.6") is False + assert scores_equal("10", "11") is False + + +class TestFormatScore: + """Tests for format_score function.""" + + def test_format_score_to_dot(self): + """Test formatting score with dot separator.""" + assert format_score("10.5", ".") == "10.5" + assert format_score("10,5", ".") == "10.5" + + def test_format_score_to_comma(self): + """Test formatting score with comma separator.""" + assert format_score("10.5", ",") == "10,5" + assert format_score("10,5", ",") == "10,5" + + def test_format_score_integer(self): + """Test formatting integer score.""" + assert format_score("10", ".") == "10" + assert format_score("10", ",") == "10" + + def test_format_score_invalid_separator(self): + """Test error with invalid separator.""" + with pytest.raises(ValueError): + format_score("10.5", ";") + + +class TestFormatGradeWithScore: + """Tests for format_grade_with_score function.""" + + def test_format_grade_score_only(self): + """Test formatting grade with score only.""" + result = format_grade_with_score("v", "10.5", 0, ".") + assert result == "v@10.5" + + def test_format_grade_score_with_penalty(self): + """Test formatting grade with score and penalty.""" + result = format_grade_with_score("v", "10.5", 3, ".") + assert result == "v@10.5-3" + + def test_format_grade_integer_score(self): + """Test formatting grade with integer score.""" + result = format_grade_with_score("v", "10", 0, ".") + assert result == "v@10" + + def test_format_grade_with_comma_separator(self): + """Test formatting grade with comma separator.""" + result = format_grade_with_score("v", "10.5", 0, ",") + assert result == "v@10,5" + + def test_format_grade_score_and_penalty_comma(self): + """Test formatting grade with score, penalty and comma.""" + result = format_grade_with_score("v", "10,5", 2, ",") + assert result == "v@10,5-2"