From 0b37911ffeae3199bab6b9e6037226340beb86f6 Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Tue, 8 Sep 2026 07:20:54 +0300 Subject: [PATCH 1/4] Add bulk grading of a group from the admin panel Grade every student of a group for one lab in a single run, with the same checks as a self-submitted work. Two modes. With a name file, every repository of the lab in the course organization is inspected: the first line of that file gives the student's full name, which resolves their spreadsheet row and records their GitHub username. Without one, only students who already have a username in the sheet are graded. grade_lab mixed the HTTP layer, per-cell Sheets reads and the grading logic in one 240-line function, so the bulk run could not reuse it. grading/bulk.py:evaluate_student now holds that decision and receives what it needs from the spreadsheet through a lazily-invoked provider, so grade_lab still answers repository and CI errors without opening a Sheets connection. Characterization tests pass unchanged. The run is a background job with the same machinery as propagate: an in-memory store under a lock, one job per (course, group, lab), 202 with a job_id, polling and 409 on a second start. The worksheet is read once with get_all_values() and grades are flushed in batches of 10 - the per-cell helpers spend ~6 Sheets requests per student, which for a group of 30 exceeds the 60 reads/minute quota. Adds GitHubClient.get_file_content and grid-based counterparts of the per-cell sheet helpers; reuses the existing list_org_repos. Co-Authored-By: Claude Opus 5 --- grading/__init__.py | 52 +++ grading/bulk.py | 953 +++++++++++++++++++++++++++++++++++++++ grading/github_client.py | 58 +++ grading/sheets_client.py | 288 +++++++++--- main.py | 430 +++++++++++------- 5 files changed, 1553 insertions(+), 228 deletions(-) create mode 100644 grading/bulk.py diff --git a/grading/__init__.py b/grading/__init__.py index 79d432e..1b89564 100644 --- a/grading/__init__.py +++ b/grading/__init__.py @@ -50,9 +50,15 @@ calculate_lab_column, can_overwrite_cell, prepare_grade_update, + parse_deadline, get_deadline_from_sheet, get_student_order, get_decimal_separator, + cell_from_grid, + column_values_from_grid, + find_lab_column_in_grid, + get_deadline_from_grid, + get_student_order_from_grid, StudentLocation, LabColumn, GradeUpdate, @@ -81,6 +87,27 @@ PR_CREATE_PAUSE_SECONDS, ) +from .bulk import ( + SheetContext, + StudentOutcome, + BulkJob, + BulkResult, + BulkGradingError, + NameMatchError, + evaluate_student, + taskid_column, + repo_name_for, + filter_lab_repos, + extract_full_name, + normalize_full_name, + find_row_by_full_name, + resolve_github_cell, + try_start_bulk_job, + get_bulk_job, + request_bulk_job_cancel, + run_bulk_grading, +) + from .score import ( extract_score_from_logs, format_score, @@ -119,9 +146,15 @@ "calculate_lab_column", "can_overwrite_cell", "prepare_grade_update", + "parse_deadline", "get_deadline_from_sheet", "get_student_order", "get_decimal_separator", + "cell_from_grid", + "column_values_from_grid", + "find_lab_column_in_grid", + "get_deadline_from_grid", + "get_student_order_from_grid", "StudentLocation", "LabColumn", "GradeUpdate", @@ -142,6 +175,25 @@ "run_propagation", "get_propagate_job", "PR_CREATE_PAUSE_SECONDS", + # bulk + "SheetContext", + "StudentOutcome", + "BulkJob", + "BulkResult", + "BulkGradingError", + "NameMatchError", + "evaluate_student", + "taskid_column", + "repo_name_for", + "filter_lab_repos", + "extract_full_name", + "normalize_full_name", + "find_row_by_full_name", + "resolve_github_cell", + "try_start_bulk_job", + "get_bulk_job", + "request_bulk_job_cancel", + "run_bulk_grading", # score "extract_score_from_logs", "format_score", diff --git a/grading/bulk.py b/grading/bulk.py new file mode 100644 index 0000000..43a4042 --- /dev/null +++ b/grading/bulk.py @@ -0,0 +1,953 @@ +""" +Bulk grading of a whole group's lab submissions from the admin panel. + +Two parts live here. + +`evaluate_student` is the grading decision for one student, factored out of +the HTTP endpoint so that the single-student endpoint (`grade_lab`) and the +bulk run share one implementation and cannot drift apart. It performs the +GitHub and CI work itself but never touches Google Sheets: everything it +needs from the spreadsheet arrives through a `SheetContext` produced by a +caller-supplied provider, invoked lazily - only once CI evaluation has +produced a result worth writing. That laziness is what lets `grade_lab` keep +returning repository and CI errors without opening a Sheets connection. + +The rest is the background job that walks a group, mirroring the job +machinery of `propagate.py`: an in-memory store guarded by a lock, one +running job per (course, group, lab), and a poll endpoint. Job state is +deliberately not persisted - the backend runs as a single uvicorn worker, so +a restart loses the report but not the work: grades are flushed to the sheet +in batches as the run proceeds. +""" +import logging +import threading +import uuid +from collections import OrderedDict +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Callable + +from .github_client import GitHubClient +from .grader import LabGrader, GradeStatus +from .penalty import calculate_penalty, format_grade_with_penalty, PenaltyStrategy +from .score import format_grade_with_score, format_score +from .sheets_client import ( + calculate_lab_column, + can_overwrite_cell, + cell_from_grid, + column_values_from_grid, + find_lab_column_in_grid, + get_decimal_separator, + get_deadline_from_grid, + get_student_order_from_grid, +) +from .taskid import calculate_expected_taskid + +logger = logging.getLogger(__name__) + +CELL_PROTECTED_MESSAGE = ( + "⚠️ Работа уже была проверена ранее. Обратитесь к преподавателю для пересдачи." +) + +# How many finished jobs to keep around for GET /admin/bulk-grade-jobs/{id}. +MAX_JOBS_KEPT = 20 + +# Cells buffered before a batch write. Flushing as the run proceeds means a +# crash, a restart or a cancellation keeps the grades already decided. +WRITE_BATCH_SIZE = 10 + +# Two header rows precede student data in every group sheet. +FIRST_DATA_ROW = 3 + + +class BulkGradingError(Exception): + """ + Raised when a step shared by the whole run fails before any per-student + work can happen (spreadsheet columns missing, org repos unavailable). + Distinct from a per-student error, which never aborts the whole job. + """ + pass + + +@dataclass +class SheetContext: + """Everything `evaluate_student` needs from the spreadsheet.""" + current_cell_value: str = "" + student_order: int | None = None + deadline: datetime | None = None + decimal_separator: str = "." + + +@dataclass +class StudentOutcome: + """Outcome of grading one student.""" + status: str # updated | rejected | pending | error + cell_value: str | None = None # Value to write: "v", "x", "v-3", "v@10,5-3" + message: str = "" + passed: str | None = None # "3/4 тестов пройдено" + checks: list[str] = field(default_factory=list) + score: str | None = None # Formatted score, if extracted + current_grade: str | None = None # Existing cell value when rejected + error_code: str | None = None # For programmatic error handling + + +def taskid_column( + course_info: dict[str, Any], + lab_config: dict[str, Any], +) -> int | None: + """ + Resolve the 1-based column holding student order numbers for TASKID checks. + + Args: + course_info: Course configuration dict from YAML + lab_config: Lab configuration dict from YAML + + Returns: + 1-based column number, or None when the TASKID check does not apply to + this lab (no `task-id-column`, no `taskid-max`, or `ignore-task-id`) + """ + column = course_info.get("google", {}).get("task-id-column") + if column is None: + return None + + if lab_config.get("taskid-max") is None: + return None + + if lab_config.get("ignore-task-id", False): + return None + + # 0-based in config, 1-based for gspread + return column + 1 + + +def repo_name_for(lab_config: dict[str, Any], username: str) -> str: + """ + Build a student's repository name by the project's naming convention. + + Args: + lab_config: Lab configuration dict from YAML + username: Student's GitHub username + + Returns: + Repository name, e.g. "os-task2-student1" + """ + return f"{lab_config.get('github-prefix', '')}-{username}" + + +def evaluate_student( + grader: LabGrader, + org: str, + username: str, + lab_config: dict[str, Any], + course_info: dict[str, Any], + sheet_context: Callable[[], SheetContext], +) -> StudentOutcome: + """ + Grade one student's repository. + + Steps: + 1. Repository checks (required files, workflows, commits) + 2. Forbidden file modifications + 3. CI evaluation and score extraction + 4. TASKID validation (if configured) + 5. Penalty calculation (if a deadline is set) + 6. Grade formatting and cell protection check + + Args: + grader: Configured LabGrader + org: GitHub organization + username: Student's GitHub username + lab_config: Lab configuration dict from YAML + course_info: Course configuration dict from YAML + sheet_context: Callable returning the spreadsheet context. Invoked at + most once, and only after CI evaluation succeeds, so callers may + defer opening a Sheets connection until then. + + Returns: + StudentOutcome. For status "updated", `cell_value` is what should be + written to the grade cell; the caller performs the write. + """ + repo_name = repo_name_for(lab_config, username) + logger.info(f"Evaluating repository: {org}/{repo_name}") + + # Step 1: Repository checks (required files, workflows, commits) + repo_error = grader.check_repository(org, repo_name, lab_config) + if repo_error: + logger.warning(f"Repository check failed: {repo_error.message}") + return StudentOutcome( + status="error", + message=repo_error.message, + error_code=repo_error.error_code, + ) + + # Step 2: Forbidden file modifications + forbidden_error = grader.check_forbidden_files(org, repo_name, lab_config) + if forbidden_error: + logger.warning(f"Forbidden modification: {forbidden_error.message}") + return StudentOutcome( + status="error", + message=forbidden_error.message, + error_code=forbidden_error.error_code, + ) + + # Step 3: CI evaluation + ci_evaluation = grader._evaluate_ci_internal(org, repo_name, lab_config) + + if ci_evaluation.grade_result.status == GradeStatus.ERROR: + logger.warning(f"CI error: {ci_evaluation.grade_result.message}") + return StudentOutcome( + status="error", + message=ci_evaluation.grade_result.message, + error_code=ci_evaluation.grade_result.error_code, + ) + + if ci_evaluation.grade_result.status == GradeStatus.PENDING: + logger.info(f"CI pending: {ci_evaluation.grade_result.message}") + return StudentOutcome( + status="pending", + message=ci_evaluation.grade_result.message, + passed=ci_evaluation.grade_result.passed, + checks=ci_evaluation.grade_result.checks, + ) + + # CI evaluation is complete - the spreadsheet is needed from here on + context = sheet_context() + + final_result = ci_evaluation.grade_result.result # "v" or "x" + final_message = ci_evaluation.grade_result.message + score_value = ci_evaluation.score + decimal_separator = context.decimal_separator + + # Steps 4-5: additional checks only make sense when CI passed + if ci_evaluation.ci_passed: + if taskid_column(course_info, lab_config) is not None and context.student_order is not None: + taskid_shift = lab_config.get("taskid-shift", 0) + taskid_max = lab_config.get("taskid-max") + expected_taskid = calculate_expected_taskid( + context.student_order, taskid_shift, taskid_max + ) + logger.info( + f"Expected TASKID: {expected_taskid} " + f"(order={context.student_order}, shift={taskid_shift}, max={taskid_max})" + ) + + taskid_error = grader.check_taskid( + org, repo_name, + ci_evaluation.successful_runs, + expected_taskid, + ) + if taskid_error: + logger.warning(f"TASKID error: {taskid_error.message}") + return StudentOutcome( + status="error", + message=taskid_error.message, + error_code=taskid_error.error_code, + ) + + penalty = 0 + if context.deadline and ci_evaluation.latest_success_time: + penalty_max = lab_config.get("penalty-max", 0) + strategy_name = lab_config.get("penalty-strategy", "weekly") + try: + strategy = PenaltyStrategy(strategy_name) + except ValueError: + strategy = PenaltyStrategy.WEEKLY + + penalty = calculate_penalty( + completed_at=ci_evaluation.latest_success_time, + deadline=context.deadline, + penalty_max=penalty_max, + strategy=strategy, + ) + + if penalty > 0: + logger.info(f"Calculated penalty: {penalty}") + + # Step 6: format the grade with score and penalty + if score_value is not None: + final_result = format_grade_with_score( + "v", score_value, penalty, decimal_separator + ) + logger.info(f"Formatted grade with score: {final_result}") + + formatted_score = format_score(score_value, decimal_separator) + if penalty > 0: + final_message = ( + f"Результат CI: ✅ Все проверки пройдены " + f"(Баллы: {formatted_score}, штраф: -{penalty})" + ) + else: + final_message = ( + f"Результат CI: ✅ Все проверки пройдены (Баллы: {formatted_score})" + ) + elif 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}") + + formatted_score = ( + format_score(score_value, decimal_separator) if score_value is not None else None + ) + + # Cell protection + if not can_overwrite_cell(context.current_cell_value): + logger.warning( + f"Update rejected: cell already contains '{context.current_cell_value}'" + ) + return StudentOutcome( + status="rejected", + cell_value=context.current_cell_value, + message=CELL_PROTECTED_MESSAGE, + passed=ci_evaluation.grade_result.passed, + checks=ci_evaluation.grade_result.checks, + score=formatted_score, + current_grade=context.current_cell_value, + ) + + return StudentOutcome( + status="updated", + cell_value=final_result, + message=final_message, + passed=ci_evaluation.grade_result.passed, + checks=ci_evaluation.grade_result.checks, + score=formatted_score, + ) + + +# --------------------------------------------------------------------------- +# Repository discovery and student matching ("by file" mode) +# --------------------------------------------------------------------------- + + +def filter_lab_repos(repo_names: list[str], prefix: str) -> dict[str, str]: + """ + Select the repositories belonging to one lab and extract usernames. + + The dash after the prefix is required, so the prefix "os-task1" does not + swallow "os-task10-student1". + + Args: + repo_names: All repository names in the organization + prefix: Lab's github-prefix + + Returns: + Mapping of GitHub username -> repository name + + Examples: + >>> filter_lab_repos(["os-task1-alice", "os-task10-bob"], "os-task1") + {'alice': 'os-task1-alice'} + >>> filter_lab_repos(["os-task1-jane-doe"], "os-task1") + {'jane-doe': 'os-task1-jane-doe'} + """ + if not prefix: + return {} + + matched: dict[str, str] = {} + marker = f"{prefix}-" + + for name in repo_names: + if not name.startswith(marker): + continue + + username = name[len(marker):] + if not username: + continue + + matched[username] = name + + return matched + + +def extract_full_name(file_content: str | None) -> str | None: + """ + Extract a student's full name from the contents of their name file. + + The name is the first non-empty line of the file. + + Args: + file_content: Decoded file content, or None if the file is unavailable + + Returns: + The name with surrounding whitespace stripped, or None if the file is + empty or holds only blank lines + + Examples: + >>> extract_full_name("Иванов Иван Иванович\\nЛР1\\n") + 'Иванов Иван Иванович' + >>> extract_full_name("\\r\\n Петров Пётр \\r\\n") + 'Петров Пётр' + >>> extract_full_name(" ") is None + True + """ + if not file_content: + return None + + for line in file_content.splitlines(): + stripped = line.strip() + if stripped: + return stripped + + return None + + +def normalize_full_name(name: str | None) -> str: + """ + Normalize a full name for comparison. + + Students type their name by hand into the name file, so comparison ignores + letter case, collapses whitespace runs (including non-breaking spaces) and + treats "ё" as "е". + + Args: + name: Raw name string + + Returns: + Normalized name, or "" for an empty input + + Examples: + >>> normalize_full_name("Иванов Иван\\tИванович") + 'иванов иван иванович' + >>> normalize_full_name("Алёшин Алексей") == normalize_full_name("Алешин Алексей") + True + """ + if not name: + return "" + + collapsed = " ".join(name.replace(" ", " ").split()) + return collapsed.casefold().replace("ё", "е") + + +class NameMatchError(Exception): + """Raised when a full name cannot be resolved to exactly one sheet row.""" + + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + self.message = message + + +def find_row_by_full_name( + student_names: list[str], + full_name: str, + start_row: int = FIRST_DATA_ROW, +) -> int: + """ + Find the spreadsheet row for a student by full name. + + Matching is exact after normalization (see normalize_full_name). Fuzzy + matching is deliberately not attempted: a wrong match writes a grade + against the wrong student. + + Args: + student_names: Values of the student name column, starting at start_row + full_name: Name read from the repository's name file + start_row: 1-based row number of the first entry in student_names + + Returns: + 1-based row number + + Raises: + NameMatchError: code "unmatched" when no row matches, "ambiguous" when + several rows carry the same name + """ + target = normalize_full_name(full_name) + if not target: + raise NameMatchError("unmatched", "ФИО не найдено в файле") + + matches = [ + start_row + idx + for idx, value in enumerate(student_names) + if normalize_full_name(value) == target + ] + + if not matches: + raise NameMatchError("unmatched", f"ФИО «{full_name}» не найдено в таблице") + + if len(matches) > 1: + raise NameMatchError( + "ambiguous", + f"ФИО «{full_name}» встречается в таблице несколько раз " + f"(строки {', '.join(str(row) for row in matches)})", + ) + + return matches[0] + + +def resolve_github_cell(existing: str, username: str) -> tuple[bool, str | None]: + """ + Decide what to do with a student's GitHub cell in "by file" mode. + + Args: + existing: Current cell value + username: Username taken from the repository name + + Returns: + (should_write, conflict_message). `should_write` is True when the cell + is empty and the username has to be recorded. When the cell holds a + different username, `conflict_message` explains the conflict and the + student must not be graded under either name. + + Examples: + >>> resolve_github_cell("", "alice") + (True, None) + >>> resolve_github_cell("Alice", "alice") + (False, None) + """ + current = (existing or "").strip() + + if not current: + return True, None + + if current.casefold() == username.casefold(): + return False, None + + return False, ( + f"В таблице указан другой аккаунт GitHub: «{current}», " + f"репозиторий принадлежит «{username}»" + ) + + +# --------------------------------------------------------------------------- +# Background job: store, planning and execution +# --------------------------------------------------------------------------- + + +@dataclass +class BulkResult: + """Outcome for a single student, as shown in the run report.""" + status: str # updated | rejected | pending | error | conflict | unmatched | ambiguous + student_name: str | None = None + github: str | None = None + repo: str | None = None + grade: str | None = None + message: str = "" + registered: bool = False # GitHub username was written to the sheet + + def to_dict(self) -> dict: + return { + "status": self.status, + "student_name": self.student_name, + "github": self.github, + "repo": self.repo, + "grade": self.grade, + "message": self.message, + "registered": self.registered, + } + + +@dataclass +class BulkJob: + """State of one background bulk grading run.""" + job_id: str + course_id: str + group_id: str + lab_id: str + mode: str # by_sheet | by_file + dry_run: bool = False + name_file: str | None = None + status: str = "running" # running | done | failed | cancelled + started_at: str = "" + finished_at: str | None = None + total: int = 0 + processed: int = 0 + results: list[BulkResult] = field(default_factory=list) + error: str | None = None + cancel_requested: bool = False + + def to_dict(self) -> dict: + counts: dict[str, int] = {} + for result in self.results: + counts[result.status] = counts.get(result.status, 0) + 1 + + return { + "job_id": self.job_id, + "course_id": self.course_id, + "group_id": self.group_id, + "lab_id": self.lab_id, + "mode": self.mode, + "dry_run": self.dry_run, + "name_file": self.name_file, + "status": self.status, + "started_at": self.started_at, + "finished_at": self.finished_at, + "total": self.total, + "processed": self.processed, + "counts": counts, + "results": [r.to_dict() for r in self.results], + "error": self.error, + } + + +# Module-level job store, same shape and rationale as propagate.py: guarded by +# a lock because BackgroundTasks run in FastAPI's threadpool, and safe as a +# plain dict only as long as the backend stays a single uvicorn worker. +_jobs: "OrderedDict[str, BulkJob]" = OrderedDict() +_running_keys: set[tuple[str, str, str]] = set() +_jobs_lock = threading.Lock() + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def get_bulk_job(job_id: str) -> BulkJob | None: + """Look up a job by id (used by GET /admin/bulk-grade-jobs/{job_id}).""" + with _jobs_lock: + return _jobs.get(job_id) + + +def _evict_old_jobs_locked() -> None: + """Keep at most MAX_JOBS_KEPT jobs, oldest first. Must hold _jobs_lock.""" + if len(_jobs) <= MAX_JOBS_KEPT: + return + for job_id in list(_jobs.keys()): + if len(_jobs) <= MAX_JOBS_KEPT: + break + # Never evict a running job - it would leave _running_keys pointing at + # a job GET can no longer find. + if _jobs[job_id].status == "running": + continue + del _jobs[job_id] + + +def try_start_bulk_job( + course_id: str, + group_id: str, + lab_id: str, + mode: str, + dry_run: bool, + name_file: str | None, +) -> BulkJob | None: + """ + Atomically create and register a running job for (course, group, lab), + unless one is already running for that same triple. + + Returns: + The new BulkJob, or None if a job for this group and lab is already + running (caller should respond HTTP 409). + """ + with _jobs_lock: + if (course_id, group_id, lab_id) in _running_keys: + return None + job = BulkJob( + job_id=uuid.uuid4().hex, + course_id=course_id, + group_id=group_id, + lab_id=lab_id, + mode=mode, + dry_run=dry_run, + name_file=name_file, + started_at=_now(), + ) + _jobs[job.job_id] = job + _running_keys.add((course_id, group_id, lab_id)) + _evict_old_jobs_locked() + return job + + +def request_bulk_job_cancel(job_id: str) -> BulkJob | None: + """ + Ask a running job to stop after the student it is currently on. + + Returns: + The job, or None if there is no job with this id. + """ + with _jobs_lock: + job = _jobs.get(job_id) + if job and job.status == "running": + job.cancel_requested = True + return job + + +def _finish_job_locked(job: BulkJob, status: str, error: str | None = None) -> None: + job.status = status + job.error = error + job.finished_at = _now() + _running_keys.discard((job.course_id, job.group_id, job.lab_id)) + + +@dataclass +class _Target: + """A student queued for grading.""" + row: int + username: str + student_name: str | None + repo: str + registered: bool = False # Username was queued for writing into the sheet + + +def _plan_by_file( + job: BulkJob, + github_client: GitHubClient, + org: str, + lab_config: dict[str, Any], + name_file: str, + values: list[list[str]], + student_col: int, + github_col: int, +) -> tuple[list[_Target], list[tuple[int, int, str]]]: + """ + Discover repositories and map them to spreadsheet rows via the name file. + + Rows that cannot be resolved are appended to `job.results` here - they are + finished work, not something to retry per student. + + Returns: + (targets, github_writes), where github_writes are (row, col, value) + triples recording newly resolved GitHub usernames + + Raises: + BulkGradingError: the organization's repositories are unavailable + """ + prefix = lab_config.get("github-prefix", "") + + org_repos = github_client.list_org_repos(org) + if org_repos is None: + raise BulkGradingError("Не удалось получить список репозиториев организации") + + repos = filter_lab_repos([repo.get("name", "") for repo in org_repos], prefix) + logger.info(f"Found {len(repos)} repositories with prefix '{prefix}' in {org}") + + student_names = column_values_from_grid(values, student_col, start_row=FIRST_DATA_ROW) + + targets: list[_Target] = [] + github_writes: list[tuple[int, int, str]] = [] + + for username, repo in sorted(repos.items(), key=lambda pair: pair[0].casefold()): + full_name = extract_full_name( + github_client.get_file_content(org, repo, name_file) + ) + if full_name is None: + job.results.append(BulkResult( + status="unmatched", + github=username, + repo=repo, + message=f"Файл {name_file} не найден, пуст или не читается как текст", + )) + continue + + try: + row = find_row_by_full_name(student_names, full_name, FIRST_DATA_ROW) + except NameMatchError as e: + job.results.append(BulkResult( + status=e.code, + student_name=full_name, + github=username, + repo=repo, + message=e.message, + )) + continue + + should_write, conflict = resolve_github_cell( + cell_from_grid(values, row, github_col), username + ) + if conflict: + job.results.append(BulkResult( + status="conflict", + student_name=full_name, + github=username, + repo=repo, + message=conflict, + )) + continue + + if should_write: + github_writes.append((row, github_col, username)) + + targets.append(_Target( + row=row, + username=username, + student_name=cell_from_grid(values, row, student_col) or full_name, + repo=repo, + registered=should_write, + )) + + return targets, github_writes + + +def _plan_by_sheet( + values: list[list[str]], + student_col: int, + github_col: int, + lab_config: dict[str, Any], +) -> list[_Target]: + """Queue every student who already has a GitHub username in the sheet.""" + targets: list[_Target] = [] + + github_values = column_values_from_grid(values, github_col, start_row=FIRST_DATA_ROW) + for idx, value in enumerate(github_values): + username = (value or "").strip() + if not username: + continue + + row = FIRST_DATA_ROW + idx + targets.append(_Target( + row=row, + username=username, + student_name=cell_from_grid(values, row, student_col) or None, + repo=repo_name_for(lab_config, username), + )) + + return targets + + +def run_bulk_grading( + job: BulkJob, + grader: LabGrader, + github_client: GitHubClient, + worksheet, + spreadsheet, + course_info: dict[str, Any], + lab_config: dict[str, Any], + lab_number: int, +) -> None: + """ + Execute a bulk grading job, updating `job` in place as it goes. + + Reads the whole worksheet once and resolves rows, columns, deadline and + task IDs in memory. The per-cell helpers spend ~6 Sheets API requests per + student, which for a group of 30 exceeds the 60 reads/minute quota. + + Students are processed sequentially: GitHub applies secondary rate limits + to bursts of parallel requests from one token, and the run is backgrounded + anyway. + + Args: + job: Job to run and report progress into + grader: Configured LabGrader + github_client: Configured GitHubClient + worksheet: gspread Worksheet for the group + spreadsheet: gspread Spreadsheet (used for the locale) + course_info: Course configuration dict from YAML + lab_config: Lab configuration dict from YAML + lab_number: Lab number from its config key, for the lab-column fallback + """ + from gspread.utils import rowcol_to_a1 + + org = course_info.get("github", {}).get("organization") + pending: list[tuple[int, int, str]] = [] + + def flush() -> None: + """Write buffered cells to the spreadsheet in one API call.""" + if job.dry_run or not pending: + pending.clear() + return + + worksheet.batch_update([ + {"range": rowcol_to_a1(row, col), "values": [[value]]} + for row, col, value in pending + ]) + logger.info(f"Bulk job {job.job_id}: flushed {len(pending)} cell(s)") + pending.clear() + + try: + values = worksheet.get_all_values() + decimal_separator = get_decimal_separator(spreadsheet) + + header_row = values[0] if values else [] + if "GitHub" not in header_row: + raise BulkGradingError("Столбец 'GitHub' не найден в таблице") + github_col = header_row.index("GitHub") + 1 + + student_col = course_info.get("google", {}).get("student-name-column", 1) + 1 + + lab_short_name = lab_config.get("short-name") + if lab_short_name: + lab_col = find_lab_column_in_grid(values, lab_short_name) + if not lab_col: + raise BulkGradingError(f"Столбец '{lab_short_name}' не найден в таблице") + else: + # Same fallback as grade_lab when a lab has no short-name. + lab_offset = course_info.get("google", {}).get("lab-column-offset", 1) + lab_col = calculate_lab_column(lab_number, lab_offset) + + deadline = get_deadline_from_grid( + values, lab_col, deadline_row=1, timezone_str=course_info.get("timezone") + ) + task_id_col = taskid_column(course_info, lab_config) + + if job.mode == "by_file": + targets, github_writes = _plan_by_file( + job, github_client, org, lab_config, job.name_file, + values, student_col, github_col, + ) + pending.extend(github_writes) + else: + targets = _plan_by_sheet(values, student_col, github_col, lab_config) + + # Rows rejected while planning are already done; count them as processed + job.total = len(targets) + len(job.results) + job.processed = len(job.results) + logger.info( + f"Bulk job {job.job_id}: {len(targets)} student(s) to grade, " + f"{len(job.results)} rejected while planning" + ) + + cancelled = False + for target in targets: + if job.cancel_requested: + logger.info(f"Bulk job {job.job_id}: cancellation requested") + cancelled = True + break + + def context_for(target=target) -> SheetContext: + return SheetContext( + current_cell_value=cell_from_grid(values, target.row, lab_col), + student_order=( + get_student_order_from_grid(values, target.row, task_id_col) + if task_id_col is not None else None + ), + deadline=deadline, + decimal_separator=decimal_separator, + ) + + try: + outcome = evaluate_student( + grader, org, target.username, lab_config, course_info, context_for, + ) + result = BulkResult( + status=outcome.status, + student_name=target.student_name, + github=target.username, + repo=target.repo, + grade=outcome.cell_value if outcome.status in ("updated", "rejected") else None, + message=outcome.message, + registered=target.registered, + ) + if outcome.status == "updated": + pending.append((target.row, lab_col, outcome.cell_value)) + except Exception as e: + logger.exception(f"Bulk job {job.job_id}: error grading {target.username}") + result = BulkResult( + status="error", + student_name=target.student_name, + github=target.username, + repo=target.repo, + message=f"Внутренняя ошибка при проверке: {e}", + registered=target.registered, + ) + + job.results.append(result) + job.processed += 1 + + if len(pending) >= WRITE_BATCH_SIZE: + flush() + + flush() + + with _jobs_lock: + _finish_job_locked(job, "cancelled" if cancelled else "done") + + except Exception as e: + logger.exception(f"Bulk job {job.job_id} failed: {e}") + try: + flush() + except Exception: + logger.exception(f"Bulk job {job.job_id}: could not flush pending writes") + with _jobs_lock: + _finish_job_locked(job, "failed", str(e)) + finally: + logger.info( + f"Bulk job {job.job_id} finished with status '{job.status}': " + f"{job.processed}/{job.total} processed" + ) diff --git a/grading/github_client.py b/grading/github_client.py index 4a1e945..56bfd1c 100644 --- a/grading/github_client.py +++ b/grading/github_client.py @@ -4,10 +4,15 @@ This module provides a client for interacting with GitHub API to check repositories, commits, and CI status. """ +import base64 +import binascii import requests from dataclasses import dataclass from typing import Any +# Text files above this size are not fetched for content extraction +MAX_TEXT_FILE_SIZE = 1024 * 1024 + @dataclass class CommitInfo: @@ -102,6 +107,59 @@ def file_exists(self, org: str, repo: str, path: str) -> bool: resp = requests.get(url, headers=self.headers) return resp.status_code == 200 + def get_file_content( + self, + org: str, + repo: str, + path: str, + max_size: int = MAX_TEXT_FILE_SIZE, + ) -> str | None: + """ + Read a repository file as text. + + Used by bulk grading to pull the student's full name out of the file + named by the lab's `student-name-file`. + + Args: + org: Organization or user name + repo: Repository name + path: File path within repository + max_size: Skip files larger than this many bytes + + Returns: + Decoded text (BOM stripped), or None if the file is missing, too + large, a directory, or not valid UTF-8 + """ + url = f"{self.BASE_URL}/repos/{org}/{repo}/contents/{path}" + resp = requests.get(url, headers=self.headers, timeout=self.DEFAULT_TIMEOUT) + + if resp.status_code != 200: + return None + + data = resp.json() + + # A directory path comes back as a list of entries, not file content + if not isinstance(data, dict) or data.get("type") != "file": + return None + + # GitHub omits the body of large files, answering with encoding "none" + if data.get("size", 0) > max_size or data.get("encoding") != "base64": + return None + + content = data.get("content") + if content is None: + return None + + try: + raw = base64.b64decode(content) + except (binascii.Error, ValueError): + return None + + try: + return raw.decode("utf-8-sig") + except UnicodeDecodeError: + return None + def check_required_files( self, org: str, diff --git a/grading/sheets_client.py b/grading/sheets_client.py index f8eef9a..b526164 100644 --- a/grading/sheets_client.py +++ b/grading/sheets_client.py @@ -50,8 +50,8 @@ def find_student_row( Examples: >>> find_student_row(["user1", "user2", "user3"], "user2") 4 - >>> find_student_row(["user1", "user2"], "unknown") - None + >>> find_student_row(["user1", "user2"], "unknown") is None + True """ # Handle case-insensitive comparison github_lower = github_username.lower() @@ -241,6 +241,35 @@ def get_deadline_from_sheet( Returns: datetime object with timezone or None if not found/parseable + Supported formats: + - "DD.MM.YYYY" (e.g., "15.03.2025") + - "DD.MM.YYYY HH:MM" (e.g., "15.03.2025 23:59") + - "YYYY-MM-DD" (e.g., "2025-03-15") + - "YYYY-MM-DDTHH:MM:SS" (ISO format) + - Dates with timezone info (e.g., "2025-03-15T23:59:59+03:00") + """ + try: + cell_value = worksheet.cell(deadline_row, lab_col).value + return parse_deadline(cell_value, timezone_str) + except Exception as e: + logger.error(f"Error reading deadline: {e}") + return None + + +def parse_deadline( + cell_value: str | None, + timezone_str: str | None = None, +) -> datetime | None: + """ + Parse a deadline cell value into a timezone-aware datetime. + + Args: + cell_value: Raw cell content (may be None or empty) + timezone_str: Timezone string (e.g., "UTC+3", "UTC-5") to apply if date is naive + + Returns: + datetime object with timezone or None if empty/unparseable + Supported formats: - "DD.MM.YYYY" (e.g., "15.03.2025") - "DD.MM.YYYY HH:MM" (e.g., "15.03.2025 23:59") @@ -251,62 +280,57 @@ def get_deadline_from_sheet( from datetime import timezone, timedelta import re - try: - cell_value = worksheet.cell(deadline_row, lab_col).value - if not cell_value: - return None + if not cell_value: + return None - cell_value = cell_value.strip() - - # Try different date formats - formats = [ - "%d.%m.%Y %H:%M", # 15.03.2025 23:59 - "%d.%m.%Y", # 15.03.2025 - "%Y-%m-%d %H:%M:%S", # 2025-03-15 23:59:59 - "%Y-%m-%d %H:%M", # 2025-03-15 23:59 - "%Y-%m-%d", # 2025-03-15 - "%Y-%m-%dT%H:%M:%S", # ISO format - ] - - parsed_dt = None - for fmt in formats: - try: - parsed_dt = datetime.strptime(cell_value, fmt) - break - except ValueError: - continue - - if parsed_dt is None: - logger.warning(f"Could not parse deadline '{cell_value}' at row {deadline_row}, col {lab_col}") - return None + cell_value = cell_value.strip() + + # Try different date formats + formats = [ + "%d.%m.%Y %H:%M", # 15.03.2025 23:59 + "%d.%m.%Y", # 15.03.2025 + "%Y-%m-%d %H:%M:%S", # 2025-03-15 23:59:59 + "%Y-%m-%d %H:%M", # 2025-03-15 23:59 + "%Y-%m-%d", # 2025-03-15 + "%Y-%m-%dT%H:%M:%S", # ISO format + ] + + parsed_dt = None + for fmt in formats: + try: + parsed_dt = datetime.strptime(cell_value, fmt) + break + except ValueError: + continue + + if parsed_dt is None: + logger.warning(f"Could not parse deadline '{cell_value}'") + 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}") - return parsed_dt - - # If no timezone and timezone_str provided, apply it - if timezone_str: - # Parse timezone string like "UTC+3" or "UTC-5" - match = re.match(r'UTC([+-]\d+)', timezone_str) - if match: - offset_hours = int(match.group(1)) - tz = timezone(timedelta(hours=offset_hours)) - parsed_dt = parsed_dt.replace(tzinfo=tz) - logger.debug(f"Applied timezone {timezone_str} to deadline: {parsed_dt}") - else: - logger.warning(f"Could not parse timezone string '{timezone_str}', using naive datetime") + # 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}") return parsed_dt - except Exception as e: - logger.error(f"Error reading deadline: {e}") - return None + + # If no timezone and timezone_str provided, apply it + if timezone_str: + # Parse timezone string like "UTC+3" or "UTC-5" + match = re.match(r'UTC([+-]\d+)', timezone_str) + if match: + offset_hours = int(match.group(1)) + tz = timezone(timedelta(hours=offset_hours)) + parsed_dt = parsed_dt.replace(tzinfo=tz) + logger.debug(f"Applied timezone {timezone_str} to deadline: {parsed_dt}") + else: + logger.warning(f"Could not parse timezone string '{timezone_str}', using naive datetime") + + return parsed_dt def get_student_order( @@ -380,3 +404,159 @@ def get_decimal_separator(spreadsheet) -> str: except Exception as e: logger.warning(f"Could not determine spreadsheet locale: {e}. Using default separator '.'") return '.' + + +# --------------------------------------------------------------------------- +# Grid-based helpers +# +# Bulk grading reads the whole worksheet once via ``get_all_values()`` and then +# resolves everything in memory. The Sheets API allows 60 read requests per +# minute, while the per-cell helpers above spend ~6 requests per student. +# +# ``values`` is the raw grid returned by gspread: a list of rows, each a list +# of cell strings. Rows may be ragged - trailing empty cells are omitted. +# --------------------------------------------------------------------------- + + +def cell_from_grid(values: list[list[str]], row: int, col: int) -> str: + """ + Read a single cell from the grid using 1-based coordinates. + + Args: + values: Grid from worksheet.get_all_values() + row: 1-based row number + col: 1-based column number + + Returns: + Cell value, or "" if the coordinates are outside the grid + + Examples: + >>> cell_from_grid([["a", "b"], ["c"]], 1, 2) + 'b' + >>> cell_from_grid([["a", "b"], ["c"]], 2, 2) + '' + """ + if row < 1 or col < 1 or row > len(values): + return "" + + row_values = values[row - 1] + if col > len(row_values): + return "" + + return row_values[col - 1] or "" + + +def column_values_from_grid( + values: list[list[str]], + col: int, + start_row: int = 1, +) -> list[str]: + """ + Read a column from the grid using 1-based coordinates. + + Mirrors gspread's ``col_values()``: missing cells become empty strings. + + Args: + values: Grid from worksheet.get_all_values() + col: 1-based column number + start_row: 1-based row to start from (default 1 = from the top) + + Returns: + List of cell values from start_row to the last row of the grid + + Examples: + >>> column_values_from_grid([["a", "b"], ["c", "d"], ["e"]], 2) + ['b', 'd', ''] + >>> column_values_from_grid([["a", "b"], ["c", "d"]], 1, start_row=2) + ['c'] + """ + return [ + cell_from_grid(values, row, col) + for row in range(start_row, len(values) + 1) + ] + + +def find_lab_column_in_grid( + values: list[list[str]], + short_name: str, +) -> int | None: + """ + Find lab column by searching for short_name in the grid. + + Reproduces the scan order of gspread's ``worksheet.find()``: rows top to + bottom, cells left to right, first exact match wins. + + Args: + values: Grid from worksheet.get_all_values() + short_name: Lab short name to find (e.g., "ЛР1") + + Returns: + 1-based column number or None if not found + + Examples: + >>> find_lab_column_in_grid([["№", "ФИО", "GitHub", "ЛР1"]], "ЛР1") + 4 + >>> find_lab_column_in_grid([["№", "ФИО"]], "ЛР1") is None + True + """ + if not short_name: + return None + + for row_values in values: + for idx, value in enumerate(row_values): + if value == short_name: + return idx + 1 + + return None + + +def get_deadline_from_grid( + values: list[list[str]], + lab_col: int, + deadline_row: int = 1, + timezone_str: str | None = None, +) -> datetime | None: + """ + Get deadline datetime from the grid. + + Grid-based counterpart of get_deadline_from_sheet(). + + Args: + values: Grid from worksheet.get_all_values() + lab_col: 1-based column number of the lab + deadline_row: Row number containing deadline (default 1) + timezone_str: Timezone string (e.g., "UTC+3") to apply if date is naive + + Returns: + datetime object with timezone or None if not found/parseable + """ + return parse_deadline(cell_from_grid(values, deadline_row, lab_col), timezone_str) + + +def get_student_order_from_grid( + values: list[list[str]], + row: int, + task_id_column: int, +) -> int | None: + """ + Get student's task ID / order number from the grid. + + Grid-based counterpart of get_student_order(). + + Args: + values: Grid from worksheet.get_all_values() + row: Student's 1-based row number + task_id_column: 1-based column number containing task IDs + + Returns: + Integer order number or None if empty/unparseable + """ + cell_value = cell_from_grid(values, row, task_id_column) + if not cell_value: + return None + + try: + return int(cell_value.strip()) + except (ValueError, TypeError) as e: + logger.warning(f"Could not parse task ID at row {row}, col {task_id_column}: {e}") + return None diff --git a/main.py b/main.py index 911d0b1..86ccfd1 100644 --- a/main.py +++ b/main.py @@ -42,6 +42,13 @@ try_start_propagate_job, run_propagation, get_propagate_job, + SheetContext, + evaluate_student, + taskid_column, + try_start_bulk_job, + get_bulk_job, + request_bulk_job_cancel, + run_bulk_grading, ) # Configure logging to both file and console @@ -662,8 +669,9 @@ def grade_lab(request: Request, course_id: str, group_id: str, lab_id: str, grad """ Grade a lab submission by checking GitHub repository and CI status. - Uses the LabGrader orchestrator for GitHub checks and CI evaluation, - then updates the grade in Google Sheets. + The grading decision itself lives in grading.bulk.evaluate_student, shared + with the bulk admin run; this endpoint supplies the spreadsheet context and + translates the outcome into an HTTP response. Flow (preserves original behavior): 1. GitHub checks (files, workflows, commits, forbidden mods) @@ -698,197 +706,136 @@ def grade_lab(request: Request, course_id: str, group_id: str, lab_id: str, grad grader = LabGrader(github_client) username = grade_request.github - repo_name = f"{repo_prefix}-{username}" - logger.info(f"Checking repository: {org}/{repo_name}") - - # Step 1: Check repository (required files, workflows, commits) - repo_error = grader.check_repository(org, repo_name, lab_config_dict) - if repo_error: - logger.warning(f"Repository check failed: {repo_error.message}") - # Use 404 for "no commits" to match original behavior - status_code = 404 if repo_error.error_code == "NO_COMMITS" else 400 - raise HTTPException(status_code=status_code, detail=repo_error.message) - - # Step 2: Check forbidden file modifications - forbidden_error = grader.check_forbidden_files(org, repo_name, lab_config_dict) - if forbidden_error: - logger.warning(f"Forbidden modification: {forbidden_error.message}") - raise HTTPException(status_code=403, detail=forbidden_error.message) - - # Step 3: Evaluate CI results - ci_evaluation = grader._evaluate_ci_internal(org, repo_name, lab_config_dict) - - # Return early for errors (no Sheets needed) - if ci_evaluation.grade_result.status == GradeStatus.ERROR: - logger.warning(f"CI error: {ci_evaluation.grade_result.message}") - raise HTTPException(status_code=400, detail=ci_evaluation.grade_result.message) - - # Return early for pending (no Sheets needed) - if ci_evaluation.grade_result.status == GradeStatus.PENDING: - logger.info(f"CI pending: {ci_evaluation.grade_result.message}") - return { - "status": "pending", - "message": ci_evaluation.grade_result.message, - "passed": ci_evaluation.grade_result.passed, - "checks": ci_evaluation.grade_result.checks - } - - # CI evaluation complete - now connect to Sheets for writing result - logger.info(f"Connecting to Google Sheets for group {group_id}") - scope = ["https://spreadsheets.google.com/feeds", "https://www.googleapis.com/auth/drive"] - creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope) - sheets_client = gspread.authorize(creds) - try: - 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 Таблице") + # Where the grade goes once evaluate_student produces one. Filled in by + # load_sheet_context(), which only runs if we get that far. + target: dict = {} + + def load_sheet_context() -> SheetContext: + """Open the group's sheet and read everything the grading needs from it.""" + logger.info(f"Connecting to Google Sheets for group {group_id}") + scope = ["https://spreadsheets.google.com/feeds", "https://www.googleapis.com/auth/drive"] + creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope) + sheets_client = gspread.authorize(creds) + + try: + 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: + github_col_idx = header_row.index("GitHub") + 1 + except ValueError: + logger.error(f"'GitHub' column not found in spreadsheet headers") + raise HTTPException(status_code=400, detail="Столбец 'GitHub' не найден") + + github_values = sheet.col_values(github_col_idx)[2:] + row_idx = find_student_row(github_values, username) + + if row_idx is None: + logger.warning(f"GitHub username '{username}' not found in spreadsheet for group {group_id}") + raise HTTPException(status_code=404, detail="GitHub логин не найден в таблице. Зарегистрируйтесь.") + + # Find lab column + lab_short_name = lab_config_dict.get("short-name") + if lab_short_name: + lab_col = find_lab_column_by_name(sheet, lab_short_name) + if lab_col: + logger.info(f"Found lab column '{lab_short_name}' at column {lab_col}") + else: + logger.error(f"Lab column '{lab_short_name}' not found in spreadsheet") + raise HTTPException(status_code=400, detail=f"Столбец '{lab_short_name}' не найден в таблице") + else: + logger.warning(f"Lab config for '{lab_id}' is missing 'short-name', using offset calculation") + lab_offset = course_info.get("google", {}).get("lab-column-offset", 1) + # Номер берём из ключа конфига: строка клиента может быть short-name. + lab_number = parse_lab_id(lab_key or lab_id) + lab_col = calculate_lab_column(lab_number, lab_offset) + logger.info(f"Calculated lab column using offset: {lab_offset} + {lab_number} = {lab_col}") + + # Get current cell value for protection check + current_value = sheet.cell(row_idx, lab_col).value or "" + logger.info(f"Current cell value at row {row_idx}, column {lab_col}: '{current_value}'") + + # Student order is only read when the TASKID check applies to this lab + student_order = None + task_id_column = taskid_column(course_info, lab_config_dict) + if task_id_column is not None: + student_order = get_student_order(sheet, row_idx, task_id_column) - # Get decimal separator from spreadsheet locale - decimal_separator = get_decimal_separator(spreadsheet) - logger.info(f"Using decimal separator: '{decimal_separator}'") + # Deadline for penalty calculation, in the course timezone + timezone_str = course_info.get("timezone") + deadline = get_deadline_from_sheet(sheet, lab_col, deadline_row=1, timezone_str=timezone_str) - # Find GitHub column and student row - header_row = sheet.row_values(1) - try: - github_col_idx = header_row.index("GitHub") + 1 - except ValueError: - logger.error(f"'GitHub' column not found in spreadsheet headers") - raise HTTPException(status_code=400, detail="Столбец 'GitHub' не найден") + target["sheet"] = sheet + target["row"] = row_idx + target["col"] = lab_col - github_values = sheet.col_values(github_col_idx)[2:] - row_idx = find_student_row(github_values, username) + return SheetContext( + current_cell_value=current_value, + student_order=student_order, + deadline=deadline, + decimal_separator=decimal_separator, + ) - if row_idx is None: - logger.warning(f"GitHub username '{username}' not found in spreadsheet for group {group_id}") - raise HTTPException(status_code=404, detail="GitHub логин не найден в таблице. Зарегистрируйтесь.") + outcome = evaluate_student( + grader, org, username, lab_config_dict, course_info, load_sheet_context, + ) - # Find lab column - lab_short_name = lab_config_dict.get("short-name") - if lab_short_name: - lab_col = find_lab_column_by_name(sheet, lab_short_name) - if lab_col: - logger.info(f"Found lab column '{lab_short_name}' at column {lab_col}") + if outcome.status == "error": + # Use 404 for "no commits" and 403 for forbidden edits, as before + if outcome.error_code == "NO_COMMITS": + status_code = 404 + elif outcome.error_code == "FORBIDDEN_MODIFICATION": + status_code = 403 else: - logger.error(f"Lab column '{lab_short_name}' not found in spreadsheet") - raise HTTPException(status_code=400, detail=f"Столбец '{lab_short_name}' не найден в таблице") - else: - logger.warning(f"Lab config for '{lab_id}' is missing 'short-name', using offset calculation") - lab_offset = course_info.get("google", {}).get("lab-column-offset", 1) - # Номер берём из ключа конфига: строка клиента может быть short-name. - lab_number = parse_lab_id(lab_key or lab_id) - lab_col = calculate_lab_column(lab_number, lab_offset) - logger.info(f"Calculated lab column using offset: {lab_offset} + {lab_number} = {lab_col}") - - # Get current cell value for protection check - current_value = sheet.cell(row_idx, lab_col).value or "" - logger.info(f"Current cell value at row {row_idx}, column {lab_col}: '{current_value}'") - - # 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: - # Check TASKID if configured - task_id_column_config = course_info.get("google", {}).get("task-id-column") - taskid_max = lab_config_dict.get("taskid-max") - ignore_taskid = lab_config_dict.get("ignore-task-id", False) - - if task_id_column_config is not None and taskid_max is not None and not ignore_taskid: - task_id_column = task_id_column_config + 1 - student_order = get_student_order(sheet, row_idx, task_id_column) + status_code = 400 + raise HTTPException(status_code=status_code, detail=outcome.message) - if student_order is not None: - taskid_shift = lab_config_dict.get("taskid-shift", 0) - expected_taskid = calculate_expected_taskid(student_order, taskid_shift, taskid_max) - logger.info(f"Expected TASKID: {expected_taskid} (order={student_order}, shift={taskid_shift}, max={taskid_max})") - - taskid_error = grader.check_taskid( - org, repo_name, - ci_evaluation.successful_runs, - expected_taskid, - ) - if taskid_error: - logger.warning(f"TASKID error: {taskid_error.message}") - raise HTTPException(status_code=400, detail=taskid_error.message) - - # Calculate penalty if deadline configured - # 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) - strategy_name = lab_config_dict.get("penalty-strategy", "weekly") - try: - strategy = PenaltyStrategy(strategy_name) - except ValueError: - strategy = PenaltyStrategy.WEEKLY - - penalty = calculate_penalty( - completed_at=ci_evaluation.latest_success_time, - deadline=deadline, - penalty_max=penalty_max, - strategy=strategy, - ) - - if penalty > 0: - 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}'") + if outcome.status == "pending": + return { + "status": "pending", + "message": outcome.message, + "passed": outcome.passed, + "checks": outcome.checks + } + + if outcome.status == "rejected": response = { "status": "rejected", - "result": current_value, - "message": "⚠️ Работа уже была проверена ранее. Обратитесь к преподавателю для пересдачи.", - "passed": ci_evaluation.grade_result.passed, - "checks": ci_evaluation.grade_result.checks, - "current_grade": current_value + "result": outcome.current_grade, + "message": outcome.message, + "passed": outcome.passed, + "checks": outcome.checks, + "current_grade": outcome.current_grade } - if score_value is not None: - response["score"] = format_score(score_value, decimal_separator) + if outcome.score is not None: + response["score"] = outcome.score 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"Updating cell at row {target['row']}, column {target['col']} with result '{outcome.cell_value}'") + target["sheet"].update_cell(target["row"], target["col"], outcome.cell_value) logger.info(f"Successfully updated grade for '{username}' in lab {lab_id}") response = { "status": "updated", - "result": final_result, - "message": final_message, - "passed": ci_evaluation.grade_result.passed, - "checks": ci_evaluation.grade_result.checks + "result": outcome.cell_value, + "message": outcome.message, + "passed": outcome.passed, + "checks": outcome.checks } - if score_value is not None: - response["score"] = format_score(score_value, decimal_separator) + if outcome.score is not None: + response["score"] = outcome.score return response except HTTPException: raise @@ -897,6 +844,7 @@ def grade_lab(request: Request, course_id: str, group_id: str, lab_id: str, grad raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") + # --------------------------------------------------------------------------- # /join: automatic student repo creation (replaces GitHub Classroom) # See docs/REPO_GENERATION_PLAN.md for the full design. @@ -1192,6 +1140,10 @@ def admin_list_course_labs(request: Request, course_id: str, admin: str = Depend "template_repo": template_repo, "repo_provisioning": repo_provisioning, "can_propagate": bool(template_repo) and repo_provisioning == "fork", + # Bulk grading: candidates for the file holding the student's full + # name, and the one preselected via `student-name-file`. + "files": lab_config.get("files", []), + "name_file": lab_config.get("student-name-file"), }) # Порядок как у преподавателя в таблице: ЛР0, ЛР0.1, ЛР1... Сортировка по @@ -1314,6 +1266,136 @@ def get_propagate_job_status(request: Request, job_id: str, admin: str = Depends return job.to_dict() +# --------------------------------------------------------------------------- +# Admin: bulk grading of a whole group's submissions for one lab. +# Same job machinery as propagate above - a group takes minutes, so the run is +# backgrounded and polled. See docs/PROJECT_DESCRIPTION.md. +# --------------------------------------------------------------------------- + + +class BulkGradeRequest(BaseModel): + # Файл, из первой строки которого берётся ФИО студента. Задан - обходятся + # все репозитории лабы в организации и логины проставляются в таблицу; + # пуст - проверяются только студенты с уже указанным логином. + name_file: str | None = None + # Прогнать все проверки и собрать отчёт, ничего не записывая в таблицу. + dry_run: bool = False + + +def _open_group_worksheet(spreadsheet_id: str, group_id: str): + """ + Open a group's worksheet, returning (spreadsheet, worksheet). + + Opened by the endpoint rather than inside the job, so that a wrong group + fails the request with 404 instead of a job that dies immediately. + """ + scope = ["https://spreadsheets.google.com/feeds", "https://www.googleapis.com/auth/drive"] + creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope) + sheets_client = gspread.authorize(creds) + + try: + spreadsheet = sheets_client.open_by_key(spreadsheet_id) + worksheet = spreadsheet.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 Таблице") + + return spreadsheet, worksheet + + +@app.post("/admin/courses/{course_id}/groups/{group_id}/labs/{lab_id}/bulk-grade") +@limiter.limit("10/minute") +def start_bulk_grade( + request: Request, + course_id: str, + group_id: str, + lab_id: str, + background_tasks: BackgroundTasks, + body: BulkGradeRequest = BulkGradeRequest(), + admin: str = Depends(require_admin), +): + """ + Start grading a whole group for one lab in the background. + + Every student goes through the same checks as a self-submitted work + (grading.bulk.evaluate_student). Unlike propagate's read-only dry run, + dry_run here still runs the full CI checks, so it is a background job too; + it just writes nothing to the spreadsheet. + + Returns 202 with a job_id to poll via GET /admin/bulk-grade-jobs/{job_id}; + only one run per (course_id, group_id, lab_id) at a time - a second POST + while one is in flight gets HTTP 409. + """ + name_file = (body.name_file or "").strip() or None + mode = "by_file" if name_file else "by_sheet" + + course_info = get_course_by_id(course_id) + org = course_info.get("github", {}).get("organization") + spreadsheet_id = course_info.get("google", {}).get("spreadsheet") + + resolved = find_lab_config(course_info.get("labs", {}), lab_id) + lab_key, lab_config_dict = resolved if resolved else (None, {}) + repo_prefix = lab_config_dict.get("github-prefix") + + if not all([org, spreadsheet_id, repo_prefix]): + logger.error( + f"Missing course configuration for {course_id}: org={org}, " + f"spreadsheet={spreadsheet_id}, repo_prefix={repo_prefix}" + ) + raise HTTPException(status_code=400, detail="Missing course configuration") + + spreadsheet, worksheet = _open_group_worksheet(spreadsheet_id, group_id) + + job = try_start_bulk_job(course_id, group_id, lab_id, mode, body.dry_run, name_file) + if job is None: + raise HTTPException( + status_code=409, + detail="Проверка этой лабораторной для этой группы уже выполняется", + ) + + logger.info( + f"Starting bulk grading job {job.job_id} for {course_id}/{group_id}/{lab_id} " + f"(mode={mode}, dry_run={body.dry_run}, admin={admin})" + ) + github_client = GitHubClient(GITHUB_TOKEN) + background_tasks.add_task( + run_bulk_grading, + job, + LabGrader(github_client), + github_client, + worksheet, + spreadsheet, + course_info, + lab_config_dict, + parse_lab_id(lab_key or lab_id), + ) + return JSONResponse(status_code=202, content={"job_id": job.job_id}) + + +@app.get("/admin/bulk-grade-jobs/{job_id}") +@limiter.limit("120/minute") +def get_bulk_grade_job_status(request: Request, job_id: str, admin: str = Depends(require_admin)): + job = get_bulk_job(job_id) + if job is None: + raise HTTPException(status_code=404, detail="Работа не найдена") + return job.to_dict() + + +@app.post("/admin/bulk-grade-jobs/{job_id}/cancel") +@limiter.limit("30/minute") +def cancel_bulk_grade_job(request: Request, job_id: str, admin: str = Depends(require_admin)): + """ + Ask a running bulk grading job to stop. + + It finishes the student it is on, flushes the grades buffered so far and + ends with status "cancelled". + """ + job = request_bulk_job_cancel(job_id) + if job is None: + raise HTTPException(status_code=404, detail="Работа не найдена") + return job.to_dict() + + @app.post("/courses/upload") @limiter.limit("10/minute") async def upload_course(request: Request, file: UploadFile = File(...), admin: str = Depends(require_admin)): From 40bc5d798eb1dcd3ab6eb0039cdb6ab168dda56f Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Tue, 8 Sep 2026 07:23:58 +0300 Subject: [PATCH 2/4] Add tests for bulk grading - tests/test_bulk_grading.py: repo prefix filtering (including the os-task1 / os-task10 collision), name file parsing, name normalization and matching, the GitHub-cell write policy, grid helpers, the shared evaluate_student decision, the job store and the orchestrator (batched writes, dry run, cell protection, cancellation, per-student error isolation, unavailable org repos). - tests/test_github_client.py: get_file_content decoding, BOM, missing file, directory, size limit, non-UTF-8 and encoding "none". - tests/test_admin_endpoints.py: the three bulk routes join PROTECTED_ROUTES, plus 202/409/mode-selection/cancel coverage for the endpoint, and the bulk job store joins the clean_job_store fixture. Co-Authored-By: Claude Opus 5 --- tests/test_admin_endpoints.py | 158 ++++++- tests/test_bulk_grading.py | 812 ++++++++++++++++++++++++++++++++++ tests/test_github_client.py | 92 ++++ 3 files changed, 1061 insertions(+), 1 deletion(-) create mode 100644 tests/test_bulk_grading.py diff --git a/tests/test_admin_endpoints.py b/tests/test_admin_endpoints.py index adee3f9..51239c9 100644 --- a/tests/test_admin_endpoints.py +++ b/tests/test_admin_endpoints.py @@ -12,7 +12,7 @@ import sys import os import yaml -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest import responses @@ -24,7 +24,9 @@ import main as main_module from main import app +from grading.grader import CIEvaluation, GradeResult, GradeStatus from grading.propagate import _jobs, _running_lab_keys +from grading.bulk import _jobs as _bulk_jobs, _running_keys as _bulk_running_keys @pytest.fixture(autouse=True) @@ -41,9 +43,13 @@ def disable_real_rate_limiting(monkeypatch): def clean_job_store(): _jobs.clear() _running_lab_keys.clear() + _bulk_jobs.clear() + _bulk_running_keys.clear() yield _jobs.clear() _running_lab_keys.clear() + _bulk_jobs.clear() + _bulk_running_keys.clear() @pytest.fixture @@ -89,6 +95,13 @@ def admin_course_env(tmp_path, monkeypatch): {"json": {"dry_run": True}}, ), ("GET", "/admin/propagate-jobs/does-not-exist", {}), + ( + "POST", + "/admin/courses/test-course/groups/P3300/labs/1/bulk-grade", + {"json": {"dry_run": True}}, + ), + ("GET", "/admin/bulk-grade-jobs/does-not-exist", {}), + ("POST", "/admin/bulk-grade-jobs/does-not-exist/cancel", {}), ] @@ -156,6 +169,16 @@ def test_admin_labs_returns_200(self, client, admin_course_env): assert response.status_code == 200 assert response.json() == [] + def test_bulk_grade_job_status_returns_404_for_unknown_job_not_401(self, client): + client.cookies.set("admin_session", valid_cookie()) + response = client.get("/admin/bulk-grade-jobs/does-not-exist") + assert response.status_code == 404 + + def test_bulk_grade_job_cancel_returns_404_for_unknown_job_not_401(self, client): + client.cookies.set("admin_session", valid_cookie()) + response = client.post("/admin/bulk-grade-jobs/does-not-exist/cancel") + assert response.status_code == 404 + def test_propagate_job_status_returns_404_for_unknown_job_not_401(self, client): client.cookies.set("admin_session", valid_cookie()) response = client.get("/admin/propagate-jobs/does-not-exist") @@ -340,3 +363,136 @@ def test_unknown_job_status_is_404(self, mock_request): with pytest.raises(HTTPException) as exc_info: main_module.get_propagate_job_status(mock_request, "does-not-exist", admin="admin") assert exc_info.value.status_code == 404 + + +class TestBulkGradeEndpoint: + """The bulk grading endpoint itself: config checks, 202 + job, 409.""" + + @pytest.fixture + def bulk_course_config(self, sample_course_config): + sample_course_config["google"]["spreadsheet"] = "sheet-id" + return sample_course_config + + @pytest.fixture + def mock_worksheet(self): + """Patch the Sheets connection the endpoint opens before starting a job.""" + worksheet = MagicMock() + worksheet.get_all_values.return_value = [ + ["№", "ФИО", "GitHub", ""], + ["", "", "", "ЛР1"], + ["1", "Иванов Иван", "student1", ""], + ] + spreadsheet = MagicMock() + spreadsheet.fetch_sheet_metadata.return_value = {"properties": {"locale": "en_US"}} + with patch.object(main_module, "_open_group_worksheet", return_value=(spreadsheet, worksheet)): + yield worksheet + + def test_missing_spreadsheet_config_is_400(self, mock_request, sample_course_config): + sample_course_config["google"].pop("spreadsheet", None) + with patch("main.get_course_by_id", return_value=sample_course_config): + with pytest.raises(HTTPException) as exc_info: + main_module.start_bulk_grade( + mock_request, "test-course", "P3300", "ЛР1", BackgroundTasks(), + body=main_module.BulkGradeRequest(), admin="admin", + ) + assert exc_info.value.status_code == 400 + + def test_unknown_lab_is_400(self, mock_request, bulk_course_config): + with patch("main.get_course_by_id", return_value=bulk_course_config): + with pytest.raises(HTTPException) as exc_info: + main_module.start_bulk_grade( + mock_request, "test-course", "P3300", "ЛР42", BackgroundTasks(), + body=main_module.BulkGradeRequest(), admin="admin", + ) + assert exc_info.value.status_code == 400 + + def test_returns_202_and_runs_the_job(self, mock_request, bulk_course_config, mock_worksheet): + import json + + with patch("main.get_course_by_id", return_value=bulk_course_config): + bg = BackgroundTasks() + response = main_module.start_bulk_grade( + mock_request, "test-course", "P3300", "ЛР1", bg, + body=main_module.BulkGradeRequest(dry_run=True), admin="admin", + ) + assert response.status_code == 202 + job_id = json.loads(response.body)["job_id"] + + job = main_module.get_bulk_job(job_id) + assert job.mode == "by_sheet" + assert job.dry_run is True + + with patch.object(main_module.LabGrader, "check_repository", return_value=None), \ + patch.object(main_module.LabGrader, "check_forbidden_files", return_value=None), \ + patch.object(main_module.LabGrader, "_evaluate_ci_internal") as evaluate: + evaluate.return_value = CIEvaluation( + grade_result=GradeResult( + status=GradeStatus.UPDATED, result="v", + message="Результат CI: ✅ Все проверки пройдены", passed="1/1", + ), + ci_passed=True, + ) + run_background_tasks(bg) + + assert job.status == "done" + assert [r.github for r in job.results] == ["student1"] + # dry_run: the report is built, the spreadsheet is left alone + mock_worksheet.batch_update.assert_not_called() + + def test_name_file_selects_by_file_mode(self, mock_request, bulk_course_config, mock_worksheet): + import json + + with patch("main.get_course_by_id", return_value=bulk_course_config): + response = main_module.start_bulk_grade( + mock_request, "test-course", "P3300", "ЛР1", BackgroundTasks(), + body=main_module.BulkGradeRequest(name_file="info.md"), admin="admin", + ) + + job = main_module.get_bulk_job(json.loads(response.body)["job_id"]) + assert job.mode == "by_file" + assert job.name_file == "info.md" + + def test_blank_name_file_falls_back_to_by_sheet_mode(self, mock_request, bulk_course_config, mock_worksheet): + import json + + with patch("main.get_course_by_id", return_value=bulk_course_config): + response = main_module.start_bulk_grade( + mock_request, "test-course", "P3300", "ЛР1", BackgroundTasks(), + body=main_module.BulkGradeRequest(name_file=" "), admin="admin", + ) + + job = main_module.get_bulk_job(json.loads(response.body)["job_id"]) + assert job.mode == "by_sheet" + assert job.name_file is None + + def test_second_run_for_same_group_and_lab_is_409(self, mock_request, bulk_course_config, mock_worksheet): + from grading.bulk import try_start_bulk_job + try_start_bulk_job("test-course", "P3300", "ЛР1", "by_sheet", False, None) + + with patch("main.get_course_by_id", return_value=bulk_course_config): + with pytest.raises(HTTPException) as exc_info: + main_module.start_bulk_grade( + mock_request, "test-course", "P3300", "ЛР1", BackgroundTasks(), + body=main_module.BulkGradeRequest(), admin="admin", + ) + assert exc_info.value.status_code == 409 + + def test_other_group_may_start_while_one_runs(self, mock_request, bulk_course_config, mock_worksheet): + from grading.bulk import try_start_bulk_job + try_start_bulk_job("test-course", "P3300", "ЛР1", "by_sheet", False, None) + + with patch("main.get_course_by_id", return_value=bulk_course_config): + response = main_module.start_bulk_grade( + mock_request, "test-course", "P3301", "ЛР1", BackgroundTasks(), + body=main_module.BulkGradeRequest(), admin="admin", + ) + assert response.status_code == 202 + + def test_cancel_marks_a_running_job(self, mock_request): + from grading.bulk import try_start_bulk_job + job = try_start_bulk_job("test-course", "P3300", "ЛР1", "by_sheet", False, None) + + result = main_module.cancel_bulk_grade_job(mock_request, job.job_id, admin="admin") + + assert result["status"] == "running" + assert job.cancel_requested is True diff --git a/tests/test_bulk_grading.py b/tests/test_bulk_grading.py new file mode 100644 index 0000000..dc5edf6 --- /dev/null +++ b/tests/test_bulk_grading.py @@ -0,0 +1,812 @@ +""" +Tests for bulk grading (grading/bulk.py). + +Covers repository discovery, student matching by full name, the shared +grading decision, the background job store and the orchestrator. +""" +import pytest +from datetime import datetime, timedelta +from unittest.mock import MagicMock, patch +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from grading.bulk import ( + BulkGradingError, + BulkJob, + BulkResult, + NameMatchError, + SheetContext, + StudentOutcome, + evaluate_student, + extract_full_name, + filter_lab_repos, + find_row_by_full_name, + normalize_full_name, + repo_name_for, + resolve_github_cell, + run_bulk_grading, + taskid_column, + try_start_bulk_job, + get_bulk_job, + request_bulk_job_cancel, + _jobs, + _running_keys, +) +from grading.grader import GradeResult, GradeStatus, CIEvaluation +from grading.sheets_client import ( + cell_from_grid, + column_values_from_grid, + find_lab_column_in_grid, + get_deadline_from_grid, + get_student_order_from_grid, + parse_deadline, +) + + +class TestFilterLabRepos: + """Selecting a lab's repositories out of an organization.""" + + def test_extracts_username_after_prefix(self): + repos = ["os-task1-alice", "os-task1-bob", "other-repo"] + assert filter_lab_repos(repos, "os-task1") == { + "alice": "os-task1-alice", + "bob": "os-task1-bob", + } + + def test_prefix_collision_with_longer_lab_number(self): + """os-task1 must not swallow os-task10 repositories.""" + repos = ["os-task1-alice", "os-task10-bob", "os-task11-carol"] + + assert filter_lab_repos(repos, "os-task1") == {"alice": "os-task1-alice"} + assert filter_lab_repos(repos, "os-task10") == {"bob": "os-task10-bob"} + + def test_username_with_dashes(self): + assert filter_lab_repos(["os-task1-jane-doe"], "os-task1") == { + "jane-doe": "os-task1-jane-doe" + } + + def test_template_repo_without_username_is_skipped(self): + assert filter_lab_repos(["os-task1", "os-task1-"], "os-task1") == {} + + def test_empty_prefix_matches_nothing(self): + assert filter_lab_repos(["os-task1-alice"], "") == {} + + def test_no_repos(self): + assert filter_lab_repos([], "os-task1") == {} + + +class TestExtractFullName: + """Reading the student's name out of the name file.""" + + def test_first_line(self): + assert extract_full_name("Иванов Иван Иванович\nЛР1\n") == "Иванов Иван Иванович" + + def test_skips_leading_blank_lines(self): + assert extract_full_name("\n\n Петров Пётр \n") == "Петров Пётр" + + def test_crlf_line_endings(self): + assert extract_full_name("Сидоров Сидор\r\nтекст") == "Сидоров Сидор" + + def test_empty_file(self): + assert extract_full_name("") is None + + def test_whitespace_only_file(self): + assert extract_full_name(" \n\t\n") is None + + def test_missing_file(self): + assert extract_full_name(None) is None + + +class TestNormalizeFullName: + """Name normalization used for matching.""" + + def test_collapses_whitespace(self): + assert normalize_full_name("Иванов Иван\tИванович") == "иванов иван иванович" + + def test_case_insensitive(self): + assert normalize_full_name("ИВАНОВ Иван") == normalize_full_name("иванов иван") + + def test_yo_equals_ye(self): + assert normalize_full_name("Алёшин Пётр") == normalize_full_name("Алешин Петр") + + def test_non_breaking_space(self): + assert normalize_full_name("Иванов Иван") == "иванов иван" + + def test_empty(self): + assert normalize_full_name("") == "" + assert normalize_full_name(None) == "" + + +class TestFindRowByFullName: + """Resolving a name to a spreadsheet row.""" + + NAMES = ["Иванов Иван Иванович", "Петров Пётр Петрович", "Сидоров Сидор"] + + def test_exact_match(self): + assert find_row_by_full_name(self.NAMES, "Петров Пётр Петрович") == 4 + + def test_first_row_is_three(self): + """Two header rows precede student data.""" + assert find_row_by_full_name(self.NAMES, "Иванов Иван Иванович") == 3 + + def test_match_after_normalization(self): + assert find_row_by_full_name(self.NAMES, "петров петр петрович") == 4 + + def test_unmatched_raises(self): + with pytest.raises(NameMatchError) as exc: + find_row_by_full_name(self.NAMES, "Неизвестный Студент") + assert exc.value.code == "unmatched" + + def test_empty_name_raises_unmatched(self): + with pytest.raises(NameMatchError) as exc: + find_row_by_full_name(self.NAMES, " ") + assert exc.value.code == "unmatched" + + def test_ambiguous_raises(self): + names = ["Иванов Иван", "Петров Пётр", "Иванов Иван"] + with pytest.raises(NameMatchError) as exc: + find_row_by_full_name(names, "Иванов Иван") + assert exc.value.code == "ambiguous" + assert "3" in exc.value.message and "5" in exc.value.message + + def test_surname_alone_does_not_match(self): + """Fuzzy matching is deliberately not attempted.""" + with pytest.raises(NameMatchError): + find_row_by_full_name(self.NAMES, "Иванов") + + +class TestResolveGithubCell: + """Policy for writing the GitHub username into the sheet.""" + + def test_empty_cell_is_written(self): + assert resolve_github_cell("", "alice") == (True, None) + + def test_whitespace_cell_is_written(self): + assert resolve_github_cell(" ", "alice") == (True, None) + + def test_same_username_is_not_rewritten(self): + assert resolve_github_cell("alice", "alice") == (False, None) + + def test_same_username_different_case(self): + should_write, conflict = resolve_github_cell("Alice", "alice") + assert should_write is False + assert conflict is None + + def test_different_username_is_a_conflict(self): + should_write, conflict = resolve_github_cell("bob", "alice") + assert should_write is False + assert "bob" in conflict and "alice" in conflict + + +class TestGridHelpers: + """In-memory equivalents of the per-cell Sheets helpers.""" + + GRID = [ + ["", "", "", "19.11.2025"], + ["№", "ФИО", "GitHub", "ЛР1"], + ["1", "Иванов Иван", "alice", "v"], + ["2", "Петров Пётр", "bob"], + ] + + def test_cell_from_grid(self): + assert cell_from_grid(self.GRID, 3, 3) == "alice" + + def test_cell_beyond_row_length(self): + """Trailing empty cells are omitted by gspread.""" + assert cell_from_grid(self.GRID, 4, 4) == "" + + def test_cell_beyond_grid(self): + assert cell_from_grid(self.GRID, 99, 1) == "" + assert cell_from_grid(self.GRID, 0, 1) == "" + + def test_column_values_pads_missing_cells(self): + assert column_values_from_grid(self.GRID, 4, start_row=3) == ["v", ""] + + def test_column_values_from_top(self): + assert column_values_from_grid(self.GRID, 3) == ["", "GitHub", "alice", "bob"] + + def test_find_lab_column(self): + assert find_lab_column_in_grid(self.GRID, "ЛР1") == 4 + + def test_find_lab_column_missing(self): + assert find_lab_column_in_grid(self.GRID, "ЛР9") is None + + def test_find_lab_column_empty_name(self): + assert find_lab_column_in_grid(self.GRID, "") is None + + def test_deadline_from_grid(self): + deadline = get_deadline_from_grid(self.GRID, 4, deadline_row=1, timezone_str="UTC+3") + assert deadline.year == 2025 and deadline.month == 11 and deadline.day == 19 + # A date without a time means the end of that day + assert (deadline.hour, deadline.minute) == (23, 59) + assert deadline.tzinfo is not None + + def test_deadline_missing(self): + assert get_deadline_from_grid(self.GRID, 2, deadline_row=1) is None + + def test_student_order_from_grid(self): + assert get_student_order_from_grid(self.GRID, 3, 1) == 1 + assert get_student_order_from_grid(self.GRID, 4, 1) == 2 + + def test_student_order_unparseable(self): + assert get_student_order_from_grid(self.GRID, 2, 1) is None + + def test_parse_deadline_matches_sheet_version(self): + assert parse_deadline("15.03.2025 23:59").hour == 23 + assert parse_deadline("") is None + assert parse_deadline("не дата") is None + + +class TestTaskidColumn: + """When the TASKID check applies.""" + + COURSE = {"google": {"task-id-column": 0}} + + def test_configured(self): + assert taskid_column(self.COURSE, {"taskid-max": 20}) == 1 + + def test_no_column_in_course(self): + assert taskid_column({"google": {}}, {"taskid-max": 20}) is None + + def test_no_taskid_max_in_lab(self): + assert taskid_column(self.COURSE, {}) is None + + def test_ignore_task_id(self): + assert taskid_column(self.COURSE, {"taskid-max": 20, "ignore-task-id": True}) is None + + +class TestRepoNameFor: + def test_builds_conventional_name(self): + assert repo_name_for({"github-prefix": "os-task2"}, "alice") == "os-task2-alice" + + +def _grade_result(status, result=None, message="", error_code=None, passed=None): + return GradeResult( + status=status, + result=result, + message=message, + passed=passed, + error_code=error_code, + ) + + +def _grader_mock(ci_evaluation, repo_error=None, forbidden_error=None, taskid_error=None): + grader = MagicMock() + grader.check_repository.return_value = repo_error + grader.check_forbidden_files.return_value = forbidden_error + grader._evaluate_ci_internal.return_value = ci_evaluation + grader.check_taskid.return_value = taskid_error + return grader + + +class TestEvaluateStudent: + """The grading decision shared by the endpoint and the bulk run.""" + + LAB = {"github-prefix": "os-task1", "short-name": "ЛР1"} + COURSE = {"github": {"organization": "test-org"}, "google": {}} + + def test_passing_ci_gives_v(self): + ci = CIEvaluation( + grade_result=_grade_result(GradeStatus.UPDATED, "v", "Результат CI: ✅ Все проверки пройдены"), + ci_passed=True, + ) + outcome = evaluate_student( + _grader_mock(ci), "test-org", "alice", self.LAB, self.COURSE, + lambda: SheetContext(), + ) + assert outcome.status == "updated" + assert outcome.cell_value == "v" + + def test_failing_ci_gives_x(self): + ci = CIEvaluation( + grade_result=_grade_result(GradeStatus.UPDATED, "x", "Результат CI: ❌ Обнаружены ошибки"), + ci_passed=False, + ) + outcome = evaluate_student( + _grader_mock(ci), "test-org", "alice", self.LAB, self.COURSE, + lambda: SheetContext(), + ) + assert outcome.status == "updated" + assert outcome.cell_value == "x" + + def test_repository_error_short_circuits(self): + repo_error = _grade_result( + GradeStatus.ERROR, message="Нет коммитов в репозитории", error_code="NO_COMMITS" + ) + grader = _grader_mock(None, repo_error=repo_error) + + outcome = evaluate_student( + grader, "test-org", "alice", self.LAB, self.COURSE, lambda: SheetContext(), + ) + assert outcome.status == "error" + assert outcome.error_code == "NO_COMMITS" + grader._evaluate_ci_internal.assert_not_called() + + def test_forbidden_modification_short_circuits(self): + forbidden = _grade_result( + GradeStatus.ERROR, message="🚨 Нельзя изменять test_main.py", + error_code="FORBIDDEN_MODIFICATION", + ) + grader = _grader_mock(None, forbidden_error=forbidden) + + outcome = evaluate_student( + grader, "test-org", "alice", self.LAB, self.COURSE, lambda: SheetContext(), + ) + assert outcome.status == "error" + assert outcome.error_code == "FORBIDDEN_MODIFICATION" + + def test_sheet_context_not_requested_on_error(self): + """Callers may defer opening a Sheets connection until a grade exists.""" + repo_error = _grade_result(GradeStatus.ERROR, message="нет файла", error_code="MISSING_FILES") + provider = MagicMock() + + evaluate_student( + _grader_mock(None, repo_error=repo_error), + "test-org", "alice", self.LAB, self.COURSE, provider, + ) + provider.assert_not_called() + + def test_sheet_context_not_requested_when_pending(self): + ci = CIEvaluation( + grade_result=_grade_result(GradeStatus.PENDING, message="CI-проверки ещё выполняются ⏳"), + ci_passed=False, + ) + provider = MagicMock() + + outcome = evaluate_student( + _grader_mock(ci), "test-org", "alice", self.LAB, self.COURSE, provider, + ) + assert outcome.status == "pending" + provider.assert_not_called() + + def test_protected_cell_is_rejected(self): + ci = CIEvaluation( + grade_result=_grade_result(GradeStatus.UPDATED, "v", "ok"), + ci_passed=True, + ) + outcome = evaluate_student( + _grader_mock(ci), "test-org", "alice", self.LAB, self.COURSE, + lambda: SheetContext(current_cell_value="v"), + ) + assert outcome.status == "rejected" + assert outcome.current_grade == "v" + + def test_x_in_cell_can_be_overwritten(self): + ci = CIEvaluation( + grade_result=_grade_result(GradeStatus.UPDATED, "v", "ok"), + ci_passed=True, + ) + outcome = evaluate_student( + _grader_mock(ci), "test-org", "alice", self.LAB, self.COURSE, + lambda: SheetContext(current_cell_value="x"), + ) + assert outcome.status == "updated" + + def test_penalty_applied_for_late_submission(self): + deadline = datetime(2025, 3, 15, 23, 59) + ci = CIEvaluation( + grade_result=_grade_result(GradeStatus.UPDATED, "v", "ok"), + ci_passed=True, + latest_success_time=deadline + timedelta(days=8), + ) + lab = dict(self.LAB, **{"penalty-max": 9}) + + outcome = evaluate_student( + _grader_mock(ci), "test-org", "alice", lab, self.COURSE, + lambda: SheetContext(deadline=deadline), + ) + assert outcome.cell_value == "v-2" + assert "штраф" in outcome.message + + def test_score_formatted_with_sheet_separator(self): + ci = CIEvaluation( + grade_result=_grade_result(GradeStatus.UPDATED, "v", "ok"), + ci_passed=True, + score="10.5", + ) + outcome = evaluate_student( + _grader_mock(ci), "test-org", "alice", self.LAB, self.COURSE, + lambda: SheetContext(decimal_separator=","), + ) + assert outcome.cell_value == "v@10,5" + assert outcome.score == "10,5" + + def test_taskid_checked_when_configured(self): + ci = CIEvaluation( + grade_result=_grade_result(GradeStatus.UPDATED, "v", "ok"), + ci_passed=True, + ) + taskid_error = _grade_result( + GradeStatus.ERROR, message="Неверный вариант", error_code="WRONG_TASKID" + ) + grader = _grader_mock(ci, taskid_error=taskid_error) + course = {"github": {"organization": "test-org"}, "google": {"task-id-column": 0}} + lab = dict(self.LAB, **{"taskid-max": 20, "taskid-shift": 4}) + + outcome = evaluate_student( + grader, "test-org", "alice", lab, course, + lambda: SheetContext(student_order=3), + ) + assert outcome.status == "error" + assert outcome.error_code == "WRONG_TASKID" + grader.check_taskid.assert_called_once() + + def test_taskid_not_checked_when_ignored(self): + ci = CIEvaluation( + grade_result=_grade_result(GradeStatus.UPDATED, "v", "ok"), + ci_passed=True, + ) + grader = _grader_mock(ci) + course = {"github": {"organization": "test-org"}, "google": {"task-id-column": 0}} + lab = dict(self.LAB, **{"taskid-max": 20, "ignore-task-id": True}) + + evaluate_student( + grader, "test-org", "alice", lab, course, + lambda: SheetContext(student_order=3), + ) + grader.check_taskid.assert_not_called() + + def test_no_taskid_check_when_ci_failed(self): + ci = CIEvaluation( + grade_result=_grade_result(GradeStatus.UPDATED, "x", "ошибки"), + ci_passed=False, + ) + grader = _grader_mock(ci) + course = {"github": {"organization": "test-org"}, "google": {"task-id-column": 0}} + lab = dict(self.LAB, **{"taskid-max": 20}) + + outcome = evaluate_student( + grader, "test-org", "alice", lab, course, + lambda: SheetContext(student_order=3), + ) + assert outcome.cell_value == "x" + grader.check_taskid.assert_not_called() + + +@pytest.fixture(autouse=True) +def clean_job_store(): + """Same isolation as tests/test_admin_endpoints.py does for propagate jobs.""" + _jobs.clear() + _running_keys.clear() + yield + _jobs.clear() + _running_keys.clear() + + +class TestBulkJobStore: + """Lifecycle of background jobs, mirroring propagate's job store.""" + + def test_start_and_get(self): + job = try_start_bulk_job("c", "g", "ЛР1", "by_sheet", False, None) + + assert get_bulk_job(job.job_id) is job + assert job.status == "running" + assert job.started_at + + def test_get_unknown_job(self): + assert get_bulk_job("nope") is None + + def test_second_job_for_same_group_and_lab_is_refused(self): + first = try_start_bulk_job("c", "g", "ЛР1", "by_sheet", False, None) + + assert try_start_bulk_job("c", "g", "ЛР1", "by_sheet", False, None) is None + assert first.status == "running" + + def test_other_group_or_lab_may_run_concurrently(self): + try_start_bulk_job("c", "g", "ЛР1", "by_sheet", False, None) + + assert try_start_bulk_job("c", "g2", "ЛР1", "by_sheet", False, None) is not None + assert try_start_bulk_job("c", "g", "ЛР2", "by_sheet", False, None) is not None + + def test_finished_job_releases_the_slot(self, bulk_setup): + job = try_start_bulk_job("c", "g", "ЛР1", "by_sheet", False, None) + _run(job, bulk_setup) + + assert job.status == "done" + assert try_start_bulk_job("c", "g", "ЛР1", "by_sheet", False, None) is not None + + def test_request_cancel(self): + job = try_start_bulk_job("c", "g", "ЛР1", "by_sheet", False, None) + + assert request_bulk_job_cancel(job.job_id) is job + assert job.cancel_requested is True + + def test_request_cancel_unknown_job(self): + assert request_bulk_job_cancel("nope") is None + + def test_to_dict_counts_statuses(self): + job = BulkJob(job_id="j", course_id="c", group_id="g", lab_id="ЛР1", mode="by_sheet") + job.results = [ + BulkResult(status="updated"), BulkResult(status="updated"), + BulkResult(status="error"), + ] + + payload = job.to_dict() + assert payload["counts"] == {"updated": 2, "error": 1} + assert len(payload["results"]) == 3 + + +@pytest.fixture +def bulk_setup(): + """Worksheet, spreadsheet and configs for orchestrator tests.""" + # Row 1 carries the left-hand headers and the lab deadline, row 2 the lab + # short name; student data starts at row 3 - as grade_lab reads them. + grid = [ + ["№", "ФИО", "GitHub", ""], + ["", "", "", "ЛР1"], + ["1", "Иванов Иван", "alice", ""], + ["2", "Петров Пётр", "bob", ""], + ["3", "Сидоров Сидор", "", ""], + ] + + worksheet = MagicMock() + worksheet.get_all_values.return_value = grid + + spreadsheet = MagicMock() + spreadsheet.fetch_sheet_metadata.return_value = {"properties": {"locale": "en_US"}} + + return { + "grid": grid, + "worksheet": worksheet, + "spreadsheet": spreadsheet, + "course_info": { + "github": {"organization": "test-org"}, + "google": {"spreadsheet": "sid", "student-name-column": 1}, + }, + "lab_config": {"github-prefix": "os-task1", "short-name": "ЛР1"}, + } + + +def _passing_grader(): + ci = CIEvaluation( + grade_result=_grade_result(GradeStatus.UPDATED, "v", "Результат CI: ✅ Все проверки пройдены"), + ci_passed=True, + ) + return _grader_mock(ci) + + +def _job(mode="by_sheet", dry_run=False, name_file=None): + return BulkJob( + job_id="j", course_id="c", group_id="g", lab_id="ЛР1", + mode=mode, dry_run=dry_run, name_file=name_file, + ) + + +def _run(job, setup, grader=None, github_client=None): + run_bulk_grading( + job, + grader or _passing_grader(), + github_client or MagicMock(), + setup["worksheet"], + setup["spreadsheet"], + setup["course_info"], + setup["lab_config"], + 1, + ) + + +def _written_cells(worksheet): + """Collect (range, value) pairs from every batch_update call.""" + written = [] + for call in worksheet.batch_update.call_args_list: + for entry in call.args[0]: + written.append((entry["range"], entry["values"][0][0])) + return written + + +class TestRunBulkGradingBySheet: + """Mode with an empty name file: grade whoever has a username.""" + + def test_grades_students_with_usernames_only(self, bulk_setup): + job = _job() + _run(job, bulk_setup) + + assert job.status == "done" + assert job.total == 2 # Сидоров has no GitHub username + assert job.processed == 2 + assert [r.github for r in job.results] == ["alice", "bob"] + + def test_writes_grades_to_the_lab_column(self, bulk_setup): + job = _job() + _run(job, bulk_setup) + + assert _written_cells(bulk_setup["worksheet"]) == [("D3", "v"), ("D4", "v")] + + def test_dry_run_writes_nothing(self, bulk_setup): + job = _job(dry_run=True) + _run(job, bulk_setup) + + assert job.status == "done" + assert [r.status for r in job.results] == ["updated", "updated"] + bulk_setup["worksheet"].batch_update.assert_not_called() + + def test_protected_cell_is_reported_not_overwritten(self, bulk_setup): + bulk_setup["grid"][2][3] = "v@8" + job = _job() + _run(job, bulk_setup) + + assert job.results[0].status == "rejected" + assert job.results[0].grade == "v@8" + assert _written_cells(bulk_setup["worksheet"]) == [("D4", "v")] + + def test_missing_github_column_fails_the_job(self, bulk_setup): + bulk_setup["grid"][0][2] = "Гитхаб" + job = _job() + _run(job, bulk_setup) + + assert job.status == "failed" + assert "GitHub" in job.error + assert job.finished_at + + def test_missing_lab_column_fails_the_job(self, bulk_setup): + bulk_setup["lab_config"]["short-name"] = "ЛР9" + job = _job() + _run(job, bulk_setup) + + assert job.status == "failed" + assert "ЛР9" in job.error + + def test_lab_without_short_name_falls_back_to_column_offset(self, bulk_setup): + del bulk_setup["lab_config"]["short-name"] + bulk_setup["course_info"]["google"]["lab-column-offset"] = 3 + job = _job() + _run(job, bulk_setup) + + # offset 3 + lab number 1 = column 4 = D + assert _written_cells(bulk_setup["worksheet"]) == [("D3", "v"), ("D4", "v")] + + def test_error_on_one_student_does_not_stop_the_run(self, bulk_setup): + grader = _passing_grader() + grader.check_repository.side_effect = [ + _grade_result(GradeStatus.ERROR, message="Нет коммитов", error_code="NO_COMMITS"), + None, + ] + job = _job() + _run(job, bulk_setup, grader=grader) + + assert job.status == "done" + assert [r.status for r in job.results] == ["error", "updated"] + assert _written_cells(bulk_setup["worksheet"]) == [("D4", "v")] + + def test_unexpected_exception_is_reported_per_student(self, bulk_setup): + grader = _passing_grader() + grader.check_repository.side_effect = [RuntimeError("boom"), None] + job = _job() + _run(job, bulk_setup, grader=grader) + + assert job.status == "done" + assert job.results[0].status == "error" + assert "boom" in job.results[0].message + + def test_cancellation_stops_and_flushes(self, bulk_setup): + job = _job() + job.cancel_requested = True + _run(job, bulk_setup) + + assert job.status == "cancelled" + assert job.processed == 0 + bulk_setup["worksheet"].batch_update.assert_not_called() + + def test_deadline_and_penalty_come_from_the_grid(self, bulk_setup): + bulk_setup["grid"][0][3] = "15.03.2025" # deadline sits above the lab header + bulk_setup["lab_config"]["penalty-max"] = 9 + bulk_setup["course_info"]["timezone"] = "UTC+3" + + from datetime import timezone as tz + late = datetime(2025, 3, 30, 12, 0, tzinfo=tz(timedelta(hours=3))) + ci = CIEvaluation( + grade_result=_grade_result(GradeStatus.UPDATED, "v", "ok"), + ci_passed=True, + latest_success_time=late, + ) + job = _job() + _run(job, bulk_setup, grader=_grader_mock(ci)) + + assert job.results[0].grade == "v-3" + + +class TestRunBulkGradingByFile: + """Mode with a name file: discover repos and match students by name.""" + + def _github_client(self, repo_names, files): + client = MagicMock() + client.list_org_repos.return_value = [{"name": name} for name in repo_names] + client.get_file_content.side_effect = lambda org, repo, path: files.get(repo) + return client + + def test_registers_username_and_grades(self, bulk_setup): + client = self._github_client( + ["os-task1-carol"], {"os-task1-carol": "Сидоров Сидор\nЛР1"} + ) + job = _job(mode="by_file", name_file="info.md") + _run(job, bulk_setup, github_client=client) + + assert job.status == "done" + assert job.results[0].status == "updated" + assert job.results[0].registered is True + written = _written_cells(bulk_setup["worksheet"]) + assert ("C5", "carol") in written + assert ("D5", "v") in written + + def test_existing_matching_username_is_not_rewritten(self, bulk_setup): + client = self._github_client(["os-task1-alice"], {"os-task1-alice": "Иванов Иван"}) + job = _job(mode="by_file", name_file="info.md") + _run(job, bulk_setup, github_client=client) + + assert job.results[0].registered is False + assert _written_cells(bulk_setup["worksheet"]) == [("D3", "v")] + + def test_conflicting_username_is_reported_and_not_graded(self, bulk_setup): + """The row already names a different account, so nothing is touched.""" + client = self._github_client(["os-task1-mallory"], {"os-task1-mallory": "Иванов Иван"}) + job = _job(mode="by_file", name_file="info.md") + _run(job, bulk_setup, github_client=client) + + assert job.results[0].status == "conflict" + assert job.total == 1 and job.processed == 1 + bulk_setup["worksheet"].batch_update.assert_not_called() + + def test_unknown_name_is_reported(self, bulk_setup): + client = self._github_client(["os-task1-dave"], {"os-task1-dave": "Неизвестный Студент"}) + job = _job(mode="by_file", name_file="info.md") + _run(job, bulk_setup, github_client=client) + + assert job.results[0].status == "unmatched" + assert "Неизвестный Студент" in job.results[0].message + + def test_missing_name_file_is_reported(self, bulk_setup): + client = self._github_client(["os-task1-dave"], {}) + job = _job(mode="by_file", name_file="info.md") + _run(job, bulk_setup, github_client=client) + + assert job.results[0].status == "unmatched" + assert "info.md" in job.results[0].message + + def test_repos_of_other_labs_are_ignored(self, bulk_setup): + client = self._github_client( + ["os-task1-alice", "os-task10-bob", "unrelated"], + {"os-task1-alice": "Иванов Иван"}, + ) + job = _job(mode="by_file", name_file="info.md") + _run(job, bulk_setup, github_client=client) + + assert job.total == 1 + assert [r.github for r in job.results] == ["alice"] + + def test_unavailable_org_repos_fail_the_job(self, bulk_setup): + client = MagicMock() + client.list_org_repos.return_value = None + job = _job(mode="by_file", name_file="info.md") + _run(job, bulk_setup, github_client=client) + + assert job.status == "failed" + assert "репозиториев организации" in job.error + + def test_dry_run_does_not_register_usernames(self, bulk_setup): + client = self._github_client(["os-task1-carol"], {"os-task1-carol": "Сидоров Сидор"}) + job = _job(mode="by_file", dry_run=True, name_file="info.md") + _run(job, bulk_setup, github_client=client) + + assert job.results[0].registered is True + bulk_setup["worksheet"].batch_update.assert_not_called() + + def test_ambiguous_name_is_reported(self, bulk_setup): + bulk_setup["grid"][4][1] = "Иванов Иван" # duplicate name + client = self._github_client(["os-task1-dave"], {"os-task1-dave": "Иванов Иван"}) + job = _job(mode="by_file", name_file="info.md") + _run(job, bulk_setup, github_client=client) + + assert job.results[0].status == "ambiguous" + + def test_planning_failures_count_toward_progress(self, bulk_setup): + client = self._github_client( + ["os-task1-alice", "os-task1-dave"], + {"os-task1-alice": "Иванов Иван", "os-task1-dave": "Кто-то Другой"}, + ) + job = _job(mode="by_file", name_file="info.md") + _run(job, bulk_setup, github_client=client) + + assert job.total == 2 + assert job.processed == 2 diff --git a/tests/test_github_client.py b/tests/test_github_client.py index db0f5d3..72b73f2 100644 --- a/tests/test_github_client.py +++ b/tests/test_github_client.py @@ -425,6 +425,98 @@ def test_single_short_page_stops_after_one_request(self): assert call.call_count == 1 +class TestGitHubClientGetFileContent: + """Tests for get_file_content method (used by bulk grading).""" + + URL = "https://api.github.com/repos/test-org/test-repo/contents/info.md" + + def _file_payload(self, text, **overrides): + import base64 + payload = { + "type": "file", + "encoding": "base64", + "size": len(text.encode("utf-8")), + "content": base64.b64encode(text.encode("utf-8")).decode(), + } + payload.update(overrides) + return payload + + @responses.activate + def test_decodes_utf8(self): + """Cyrillic content comes back intact.""" + responses.add( + responses.GET, self.URL, + json=self._file_payload("Иванов Иван\nЛР1"), + status=200 + ) + client = GitHubClient("test_token") + assert client.get_file_content("test-org", "test-repo", "info.md") == "Иванов Иван\nЛР1" + + @responses.activate + def test_strips_bom(self): + """A UTF-8 BOM must not end up glued to the student's surname.""" + responses.add( + responses.GET, self.URL, + json=self._file_payload("\ufeffИванов Иван"), + status=200 + ) + client = GitHubClient("test_token") + assert client.get_file_content("test-org", "test-repo", "info.md") == "Иванов Иван" + + @responses.activate + def test_missing_file(self): + """A missing file returns None.""" + responses.add(responses.GET, self.URL, json={"message": "Not Found"}, status=404) + client = GitHubClient("test_token") + assert client.get_file_content("test-org", "test-repo", "info.md") is None + + @responses.activate + def test_directory_returns_none(self): + """A directory path answers with a list of entries, not content.""" + responses.add(responses.GET, self.URL, json=[{"name": "a.md"}], status=200) + client = GitHubClient("test_token") + assert client.get_file_content("test-org", "test-repo", "info.md") is None + + @responses.activate + def test_oversized_file_is_skipped(self): + """Files above the size limit are not decoded.""" + responses.add( + responses.GET, self.URL, + json=self._file_payload("Иванов Иван", size=10 * 1024 * 1024), + status=200 + ) + client = GitHubClient("test_token") + assert client.get_file_content("test-org", "test-repo", "info.md") is None + + @responses.activate + def test_unsupported_encoding_returns_none(self): + """GitHub omits the body of large files, answering encoding "none".""" + responses.add( + responses.GET, self.URL, + json={"type": "file", "encoding": "none", "size": 4, "content": ""}, + status=200 + ) + client = GitHubClient("test_token") + assert client.get_file_content("test-org", "test-repo", "info.md") is None + + @responses.activate + def test_non_utf8_content_returns_none(self): + """Binary content that is not valid UTF-8 returns None, never raises.""" + import base64 + responses.add( + responses.GET, self.URL, + json={ + "type": "file", + "encoding": "base64", + "size": 4, + "content": base64.b64encode(b"\xff\xfe\x00\x01").decode(), + }, + status=200 + ) + client = GitHubClient("test_token") + assert client.get_file_content("test-org", "test-repo", "info.md") is None + + class TestGitHubClientCreatePullRequest: """Tests for create_pull_request method.""" From 851fcbc200cee523f511d2cdc93abf310106a447 Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Tue, 8 Sep 2026 07:26:02 +0300 Subject: [PATCH 3/4] Add bulk grading to the admin lab list page The teacher is already picking a course and a lab there for template updates, so the run starts from the same table: a second action per row opens a dialog that asks for the group (labs are per course, the sheet is per group) and the file holding the student's full name, with the lab's required files offered as suggestions and student-name-file preselected. The dialog then shows progress and the per-student report, polling the job every 2s like the propagate dialog does, with a stop button while the run is going. Co-Authored-By: Claude Opus 5 --- .../admin/LabList/BulkGradeDialog.jsx | 278 ++++++++++++++++++ .../src/components/admin/LabList/index.jsx | 21 ++ .../src/locales/en/translation.json | 40 +++ .../src/locales/ru/translation.json | 40 +++ .../src/locales/zh/translation.json | 40 +++ 5 files changed, 419 insertions(+) create mode 100644 frontend/courses-front/src/components/admin/LabList/BulkGradeDialog.jsx diff --git a/frontend/courses-front/src/components/admin/LabList/BulkGradeDialog.jsx b/frontend/courses-front/src/components/admin/LabList/BulkGradeDialog.jsx new file mode 100644 index 0000000..c0cb424 --- /dev/null +++ b/frontend/courses-front/src/components/admin/LabList/BulkGradeDialog.jsx @@ -0,0 +1,278 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { + Autocomplete, + Checkbox, + Chip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + FormControlLabel, + LinearProgress, + MenuItem, + Table, + TableHead, + TableBody, + TableRow, + TableCell, + TextField, + Button as MuiButton, +} from "@mui/material"; +import { fetchGroups } from "../../../api"; +import { TableWrapper, HintText, StatusChipRow } from "./styled"; + +const JOB_POLL_INTERVAL_MS = 2000; + +const RESULT_STATUS_COLOR = { + updated: "success", + rejected: "warning", + pending: "info", + error: "error", + conflict: "error", + unmatched: "warning", + ambiguous: "warning", +}; + +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 BulkGradeDialog = ({ courseId, lab, onClose, onError }) => { + const { t } = useTranslation(); + + const [groups, setGroups] = useState([]); + const [groupId, setGroupId] = useState(""); + const [nameFile, setNameFile] = useState(lab.name_file || ""); + const [dryRun, setDryRun] = useState(false); + const [starting, setStarting] = useState(false); + + const [job, setJob] = useState(null); + const pollRef = useRef(null); + + useEffect(() => { + fetchGroups(courseId) + .then(setGroups) + .catch((err) => onError(err.message || t("adminLabs.bulk.errors.groupsFailed"))); + }, [courseId, onError, t]); + + const stopPolling = () => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }; + + useEffect(() => stopPolling, []); + + const pollJob = useCallback((jobId) => { + stopPolling(); + const poll = () => { + fetchJson(`/api/v1/admin/bulk-grade-jobs/${jobId}`) + .then((data) => { + setJob(data); + if (data.status !== "running") { + stopPolling(); + } + }) + .catch(() => { + stopPolling(); + }); + }; + poll(); + pollRef.current = setInterval(poll, JOB_POLL_INTERVAL_MS); + }, []); + + const handleStart = () => { + setStarting(true); + fetchJson( + `/api/v1/admin/courses/${courseId}/groups/${encodeURIComponent(groupId)}/labs/${encodeURIComponent(lab.id)}/bulk-grade`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name_file: nameFile || null, dry_run: dryRun }), + } + ) + .then((data) => { + setStarting(false); + setJob({ job_id: data.job_id, status: "running", total: 0, processed: 0, results: [] }); + pollJob(data.job_id); + }) + .catch((err) => { + setStarting(false); + if (err.status === 409) { + onError(t("adminLabs.bulk.errors.alreadyRunning")); + } else { + onError(err.message || t("adminLabs.bulk.errors.startFailed")); + } + }); + }; + + const handleCancelJob = () => { + if (!job) return; + fetchJson(`/api/v1/admin/bulk-grade-jobs/${job.job_id}/cancel`, { method: "POST" }) + .then(setJob) + .catch((err) => onError(err.message || t("adminLabs.bulk.errors.cancelFailed"))); + }; + + const handleClose = () => { + stopPolling(); + onClose(); + }; + + const running = job && job.status === "running"; + const results = (job && job.results) || []; + + return ( + + {t("adminLabs.bulk.title", { lab: lab.short_name })} + + {!job && ( + <> + setGroupId(e.target.value)} + disabled={groups.length === 0} + > + {groups.map((group) => ( + + {group} + + ))} + + + setNameFile(value || "")} + onInputChange={(_, value) => setNameFile(value || "")} + renderInput={(params) => ( + + )} + /> + + + {nameFile ? t("adminLabs.bulk.modeByFile") : t("adminLabs.bulk.modeBySheet")} + + + setDryRun(e.target.checked)} /> + } + label={t("adminLabs.bulk.dryRun")} + /> + + )} + + {job && ( + <> +

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

