From 5d7dba5ecba9b57602e754ea54d4c3e5ebf92ed8 Mon Sep 17 00:00:00 2001 From: Danil Gryaznev Date: Mon, 29 Sep 2025 12:41:57 -0600 Subject: [PATCH] Fix coding mission panel state handling --- .../versions/20241005_0007_coding_missions.py | 68 +++++ backend/app/api/routes/missions.py | 231 ++++++++++++++- backend/app/models/__init__.py | 3 + backend/app/models/coding.py | 52 ++++ backend/app/models/mission.py | 11 +- backend/app/models/user.py | 8 +- backend/app/schemas/coding.py | 55 ++++ backend/app/schemas/mission.py | 4 +- backend/app/services/coding.py | 202 +++++++++++++ backend/app/utils/python_runner.py | 54 ++++ backend/tests/test_coding_mission.py | 107 +++++++ frontend/src/app/missions/[id]/page.tsx | 67 ++++- .../src/components/CodingMissionPanel.tsx | 275 ++++++++++++++++++ frontend/src/components/MissionList.tsx | 12 +- .../src/components/OnboardingCarousel.tsx | 2 + scripts/seed_data.py | 117 +++++++- 16 files changed, 1252 insertions(+), 16 deletions(-) create mode 100644 backend/alembic/versions/20241005_0007_coding_missions.py create mode 100644 backend/app/models/coding.py create mode 100644 backend/app/schemas/coding.py create mode 100644 backend/app/services/coding.py create mode 100644 backend/app/utils/python_runner.py create mode 100644 backend/tests/test_coding_mission.py create mode 100644 frontend/src/components/CodingMissionPanel.tsx diff --git a/backend/alembic/versions/20241005_0007_coding_missions.py b/backend/alembic/versions/20241005_0007_coding_missions.py new file mode 100644 index 0000000..9b128d0 --- /dev/null +++ b/backend/alembic/versions/20241005_0007_coding_missions.py @@ -0,0 +1,68 @@ +"""Добавляем таблицы для кодовых миссий.""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "20241005_0007" +down_revision = "20240927_0006" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Создаём таблицы испытаний и попыток запуска кода.""" + + op.create_table( + "coding_challenges", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("mission_id", sa.Integer(), sa.ForeignKey("missions.id"), nullable=False), + sa.Column("order", sa.Integer(), nullable=False), + sa.Column("title", sa.String(length=160), nullable=False), + sa.Column("prompt", sa.Text(), nullable=False), + sa.Column("starter_code", sa.Text(), nullable=True), + sa.Column("expected_output", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("mission_id", "order", name="uq_coding_challenge_order"), + ) + + op.create_table( + "coding_attempts", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("challenge_id", sa.Integer(), sa.ForeignKey("coding_challenges.id"), nullable=False), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("code", sa.Text(), nullable=False), + sa.Column("stdout", sa.Text(), nullable=False, server_default=""), + sa.Column("stderr", sa.Text(), nullable=False, server_default=""), + sa.Column("exit_code", sa.Integer(), nullable=False, server_default="0"), + sa.Column("is_passed", sa.Boolean(), nullable=False, server_default=sa.sql.expression.false()), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + + op.create_index( + "ix_coding_challenges_mission_id", + "coding_challenges", + ["mission_id"], + unique=False, + ) + op.create_index( + "ix_coding_attempts_user_id", + "coding_attempts", + ["user_id"], + unique=False, + ) + + +def downgrade() -> None: + """Удаляем вспомогательные таблицы.""" + + op.drop_index("ix_coding_attempts_user_id", table_name="coding_attempts") + op.drop_index("ix_coding_challenges_mission_id", table_name="coding_challenges") + op.drop_table("coding_attempts") + op.drop_table("coding_challenges") + diff --git a/backend/app/api/routes/missions.py b/backend/app/api/routes/missions.py index e95a010..ebb9d7a 100644 --- a/backend/app/api/routes/missions.py +++ b/backend/app/api/routes/missions.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session, selectinload from app.api.deps import get_current_user from app.db.session import get_db +from app.models.coding import CodingAttempt, CodingChallenge from app.models.branch import Branch, BranchMission from app.models.mission import Mission, MissionSubmission, SubmissionStatus from app.models.user import User, UserRole @@ -18,6 +19,13 @@ from app.schemas.mission import ( MissionDetail, MissionSubmissionRead, ) +from app.schemas.coding import ( + CodingChallengeState, + CodingMissionState, + CodingRunRequest, + CodingRunResponse, +) +from app.services.coding import count_completed_challenges, evaluate_challenge from app.services.mission import UNSET, submit_mission from app.services.storage import delete_submission_document, save_submission_document from app.core.config import settings @@ -87,6 +95,91 @@ def _mission_availability( return is_available, reasons +def _ensure_mission_access( + *, + mission: Mission, + user: User, + db: Session, +) -> tuple[bool, set[int]]: + """Проверяем, что миссия активна и доступна пилоту.""" + + db.refresh(user) + _ = user.submissions + + branches = ( + db.query(Branch) + .options(selectinload(Branch.missions)) + .all() + ) + branch_dependencies = _build_branch_dependencies(branches) + completed_missions = _load_user_progress(user) + mission_titles = dict(db.query(Mission.id, Mission.title).all()) + + is_available, reasons = _mission_availability( + mission=mission, + user=user, + completed_missions=completed_missions, + branch_dependencies=branch_dependencies, + mission_titles=mission_titles, + ) + + if mission.id not in completed_missions and not is_available: + message = reasons[0] if reasons else "Миссия пока недоступна." + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message) + + return mission.id in completed_missions, completed_missions + + +def _build_challenge_state( + *, + challenges: list[CodingChallenge], + attempts: list[CodingAttempt], +) -> tuple[list[CodingChallengeState], int, int, int | None]: + """Формируем состояние каждого задания.""" + + latest_attempts: dict[int, CodingAttempt] = {} + completed_ids: set[int] = set() + + for attempt in sorted(attempts, key=lambda item: item.created_at, reverse=True): + if attempt.challenge_id not in latest_attempts: + latest_attempts[attempt.challenge_id] = attempt + if attempt.is_passed: + completed_ids.add(attempt.challenge_id) + + completed_count = len(completed_ids) + total = len(challenges) + + states: list[CodingChallengeState] = [] + current_id: int | None = None + + for challenge in challenges: + last_attempt = latest_attempts.get(challenge.id) + is_passed = challenge.id in completed_ids + is_unlocked = is_passed + + if not is_passed and current_id is None: + current_id = challenge.id + is_unlocked = True + + states.append( + CodingChallengeState( + id=challenge.id, + order=challenge.order, + title=challenge.title, + prompt=challenge.prompt, + starter_code=challenge.starter_code, + is_passed=is_passed, + is_unlocked=is_unlocked, + last_submitted_code=last_attempt.code if last_attempt else None, + last_stdout=last_attempt.stdout if last_attempt else None, + last_stderr=last_attempt.stderr if last_attempt else None, + last_exit_code=last_attempt.exit_code if last_attempt else None, + updated_at=last_attempt.updated_at if last_attempt else None, + ) + ) + + return states, total, completed_count, current_id + @router.get("/branches", response_model=list[BranchRead], summary="Список веток миссий") def list_branches( *, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) @@ -179,6 +272,7 @@ def list_missions( .options( selectinload(Mission.prerequisites), selectinload(Mission.minimum_rank), + selectinload(Mission.coding_challenges), ) .filter(Mission.is_active.is_(True)) .order_by(Mission.id) @@ -187,6 +281,11 @@ def list_missions( mission_titles = {mission.id: mission.title for mission in missions} completed_missions = _load_user_progress(current_user) + coding_progress = count_completed_challenges( + db, + mission_ids=[mission.id for mission in missions if mission.coding_challenges], + user=current_user, + ) response: list[MissionBase] = [] for mission in missions: @@ -207,6 +306,9 @@ def list_missions( dto.is_completed = False dto.is_available = is_available dto.locked_reasons = reasons + dto.has_coding_challenges = bool(mission.coding_challenges) + dto.coding_challenge_count = len(mission.coding_challenges) + dto.completed_coding_challenges = coding_progress.get(mission.id, 0) response.append(dto) return response @@ -221,7 +323,12 @@ def get_mission( ) -> MissionDetail: """Возвращаем подробную информацию о миссии.""" - mission = db.query(Mission).filter(Mission.id == mission_id, Mission.is_active.is_(True)).first() + mission = ( + db.query(Mission) + .options(selectinload(Mission.coding_challenges)) + .filter(Mission.id == mission_id, Mission.is_active.is_(True)) + .first() + ) if not mission: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена") @@ -235,6 +342,7 @@ def get_mission( branch_dependencies = _build_branch_dependencies(branches) completed_missions = _load_user_progress(current_user) mission_titles = dict(db.query(Mission.id, Mission.title).all()) + coding_progress = count_completed_challenges(db, mission_ids=[mission.id], user=current_user) is_available, reasons = _mission_availability( mission=mission, @@ -272,6 +380,9 @@ def get_mission( updated_at=mission.updated_at, ) data.requires_documents = mission.id in REQUIRED_DOCUMENT_MISSIONS + data.has_coding_challenges = bool(mission.coding_challenges) + data.coding_challenge_count = len(mission.coding_challenges) + data.completed_coding_challenges = coding_progress.get(mission.id, 0) if mission.id in completed_missions: data.is_completed = True data.is_available = False @@ -279,6 +390,124 @@ def get_mission( return data +@router.get( + "/{mission_id}/coding/challenges", + response_model=CodingMissionState, + summary="Получаем список заданий по Python", +) +def get_coding_challenges( + mission_id: int, + *, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> CodingMissionState: + """Возвращаем состояние миссии с программированием.""" + + mission = ( + db.query(Mission) + .options(selectinload(Mission.coding_challenges)) + .filter(Mission.id == mission_id, Mission.is_active.is_(True)) + .first() + ) + if not mission: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена") + + if not mission.coding_challenges: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Для миссии не настроены задания на программирование.", + ) + + mission_completed, completed_missions = _ensure_mission_access( + mission=mission, + user=current_user, + db=db, + ) + + challenge_ids = [challenge.id for challenge in mission.coding_challenges] + attempts: list[CodingAttempt] = [] + if challenge_ids: + attempts = ( + db.query(CodingAttempt) + .filter( + CodingAttempt.user_id == current_user.id, + CodingAttempt.challenge_id.in_(challenge_ids), + ) + .order_by(CodingAttempt.created_at.desc()) + .all() + ) + + states, total, completed_count, current_id = _build_challenge_state( + challenges=sorted(mission.coding_challenges, key=lambda item: item.order), + attempts=attempts, + ) + + if mission.id in completed_missions: + mission_completed = True + + return CodingMissionState( + mission_id=mission.id, + total_challenges=total, + completed_challenges=completed_count, + current_challenge_id=current_id, + is_mission_completed=mission_completed, + challenges=states, + ) + + +@router.post( + "/{mission_id}/coding/challenges/{challenge_id}/run", + response_model=CodingRunResponse, + summary="Проверяем решение задания", +) +def run_coding_challenge( + mission_id: int, + challenge_id: int, + payload: CodingRunRequest, + *, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> CodingRunResponse: + """Запускаем Python-код кандидата и возвращаем результат.""" + + mission = ( + db.query(Mission) + .options(selectinload(Mission.coding_challenges)) + .filter(Mission.id == mission_id, Mission.is_active.is_(True)) + .first() + ) + if not mission: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена") + + mission_completed, _ = _ensure_mission_access(mission=mission, user=current_user, db=db) + + challenge = next((item for item in mission.coding_challenges if item.id == challenge_id), None) + if not challenge: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Задание не найдено") + + evaluation = evaluate_challenge( + db, + challenge=challenge, + user=current_user, + code=payload.code, + ) + + mission_completed = mission_completed or evaluation.mission_completed + expected_output = None + if not evaluation.attempt.is_passed: + expected_output = challenge.expected_output + + return CodingRunResponse( + attempt_id=evaluation.attempt.id, + stdout=evaluation.attempt.stdout, + stderr=evaluation.attempt.stderr, + exit_code=evaluation.attempt.exit_code, + is_passed=evaluation.attempt.is_passed, + mission_completed=mission_completed, + expected_output=expected_output, + ) + + @router.post("/{mission_id}/submit", response_model=MissionSubmissionRead, summary="Отправляем отчёт") async def submit( mission_id: int, diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8e2ba38..14f3449 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -4,6 +4,7 @@ from .artifact import Artifact # noqa: F401 from .branch import Branch, BranchMission # noqa: F401 from .journal import JournalEntry # noqa: F401 from .mission import Mission, MissionCompetencyReward, MissionPrerequisite, MissionSubmission # noqa: F401 +from .coding import CodingAttempt, CodingChallenge # noqa: F401 from .onboarding import OnboardingSlide, OnboardingState # noqa: F401 from .rank import Rank, RankCompetencyRequirement, RankMissionRequirement # noqa: F401 from .store import Order, StoreItem # noqa: F401 @@ -14,6 +15,8 @@ __all__ = [ "Branch", "BranchMission", "JournalEntry", + "CodingChallenge", + "CodingAttempt", "Mission", "MissionCompetencyReward", "MissionPrerequisite", diff --git a/backend/app/models/coding.py b/backend/app/models/coding.py new file mode 100644 index 0000000..8334b9c --- /dev/null +++ b/backend/app/models/coding.py @@ -0,0 +1,52 @@ +"""Модели для кодовых испытаний внутри миссий.""" + +from __future__ import annotations + +from typing import List, Optional + +from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin + + +class CodingChallenge(Base, TimestampMixin): + """Шаг миссии с заданием на программирование.""" + + __tablename__ = "coding_challenges" + __table_args__ = ( + # Порядок внутри миссии должен быть уникальным, чтобы упрощать проверку последовательности. + UniqueConstraint("mission_id", "order", name="uq_coding_challenge_order"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + mission_id: Mapped[int] = mapped_column(ForeignKey("missions.id"), nullable=False, index=True) + order: Mapped[int] = mapped_column(Integer, nullable=False) + title: Mapped[str] = mapped_column(String(160), nullable=False) + prompt: Mapped[str] = mapped_column(Text, nullable=False) + starter_code: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + expected_output: Mapped[str] = mapped_column(Text, nullable=False) + + mission = relationship("Mission", back_populates="coding_challenges") + attempts: Mapped[List["CodingAttempt"]] = relationship( + "CodingAttempt", back_populates="challenge", cascade="all, delete-orphan" + ) + + +class CodingAttempt(Base, TimestampMixin): + """История запусков кода пилота для конкретного задания.""" + + __tablename__ = "coding_attempts" + + id: Mapped[int] = mapped_column(primary_key=True) + challenge_id: Mapped[int] = mapped_column(ForeignKey("coding_challenges.id"), nullable=False) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True) + code: Mapped[str] = mapped_column(Text, nullable=False) + stdout: Mapped[str] = mapped_column(Text, nullable=False, default="") + stderr: Mapped[str] = mapped_column(Text, nullable=False, default="") + exit_code: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + is_passed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + challenge = relationship("CodingChallenge", back_populates="attempts") + user = relationship("User", back_populates="coding_attempts") + diff --git a/backend/app/models/mission.py b/backend/app/models/mission.py index 62bf08c..1885129 100644 --- a/backend/app/models/mission.py +++ b/backend/app/models/mission.py @@ -3,13 +3,16 @@ from __future__ import annotations from enum import Enum -from typing import List, Optional +from typing import TYPE_CHECKING, List, Optional from sqlalchemy import Boolean, Enum as SQLEnum, ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from app.models.base import Base, TimestampMixin +if TYPE_CHECKING: + from app.models.coding import CodingChallenge + class MissionDifficulty(str, Enum): """Условные уровни сложности.""" @@ -56,6 +59,12 @@ class Mission(Base, TimestampMixin): rank_requirements: Mapped[List["RankMissionRequirement"]] = relationship( "RankMissionRequirement", back_populates="mission" ) + coding_challenges: Mapped[List["CodingChallenge"]] = relationship( + "CodingChallenge", + back_populates="mission", + cascade="all, delete-orphan", + order_by="CodingChallenge.order", + ) class MissionCompetencyReward(Base, TimestampMixin): diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 42ab26a..3d92237 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime from enum import Enum -from typing import List, Optional +from typing import TYPE_CHECKING, List, Optional from sqlalchemy import Boolean, DateTime, Enum as SQLEnum, ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -14,6 +14,9 @@ from app.models.base import Base, TimestampMixin # Локальные импорты внизу файла, чтобы избежать циклов типов +if TYPE_CHECKING: + from app.models.coding import CodingAttempt + class UserRole(str, Enum): """Типы ролей в системе.""" @@ -61,6 +64,9 @@ class User(Base, TimestampMixin): onboarding_state: Mapped[Optional["OnboardingState"]] = relationship( "OnboardingState", back_populates="user", cascade="all, delete-orphan", uselist=False ) + coding_attempts: Mapped[List["CodingAttempt"]] = relationship( + "CodingAttempt", back_populates="user", cascade="all, delete-orphan" + ) class CompetencyCategory(str, Enum): diff --git a/backend/app/schemas/coding.py b/backend/app/schemas/coding.py new file mode 100644 index 0000000..ea128db --- /dev/null +++ b/backend/app/schemas/coding.py @@ -0,0 +1,55 @@ +"""Схемы для миссий с задачами на программирование.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +class CodingChallengeState(BaseModel): + """Описание шага миссии для фронтенда.""" + + id: int + order: int + title: str + prompt: str + starter_code: Optional[str] = None + is_passed: bool = False + is_unlocked: bool = False + last_submitted_code: Optional[str] = None + last_stdout: Optional[str] = None + last_stderr: Optional[str] = None + last_exit_code: Optional[int] = None + updated_at: Optional[datetime] = None + + +class CodingMissionState(BaseModel): + """Состояние всей миссии: прогресс и список задач.""" + + mission_id: int + total_challenges: int + completed_challenges: int + current_challenge_id: Optional[int] + is_mission_completed: bool + challenges: list[CodingChallengeState] + + +class CodingRunRequest(BaseModel): + """Запрос на запуск решения.""" + + code: str = Field(..., min_length=1, description="Исходный код на Python") + + +class CodingRunResponse(BaseModel): + """Ответ после запуска кода пользователя.""" + + attempt_id: int + stdout: str + stderr: str + exit_code: int + is_passed: bool + mission_completed: bool + expected_output: Optional[str] = None + diff --git a/backend/app/schemas/mission.py b/backend/app/schemas/mission.py index 8d2917d..01e7f02 100644 --- a/backend/app/schemas/mission.py +++ b/backend/app/schemas/mission.py @@ -24,7 +24,9 @@ class MissionBase(BaseModel): locked_reasons: list[str] = Field(default_factory=list) is_completed: bool = False requires_documents: bool = False - is_completed: bool = False + has_coding_challenges: bool = False + coding_challenge_count: int = 0 + completed_coding_challenges: int = 0 class Config: from_attributes = True diff --git a/backend/app/services/coding.py b/backend/app/services/coding.py new file mode 100644 index 0000000..ab85296 --- /dev/null +++ b/backend/app/services/coding.py @@ -0,0 +1,202 @@ +"""Сервисные функции для миссии с заданиями на программирование.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + +from fastapi import HTTPException, status +from sqlalchemy import and_, func, select +from sqlalchemy.orm import Session + +from app.models.coding import CodingAttempt, CodingChallenge +from app.models.mission import Mission, MissionSubmission, SubmissionStatus +from app.models.user import User +from app.services.mission import approve_submission +from app.utils.python_runner import PythonRunResult, run_user_python_code + + +@dataclass(slots=True) +class AttemptEvaluation: + """Результат проверки решения.""" + + attempt: CodingAttempt + mission_completed: bool + + +def _normalize_output(raw: str) -> str: + """Удаляем завершающие перевод строки и приводим переносы к ``\n``.""" + + return raw.replace("\r\n", "\n").rstrip("\n") + + +def _ensure_previous_challenges_solved( + db: Session, + *, + challenge: CodingChallenge, + user: User, +) -> None: + """Проверяем, что все предыдущие задания завершены.""" + + previous_ids = ( + db.execute( + select(CodingChallenge.id) + .where( + CodingChallenge.mission_id == challenge.mission_id, + CodingChallenge.order < challenge.order, + ) + .order_by(CodingChallenge.order) + ) + .scalars() + .all() + ) + + if not previous_ids: + return + + solved_ids = set( + db.execute( + select(CodingAttempt.challenge_id) + .where( + CodingAttempt.user_id == user.id, + CodingAttempt.challenge_id.in_(previous_ids), + CodingAttempt.is_passed.is_(True), + ) + .distinct() + ) + .scalars() + .all() + ) + + missing = [challenge_id for challenge_id in previous_ids if challenge_id not in solved_ids] + if missing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Сначала решите предыдущие задачи этой миссии.", + ) + + +def _finalize_mission_if_needed( + db: Session, + *, + mission: Mission, + user: User, + challenge_ids: Iterable[int], +) -> bool: + """Если пилот решил все задания, автоматически засчитываем миссию.""" + + challenge_ids = list(challenge_ids) + if not challenge_ids: + return False + + solved_ids = set( + db.execute( + select(CodingAttempt.challenge_id) + .where( + CodingAttempt.user_id == user.id, + CodingAttempt.challenge_id.in_(challenge_ids), + CodingAttempt.is_passed.is_(True), + ) + .distinct() + ) + .scalars() + .all() + ) + + if len(solved_ids) != len(set(challenge_ids)): + return False + + submission = ( + db.query(MissionSubmission) + .filter( + MissionSubmission.user_id == user.id, + MissionSubmission.mission_id == mission.id, + ) + .first() + ) + + if not submission: + submission = MissionSubmission(user_id=user.id, mission_id=mission.id) + db.add(submission) + db.flush() + db.refresh(submission) + + if submission.status == SubmissionStatus.APPROVED: + return True + + submission.comment = "Автоматическая проверка: все задания решены." + db.add(submission) + db.flush() + db.refresh(submission) + approve_submission(db, submission) + return True + + +def evaluate_challenge( + db: Session, + *, + challenge: CodingChallenge, + user: User, + code: str, +) -> AttemptEvaluation: + """Запускаем код пользователя и сохраняем попытку.""" + + _ensure_previous_challenges_solved(db, challenge=challenge, user=user) + + run_result: PythonRunResult = run_user_python_code(code) + expected = _normalize_output(challenge.expected_output) + actual = _normalize_output(run_result.stdout) + + is_passed = run_result.exit_code == 0 and actual == expected + + attempt = CodingAttempt( + challenge_id=challenge.id, + user_id=user.id, + code=code, + stdout=run_result.stdout, + stderr=run_result.stderr, + exit_code=run_result.exit_code, + is_passed=is_passed, + ) + + db.add(attempt) + db.commit() + db.refresh(attempt) + + mission = challenge.mission or db.query(Mission).filter(Mission.id == challenge.mission_id).first() + mission_completed = False + + if is_passed and mission: + mission_completed = _finalize_mission_if_needed( + db, + mission=mission, + user=user, + challenge_ids=[item.id for item in mission.coding_challenges], + ) + + return AttemptEvaluation(attempt=attempt, mission_completed=mission_completed) + + +def count_completed_challenges(db: Session, *, mission_ids: Iterable[int], user: User) -> dict[int, int]: + """Возвращаем количество решённых заданий по миссиям.""" + + mission_ids = list(mission_ids) + if not mission_ids: + return {} + + rows = db.execute( + select(CodingChallenge.mission_id, func.count(func.distinct(CodingChallenge.id))) + .join( + CodingAttempt, + and_( + CodingAttempt.challenge_id == CodingChallenge.id, + CodingAttempt.user_id == user.id, + CodingAttempt.is_passed.is_(True), + ), + ) + .where(CodingChallenge.mission_id.in_(mission_ids)) + .group_by(CodingChallenge.mission_id) + ).all() + + return {mission_id: count for mission_id, count in rows} + diff --git a/backend/app/utils/python_runner.py b/backend/app/utils/python_runner.py new file mode 100644 index 0000000..fb1777c --- /dev/null +++ b/backend/app/utils/python_runner.py @@ -0,0 +1,54 @@ +"""Запуск пользовательского Python-кода в отдельном процессе.""" + +from __future__ import annotations + +from dataclasses import dataclass +import subprocess +import sys +from typing import Final + + +DEFAULT_TIMEOUT_SECONDS: Final[float] = 5.0 + + +@dataclass(slots=True) +class PythonRunResult: + """Результат выполнения пользовательской программы.""" + + stdout: str + stderr: str + exit_code: int + timeout: bool = False + + +def run_user_python_code(code: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> PythonRunResult: + """Запускаем код в отдельном процессе и возвращаем stdout/stderr. + + Код выполняется через ``sys.executable`` в режиме ``-c``. Таймаут ограничен, + чтобы бесконечные циклы не блокировали API. Любые ошибки компиляции или + выполнения попадают в ``stderr`` и возвращаются пользователю без изменений. + """ + + try: + completed = subprocess.run( # noqa: PLW1510 - таймаут обрабатываем вручную + [sys.executable, "-c", code], + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired as exc: + stderr = exc.stderr or "" + if stderr: + stderr = f"{stderr}\nПрограмма превысила лимит {timeout:.1f} сек." + else: + stderr = f"Программа превысила лимит {timeout:.1f} сек." + return PythonRunResult(stdout=exc.stdout or "", stderr=stderr, exit_code=124, timeout=True) + + return PythonRunResult( + stdout=completed.stdout, + stderr=completed.stderr, + exit_code=completed.returncode, + timeout=False, + ) + diff --git a/backend/tests/test_coding_mission.py b/backend/tests/test_coding_mission.py new file mode 100644 index 0000000..c60ce86 --- /dev/null +++ b/backend/tests/test_coding_mission.py @@ -0,0 +1,107 @@ +"""Проверяем автоматические миссии на Python.""" + +from __future__ import annotations + +import pytest +from fastapi import HTTPException + +from app.models.coding import CodingChallenge +from app.models.mission import Mission, MissionDifficulty, MissionSubmission, SubmissionStatus +from app.models.user import User, UserRole +from app.services.coding import count_completed_challenges, evaluate_challenge + + +def _create_user(db_session) -> User: + user = User( + email="python@alabuga.space", + full_name="Python Pilot", + role=UserRole.PILOT, + hashed_password="hash", + ) + db_session.add(user) + db_session.flush() + return user + + +def _create_mission_with_challenges(db_session) -> tuple[Mission, list[CodingChallenge]]: + mission = Mission( + title="Учебная миссия", + description="Две задачи на программирование", + xp_reward=120, + mana_reward=60, + difficulty=MissionDifficulty.MEDIUM, + ) + challenge_one = CodingChallenge( + mission=mission, + order=1, + title="Приветствие", + prompt="Выведите Привет", + starter_code="", + expected_output="Привет", + ) + challenge_two = CodingChallenge( + mission=mission, + order=2, + title="Пока", + prompt="Выведите Пока", + starter_code="", + expected_output="Пока", + ) + db_session.add_all([mission, challenge_one, challenge_two]) + db_session.flush() + return mission, [challenge_one, challenge_two] + + +def test_challenges_require_sequential_completion(db_session): + """Нельзя переходить к следующему заданию без успешного выполнения предыдущего.""" + + mission, (challenge_one, challenge_two) = _create_mission_with_challenges(db_session) + user = _create_user(db_session) + + with pytest.raises(HTTPException): + evaluate_challenge(db_session, challenge=challenge_two, user=user, code="print('Пока')") + + first_attempt = evaluate_challenge(db_session, challenge=challenge_one, user=user, code="print('Привет')") + assert first_attempt.attempt.is_passed is True + assert first_attempt.mission_completed is False + + second_attempt = evaluate_challenge(db_session, challenge=challenge_two, user=user, code="print('Пока')") + assert second_attempt.attempt.is_passed is True + assert second_attempt.mission_completed is True + + db_session.refresh(user) + assert user.xp == mission.xp_reward + assert user.mana == mission.mana_reward + + submission = ( + db_session.query(MissionSubmission) + .filter(MissionSubmission.user_id == user.id, MissionSubmission.mission_id == mission.id) + .first() + ) + assert submission is not None + assert submission.status == SubmissionStatus.APPROVED + + +def test_failed_attempt_does_not_unlock_next_challenge(db_session): + """Неудачные запуски фиксируются, но не засчитываются как выполненные.""" + + mission, (challenge_one, challenge_two) = _create_mission_with_challenges(db_session) + user = _create_user(db_session) + + failed = evaluate_challenge(db_session, challenge=challenge_one, user=user, code="print('Не то')") + assert failed.attempt.is_passed is False + assert failed.mission_completed is False + + progress = count_completed_challenges(db_session, mission_ids=[mission.id], user=user) + assert progress.get(mission.id, 0) == 0 + + with pytest.raises(HTTPException): + evaluate_challenge(db_session, challenge=challenge_two, user=user, code="print('Пока')") + + evaluate_challenge(db_session, challenge=challenge_one, user=user, code="print('Привет')") + progress = count_completed_challenges(db_session, mission_ids=[mission.id], user=user) + assert progress.get(mission.id, 0) == 1 + + follow_up = evaluate_challenge(db_session, challenge=challenge_two, user=user, code="print('Пока')") + assert follow_up.attempt.is_passed is True + diff --git a/frontend/src/app/missions/[id]/page.tsx b/frontend/src/app/missions/[id]/page.tsx index b195369..5def991 100644 --- a/frontend/src/app/missions/[id]/page.tsx +++ b/frontend/src/app/missions/[id]/page.tsx @@ -1,6 +1,7 @@ import { apiFetch } from '../../../lib/api'; import { requireSession } from '../../../lib/auth/session'; import { MissionSubmissionForm } from '../../../components/MissionSubmissionForm'; +import { CodingMissionPanel } from '../../../components/CodingMissionPanel'; interface MissionDetail { id: number; @@ -21,6 +22,9 @@ interface MissionDetail { locked_reasons: string[]; is_completed: boolean; requires_documents: boolean; + has_coding_challenges: boolean; + coding_challenge_count: number; + completed_coding_challenges: number; } async function fetchMission(id: number, token: string) { @@ -46,11 +50,40 @@ interface MissionPageProps { params: { id: string }; } +interface CodingChallengeState { + id: number; + order: number; + title: string; + prompt: string; + starter_code: string | null; + is_passed: boolean; + is_unlocked: boolean; + last_submitted_code: string | null; + last_stdout: string | null; + last_stderr: string | null; + last_exit_code: number | null; + updated_at: string | null; +} + +interface CodingMissionState { + mission_id: number; + total_challenges: number; + completed_challenges: number; + current_challenge_id: number | null; + is_mission_completed: boolean; + challenges: CodingChallengeState[]; +} + export default async function MissionPage({ params }: MissionPageProps) { const missionId = Number(params.id); // Даже при прямом переходе на URL миссия доступна только авторизованным пользователям. const session = await requireSession(); const { mission, submission } = await fetchMission(missionId, session.token); + const codingState = mission.has_coding_challenges + ? await apiFetch(`/api/missions/${missionId}/coding/challenges`, { + authToken: session.token + }) + : null; return (
@@ -60,6 +93,11 @@ export default async function MissionPage({ params }: MissionPageProps) {

Награда: {mission.xp_reward} XP · {mission.mana_reward} ⚡

+ {mission.has_coding_challenges && ( +

+ Прогресс тренажёра: {mission.completed_coding_challenges}/{mission.coding_challenge_count} заданий +

+ )} {mission.is_completed && (
Миссия завершена

- HR уже подтвердил выполнение. Вы можете просмотреть прикреплённые документы ниже. + {mission.has_coding_challenges + ? 'Система проверила код автоматически и уже начислила награды.' + : 'HR уже подтвердил выполнение. Вы можете просмотреть прикреплённые документы ниже.'}

)} @@ -96,14 +136,23 @@ export default async function MissionPage({ params }: MissionPageProps) { {mission.competency_rewards.length === 0 &&
  • Нет прокачки компетенций.
  • } - + {mission.has_coding_challenges ? ( + + ) : ( + + )}
    ); } diff --git a/frontend/src/components/CodingMissionPanel.tsx b/frontend/src/components/CodingMissionPanel.tsx new file mode 100644 index 0000000..cdeca0a --- /dev/null +++ b/frontend/src/components/CodingMissionPanel.tsx @@ -0,0 +1,275 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { apiFetch } from '../lib/api'; + +interface CodingChallengeState { + id: number; + order: number; + title: string; + prompt: string; + starter_code: string | null; + is_passed: boolean; + is_unlocked: boolean; + last_submitted_code: string | null; + last_stdout: string | null; + last_stderr: string | null; + last_exit_code: number | null; + updated_at: string | null; +} + +interface CodingMissionState { + mission_id: number; + total_challenges: number; + completed_challenges: number; + current_challenge_id: number | null; + is_mission_completed: boolean; + challenges: CodingChallengeState[]; +} + +interface CodingMissionPanelProps { + missionId: number; + token?: string; + initialState: CodingMissionState | null; + initialCompleted?: boolean; +} + +interface RunResult { + stdout: string; + stderr: string; + exit_code: number; + is_passed: boolean; + mission_completed: boolean; + expected_output?: string | null; +} + +export function CodingMissionPanel({ missionId, token, initialState, initialCompleted = false }: CodingMissionPanelProps) { + const [state, setState] = useState(initialState); + const [missionCompleted, setMissionCompleted] = useState(initialState?.is_mission_completed || initialCompleted); + const [editorCode, setEditorCode] = useState(''); + const [status, setStatus] = useState(null); + const [runResult, setRunResult] = useState(null); + const [loading, setLoading] = useState(false); + const previousChallengeIdRef = useRef(null); + + const activeChallenge = useMemo(() => { + if (!state || !state.challenges.length) { + return null; + } + + if (state.current_challenge_id) { + const current = state.challenges.find((challenge) => challenge.id === state.current_challenge_id); + if (current) { + return current; + } + } + + const nextUnlocked = state.challenges.find((challenge) => challenge.is_unlocked && !challenge.is_passed); + if (nextUnlocked) { + return nextUnlocked; + } + + const firstIncomplete = state.challenges.find((challenge) => !challenge.is_passed); + if (firstIncomplete) { + return firstIncomplete; + } + + return state.challenges[state.challenges.length - 1]; + }, [state]); + + useEffect(() => { + if (!state) { + return; + } + setMissionCompleted(state.is_mission_completed); + }, [state]); + + useEffect(() => { + if (!activeChallenge) { + previousChallengeIdRef.current = null; + return; + } + + if (previousChallengeIdRef.current === activeChallenge.id) { + return; + } + + previousChallengeIdRef.current = activeChallenge.id; + const baseCode = activeChallenge.last_submitted_code ?? activeChallenge.starter_code ?? ''; + setEditorCode(baseCode); + setRunResult(null); + setStatus(null); + }, [activeChallenge]); + + if (!state) { + return ( +
    +

    + Не удалось загрузить задания для миссии. Попробуйте обновить страницу или обратитесь к HR, чтобы + проверить настройки миссии. +

    +
    + ); + } + + const handleRefresh = async () => { + if (!token) { + return; + } + try { + const updated = await apiFetch(`/api/missions/${missionId}/coding/challenges`, { + authToken: token + }); + setState(updated); + } catch (error) { + if (error instanceof Error) { + setStatus(error.message); + } + } + }; + + const handleRun = async () => { + if (!token) { + setStatus('Не удалось получить токен авторизации. Перезайдите в систему.'); + return; + } + if (!activeChallenge) { + setStatus('Все задания выполнены — можно переходить к другим миссиям.'); + return; + } + if (!editorCode.trim()) { + setStatus('Добавьте решение в редакторе, прежде чем запускать проверку.'); + return; + } + + try { + setLoading(true); + setStatus(null); + const result = await apiFetch( + `/api/missions/${missionId}/coding/challenges/${activeChallenge.id}/run`, + { + method: 'POST', + body: JSON.stringify({ code: editorCode }), + authToken: token + } + ); + setRunResult(result); + setMissionCompleted(result.mission_completed); + setStatus( + result.is_passed + ? 'Отлично! Задание пройдено. Можно переходить к следующему шагу.' + : 'Проверка не пройдена. Сверьтесь с выводом программы и подсказками.' + ); + await handleRefresh(); + } catch (error) { + if (error instanceof Error) { + setStatus(error.message); + } else { + setStatus('Неожиданная ошибка при выполнении проверки. Попробуйте повторить позже.'); + } + } finally { + setLoading(false); + } + }; + + return ( +
    + {state.challenges.map((challenge) => { + const isActive = activeChallenge?.id === challenge.id && !missionCompleted; + return ( +
    +
    +

    + {challenge.order}. {challenge.title} +

    + {challenge.is_passed && ✓ Готово} +
    +

    {challenge.prompt}

    + {!challenge.is_unlocked && !challenge.is_passed && ( +

    + 🔒 Задание откроется после успешного решения предыдущих пунктов. +

    + )} + {challenge.is_passed && challenge.last_stdout && ( +
    + Последний вывод программы: +
    +                  {challenge.last_stdout}
    +                
    +
    + )} + {isActive && ( +
    +