+ + {job.dry_run && } + + {running && ( + + )} + + {job.counts && Object.keys(job.counts).length > 0 && ( + + {Object.entries(job.counts).map(([status, count]) => ( + + ))} + + )} + + {results.length > 0 && ( + + + + + {t("adminLabs.bulk.columns.student")} + {t("adminLabs.bulk.columns.github")} + {t("adminLabs.bulk.columns.status")} + {t("adminLabs.bulk.columns.grade")} + {t("adminLabs.bulk.columns.message")} + + + + {results.map((r, index) => ( + + {r.student_name || "—"} + + {r.github || "—"} + {r.registered && ` (${t("adminLabs.bulk.registered")})`} + + + + + {r.grade || "—"} + {r.message} + + ))} + +
+
+ )} + + )} +
+ + {running ? ( + + {t("adminLabs.bulk.cancelRun")} + + ) : ( + {t("adminLabs.bulk.close")} + )} + {!job && ( + + {t("adminLabs.bulk.start")} + + )} + +
+ ); +}; diff --git a/frontend/courses-front/src/components/admin/LabList/index.jsx b/frontend/courses-front/src/components/admin/LabList/index.jsx index 5c55d53..f7101aa 100644 --- a/frontend/courses-front/src/components/admin/LabList/index.jsx +++ b/frontend/courses-front/src/components/admin/LabList/index.jsx @@ -18,6 +18,7 @@ import { Button as MuiButton, Checkbox, } from "@mui/material"; +import { BulkGradeDialog } from "./BulkGradeDialog"; import { Container, Panel, @@ -76,6 +77,9 @@ export const LabList = ({ courseId, onBack }) => { const [job, setJob] = useState(null); const pollRef = useRef(null); + // Лаба, для которой открыт диалог массовой проверки (независим от рассылки) + const [bulkLab, setBulkLab] = useState(null); + const showSnackbar = (message, severity = "info") => setSnackbar({ open: true, message, severity }); const loadLabs = useCallback(() => { @@ -252,6 +256,14 @@ export const LabList = ({ courseId, onBack }) => { )} + setBulkLab(lab)} + > + {t("adminLabs.bulk.button")} + ))} @@ -401,6 +413,15 @@ export const LabList = ({ courseId, onBack }) => { + {bulkLab && ( + setBulkLab(null)} + onError={(message) => showSnackbar(message, "error")} + /> + )} + Date: Tue, 8 Sep 2026 07:31:50 +0300 Subject: [PATCH 4/4] Document bulk grading - docs/PROJECT_DESCRIPTION.md: the two modes, name matching rules, the GitHub-cell write policy, cell protection, dry run, and the job/quota details; the three new routes join the admin table. - docs/COURSE_CONFIG.md: the student-name-file lab key. - CLAUDE.md: where the shared grading decision lives and the endpoints. - docs/BULK_GRADING_PLAN.md: rewritten as a record of the decisions behind the feature rather than a work plan, and trimmed to what PROJECT_DESCRIPTION does not already say. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 20 +++++++++++++ docs/BULK_GRADING_PLAN.md | 57 +++++++++++++++++++++++++++++++++++++ docs/COURSE_CONFIG.md | 15 ++++++++++ docs/PROJECT_DESCRIPTION.md | 38 ++++++++++++++++++++++++- 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 docs/BULK_GRADING_PLAN.md diff --git a/CLAUDE.md b/CLAUDE.md index 219fa2c..291273a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,7 @@ FRONTEND_URL=http://localhost:8080 | Task | Location | |------|----------| | Add API endpoint | `main.py` | +| Change grading logic | `grading/bulk.py` (`evaluate_student`, shared by single and bulk grading) | | Add React component | `frontend/courses-front/src/components/` | | Add/edit course | `courses/` directory + `index.yaml` | | Add translation | `frontend/courses-front/src/locales/{en,ru,zh}/` | @@ -118,6 +119,25 @@ Orchestration lives in `grading/propagate.py` (in-memory job state, single-worke `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`. +## Bulk Grading (admin) + +Grades a whole group for one lab in a single run, started from the admin lab list page +(`/admin/courses/{course_id}/labs`) - see `docs/PROJECT_DESCRIPTION.md` for the full behaviour. + +- `grading/bulk.py:evaluate_student` holds the grading decision and is shared by `grade_lab` and the + bulk run, so the two cannot drift apart. It never touches Sheets: the spreadsheet context arrives + through a lazily-invoked provider, which is what lets `grade_lab` still answer repository and CI + errors without opening a Sheets connection. +- Endpoints: `POST /admin/courses/{id}/groups/{g}/labs/{l}/bulk-grade` (202 + `job_id`), + `GET /admin/bulk-grade-jobs/{job_id}`, `POST /admin/bulk-grade-jobs/{job_id}/cancel`. +- Job state mirrors `grading/propagate.py` (in-memory, single-worker backend required, 409 on a + second run for the same course/group/lab). +- With `name_file` set, repos are discovered by the lab's prefix and matched to sheet rows by the + first line of that file; without it, only students who already have a username in the sheet are + graded. Name matching is exact after normalization - no fuzzy matching, by design. +- Reads the worksheet once via `get_all_values()` and writes grades in batches of 10: the per-cell + helpers spend ~6 Sheets requests per student, over the 60 reads/minute quota for a group of 30. + ## CI/CD - **Tests**: Run on every push via `.github/workflows/tests.yml` diff --git a/docs/BULK_GRADING_PLAN.md b/docs/BULK_GRADING_PLAN.md new file mode 100644 index 0000000..a3ece92 --- /dev/null +++ b/docs/BULK_GRADING_PLAN.md @@ -0,0 +1,57 @@ +# Массовая проверка лабораторных: разбор решений + +Запись принятых решений по фиче. Поведение и API описаны в `docs/PROJECT_DESCRIPTION.md` (раздел «Массовая проверка лабораторной»); здесь - только то, почему сделано именно так. + +## 1. Постановка задачи + +Преподаватель выбирает курс, группу и лабораторную и нажимает кнопку. Анализируются репозитории лабы в организации курса, результат проверки проставляется в Google Таблицу. Необязательное поле «файл с ФИО» переключает режим: с ним обходятся все репозитории лабы и GitHub-логины проставляются в таблицу по ФИО из файла, без него проверяются только студенты с уже указанным логином. + +## 2. Одно ядро для одиночной и массовой проверки + +`grade_lab` (main.py) - функция на 240 строк, в которой перемешаны HTTP-слой, поячеечное чтение Google Таблицы и логика выставления оценки. Массовая проверка не может переиспользовать её как есть: ей нужен тот же алгоритм без `HTTPException` и без обращений к Sheets на каждого студента. + +Дублировать алгоритм нельзя - разойдутся. Поэтому решение об оценке вынесено в `grading/bulk.py:evaluate_student`, а `grade_lab` переписан в тонкую обёртку над ним. + +### Почему контекст таблицы передаётся через провайдер + +`grade_lab` намеренно устроен так, что ошибки репозитория и CI возвращаются **до** подключения к Google Sheets: при отсутствии коммитов или незавершённом CI соединение с таблицей не открывается вовсе. Если бы `evaluate_student` принимала готовый `SheetContext`, вызывающему пришлось бы читать таблицу заранее - и это свойство потерялось бы. + +Поэтому `evaluate_student` принимает не значение, а функцию, возвращающую `SheetContext`, и вызывает её не раньше, чем CI даст результат, который есть смысл записывать. `grade_lab` передаёт замыкание, открывающее лист; массовая проверка - функцию, отдающую уже прочитанную строку сетки. + +## 3. Чтение таблицы одним запросом + +Квота Google Sheets API - 60 запросов чтения в минуту. Поячеечные хелперы (`sheet.cell`, `row_values`, `col_values`, `worksheet.find`) тратят ~6 запросов на студента: на группе из 30 человек это ~180 запросов и гарантированное превышение квоты. + +Массовый режим читает лист один раз через `get_all_values()` и ищет по сетке в памяти. В `grading/sheets_client.py` добавлены grid-варианты существующих хелперов; поячеечные не тронуты, ими продолжает пользоваться `grade_lab`. Парсинг дедлайна вынесен в общую `parse_deadline`, чтобы обе ветки разбирали даты одинаково. + +Запись - пакетами по 10 ячеек **по ходу работы**, а не одним блоком в конце: падение, перезапуск или отмена сохраняют уже проставленные оценки. + +## 4. Сопоставление ФИО - только точное + +ФИО берётся из файла, который студент заполнял руками, поэтому сравнение нормализует пробелы (включая неразрывные), регистр и `ё`/`е`. Дальше - только точное совпадение. + +Нечёткий подбор (по фамилии, по расстоянию Левенштейна) сознательно не реализован: цена ошибки - оценка, записанная не тому студенту, и обнаружится она нескоро. Несопоставленные репозитории попадают в отчёт с прочитанным ФИО, чтобы преподаватель исправил вручную - это дешевле, чем разбирать последствия неверного совпадения. + +По той же причине совпадение ФИО у двух студентов - не повод выбрать первого: строка пропускается со статусом `ambiguous`. + +## 5. Конфликт GitHub-логинов + +Если в строке студента уже указан **другой** логин, репозиторий не проверяется ни под одним из них и попадает в отчёт со статусом `conflict`. Молча перезаписать логин нельзя (потеряется факт, что студент регистрировался сам), а проверить репозиторий под логином из строки - значит проверить чужую работу. + +## 6. Защита оценок + +Правило `can_overwrite_cell` (перезаписываются только пустые ячейки, `x` и значения с `?` в начале) действует и здесь. Флага принудительной перезаписи нет: одна случайная галочка затирала бы оценки всей группы, а пересдача - штучная операция, для которой достаточно очистить ячейку в таблице. + +## 7. Последовательная обработка + +Параллелизм не вводится. GitHub применяет secondary rate limits к всплескам параллельных запросов от одного токена, а работа и так фоновая с прогресс-баром: выигрыш во времени не стоит риска упереться в лимит на середине группы. + +## 8. Границы отказа + +Ошибка на одном студенте (нет коммитов, неверный вариант, недоступный репозиторий, неожиданное исключение) записывается в его строку отчёта и не останавливает работу. Останавливают только сбои, общие для всего запуска: нет столбца `GitHub` или столбца лабы, недоступен список репозиториев организации - продолжать после них бессмысленно. + +## 9. Где живёт интерфейс + +Отдельный экран не заводился: преподаватель уже выбирает курс и лабораторную на странице лабораторных в админке (`/admin/courses/{course_id}/labs`), откуда запускается рассылка обновлений шаблона. Массовая проверка добавлена туда же вторым действием в строке. Группа спрашивается в диалоге, потому что список лаб общий для курса, а лист - у каждой группы свой. + +Устройство фоновой работы (хранилище в памяти под блокировкой, `202` с `job_id`, опрос статуса, `409` на повторный запуск) повторяет `grading/propagate.py` - тот же сценарий и те же ограничения. diff --git a/docs/COURSE_CONFIG.md b/docs/COURSE_CONFIG.md index 00d6e4d..db65227 100644 --- a/docs/COURSE_CONFIG.md +++ b/docs/COURSE_CONFIG.md @@ -369,6 +369,21 @@ files: - README.md ``` +### `student-name-file` +**Тип:** `string` +**Описание:** Файл в репозитории студента, первая строка которого содержит его ФИО. Используется только массовой проверкой из админки: по этому ФИО находится строка студента в Google Таблице и в неё записывается GitHub-логин из имени репозитория (см. «Массовая проверка лабораторной» в `docs/PROJECT_DESCRIPTION.md`). Если ключ задан, поле «Файл с ФИО студента» в диалоге проверки предзаполняется этим значением; преподаватель может его изменить или очистить. +**По умолчанию:** не задан - массовая проверка тогда работает только по логинам, уже указанным в таблице +**Пример:** +```yaml +labs: + "1": + github-prefix: os-task1 + short-name: ЛР1 + files: + - info.md + student-name-file: info.md +``` + ### `forbidden-modifications` **Тип:** `list[string]` **Описание:** Список файлов/директорий, которые студент не может изменять diff --git a/docs/PROJECT_DESCRIPTION.md b/docs/PROJECT_DESCRIPTION.md index 3feb4d8..aa9e9dd 100644 --- a/docs/PROJECT_DESCRIPTION.md +++ b/docs/PROJECT_DESCRIPTION.md @@ -89,6 +89,7 @@ 2. **Редактирование конфигураций** — YAML редактор с подсветкой синтаксиса 3. **Загрузка новых курсов** — импорт конфигураций через веб-интерфейс 4. **Аутентификация** — защищенный доступ к административным функциям +5. **Массовая проверка лабораторных** — проверка всей группы одной кнопкой (см. «Массовая проверка лабораторной») ### Процесс проверки работы @@ -191,9 +192,12 @@ 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) | +| GET | `/admin/courses/{course_id}/labs` | Список лаб курса для админки (номер, github-prefix, template-repo, repo-provisioning, files, student-name-file) | | POST | `/admin/courses/{course_id}/labs/{lab_id}/propagate-template-update` | Рассылка обновлений шаблона в репозитории студентов через fork PR (только `repo-provisioning: fork`, см. ниже) | | GET | `/admin/propagate-jobs/{job_id}` | Статус фоновой рассылки обновлений | +| POST | `/admin/courses/{course_id}/groups/{group_id}/labs/{lab_id}/bulk-grade` | Массовая проверка лабораторной для группы (см. ниже) | +| GET | `/admin/bulk-grade-jobs/{job_id}` | Прогресс и отчёт массовой проверки | +| POST | `/admin/bulk-grade-jobs/{job_id}/cancel` | Остановка массовой проверки | Все административные маршруты (включая перечисленные выше) защищены зависимостью `require_admin` - её отсутствие раньше означало, что `POST /courses/upload`, `DELETE /courses/{course_id}` и `GET/PUT /courses/{course_id}/edit` были доступны без аутентификации на уровне API (защита была только на уровне фронтенда). @@ -209,6 +213,38 @@ lab_grader_web/ - Технически PR открывается не «из шаблона в форк»: GitHub не даёт создать кросс-репо pull request, когда шаблон и репозитории студентов принадлежат одной организации (`head` вида `owner:branch` в этом случае разрешается в сам целевой репозиторий). Вместо этого сервис пользуется тем, что форк и шаблон делят хранилище объектов: в репозиторий студента создаётся служебная ветка `template-update`, указывающая на текущий коммит шаблона, и PR открывается из неё в ветку по умолчанию. При каждой новой рассылке ветка перемещается на актуальный коммит - уже открытый PR при этом обновляется, а не дублируется. Студенту работать с этой веткой не нужно, но она видна в списке веток его репозитория. - Прогон CI после мержа PR зависит от `paths`-фильтров workflow конкретной лабы в репозитории-шаблоне - обновление файлов вне этих фильтров не запустит проверку автоматически. +### Массовая проверка лабораторной (`bulk-grade`) + +Проверка всей группы одной кнопкой, вместо ожидания, пока каждый студент сам отправит работу. Запускается со страницы лабораторных курса в админке (`/admin/courses/{course_id}/labs`): у каждой лабы есть действие «Заполнить таблицу», в диалоге выбирается группа и, при необходимости, файл с ФИО. + +Каждый студент проходит ровно те же проверки, что и при самостоятельной отправке: обязательные файлы, `.github/workflows`, коммиты, запрещённые модификации, результаты CI, номер варианта, баллы из логов, штраф за просрочку. Решение об оценке принимает `grading/bulk.py:evaluate_student` - общая функция для одиночной проверки (`grade_lab`) и массовой, поэтому их поведение не может разойтись. + +**Два режима, выбираются полем «Файл с ФИО студента»:** + +| Поле | Режим | Что происходит | +|------|-------|----------------| +| заполнено | `by_file` | Перебираются все репозитории организации с префиксом лабы. Из указанного файла берётся первая строка - ФИО студента. По ФИО находится строка в таблице, GitHub-логин из имени репозитория записывается в столбец `GitHub`, затем работа проверяется | +| пусто | `by_sheet` | Проверяются только студенты, у которых GitHub-логин уже указан в таблице | + +Подсказки в поле берутся из списка `files` лабы; ключ `student-name-file` (см. `docs/COURSE_CONFIG.md`) предзаполняет его. + +**Сопоставление ФИО** - точное после нормализации: схлопывание пробелов (включая неразрывные), регистр, `ё` → `е`. Нечёткий подбор не выполняется намеренно: цена ошибки - оценка, записанная не тому студенту. Репозитории, для которых ФИО не нашлось или нашлось дважды, попадают в отчёт со статусами `unmatched` / `ambiguous` и прочитанным ФИО, чтобы преподаватель исправил вручную. + +**Запись GitHub-логина:** пустая ячейка - логин записывается; тот же логин (без учёта регистра) - не перезаписывается; другой логин - `conflict`, строка пропускается целиком и не проверяется ни под одним из логинов. + +**Защита оценок:** действует то же правило `can_overwrite_cell`, что и при самостоятельной отправке - перезаписываются только пустые ячейки, `x` и значения, начинающиеся с `?`. Остальные попадают в отчёт со статусом `rejected` и текущим значением. Флага принудительной перезаписи нет. + +**Пробный запуск** (`dry_run`) выполняет все проверки и строит полный отчёт, не записывая в таблицу ничего - ни оценок, ни логинов. В отличие от `dry_run` у рассылки шаблона, здесь он тоже фоновая работа: проверки настоящие и занимают столько же времени. + +**Технические детали:** + +- Запуск возвращает `202` и `job_id`; работа идёт в фоне (`BackgroundTasks`), прогресс опрашивается через `GET /admin/bulk-grade-jobs/{job_id}` раз в 2 секунды. Хранилище работ - то же по устройству, что у рассылки шаблона (модульный словарь под блокировкой, не переживает перезапуск). Повторный запуск для той же тройки курс/группа/лаба, пока работа выполняется, отклоняется с `409`. +- Лист читается одним вызовом `get_all_values()`, все поиски (строка студента, столбец лабы, дедлайн, номер варианта) выполняются по сетке в памяти. Поячеечные хелперы тратят ~6 обращений к Sheets API на студента, что для группы из 30 человек выходит за квоту в 60 запросов чтения в минуту. +- Запись - пакетами по 10 ячеек по ходу работы, а не одним блоком в конце: падение, перезапуск или отмена сохраняют уже проставленные оценки. +- Студенты обрабатываются последовательно: GitHub применяет secondary rate limits к всплескам параллельных запросов от одного токена. +- Ошибка на одном студенте (нет коммитов, неверный вариант, недоступный репозиторий) не останавливает работу - она попадает в его строку отчёта. Останавливают работу только общие сбои: нет столбца `GitHub` или столбца лабы, недоступен список репозиториев организации. +- Отмена проверяется между студентами: накопленная порция дописывается в таблицу, работа завершается со статусом `cancelled`. + ## Конфигурация курса ### Индексный файл курсов (`courses/index.yaml`)