Add Python coding mission with interpreter and UI

This commit is contained in:
Danil Gryaznev 2025-09-29 12:08:06 -06:00
parent 2aea7e840e
commit ddb4d2120f
15 changed files with 1241 additions and 16 deletions

View File

@ -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")

View File

@ -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,

View File

@ -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",

View File

@ -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")

View File

@ -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):

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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}

View File

@ -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,
)

View File

@ -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

View File

@ -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<CodingMissionState>(`/api/missions/${missionId}/coding/challenges`, {
authToken: session.token
})
: null;
return (
<section>
@ -60,6 +93,11 @@ export default async function MissionPage({ params }: MissionPageProps) {
<p style={{ marginTop: '1rem' }}>
Награда: {mission.xp_reward} XP · {mission.mana_reward}
</p>
{mission.has_coding_challenges && (
<p style={{ marginTop: '0.5rem', color: 'var(--accent-light)' }}>
Прогресс тренажёра: {mission.completed_coding_challenges}/{mission.coding_challenge_count} заданий
</p>
)}
{mission.is_completed && (
<div
className="card"
@ -71,7 +109,9 @@ export default async function MissionPage({ params }: MissionPageProps) {
>
<strong>Миссия завершена</strong>
<p style={{ marginTop: '0.5rem', color: 'var(--text-muted)' }}>
HR уже подтвердил выполнение. Вы можете просмотреть прикреплённые документы ниже.
{mission.has_coding_challenges
? 'Система проверила код автоматически и уже начислила награды.'
: 'HR уже подтвердил выполнение. Вы можете просмотреть прикреплённые документы ниже.'}
</p>
</div>
)}
@ -96,6 +136,14 @@ export default async function MissionPage({ params }: MissionPageProps) {
{mission.competency_rewards.length === 0 && <li>Нет прокачки компетенций.</li>}
</ul>
</div>
{mission.has_coding_challenges ? (
<CodingMissionPanel
missionId={mission.id}
token={session.token}
initialState={codingState}
initialCompleted={mission.is_completed}
/>
) : (
<MissionSubmissionForm
missionId={mission.id}
token={session.token}
@ -104,6 +152,7 @@ export default async function MissionPage({ params }: MissionPageProps) {
requiresDocuments={mission.requires_documents}
submission={submission ?? undefined}
/>
)}
</section>
);
}

View File

@ -0,0 +1,266 @@
'use client';
import { useEffect, useMemo, 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<CodingMissionState | null>(initialState);
const [missionCompleted, setMissionCompleted] = useState(initialState?.is_mission_completed || initialCompleted);
const [activeChallengeId, setActiveChallengeId] = useState<number | null>(
initialState?.current_challenge_id ?? initialState?.challenges?.[0]?.id ?? null
);
const [editorCode, setEditorCode] = useState<string>(() => {
const active = initialState?.challenges.find((challenge) => challenge.id === activeChallengeId);
if (!active) {
return '';
}
return active.last_submitted_code ?? active.starter_code ?? '';
});
const [status, setStatus] = useState<string | null>(null);
const [runResult, setRunResult] = useState<RunResult | null>(null);
const [loading, setLoading] = useState(false);
const activeChallenge = useMemo(() => {
if (!state || !state.challenges.length) {
return null;
}
const targetId = state.current_challenge_id ?? activeChallengeId;
if (targetId == null) {
return null;
}
return state.challenges.find((challenge) => challenge.id === targetId) ?? null;
}, [state, activeChallengeId]);
useEffect(() => {
if (!state || !state.challenges.length) {
return;
}
const nextActiveId = state.current_challenge_id ?? state.challenges[state.challenges.length - 1].id;
if (nextActiveId !== activeChallengeId) {
setActiveChallengeId(nextActiveId);
const nextChallenge = state.challenges.find((challenge) => challenge.id === nextActiveId);
const nextCode = nextChallenge?.last_submitted_code ?? nextChallenge?.starter_code ?? '';
setEditorCode(nextCode);
setRunResult(null);
setStatus(null);
}
setMissionCompleted(state.is_mission_completed);
}, [state, activeChallengeId]);
useEffect(() => {
if (!activeChallenge) {
return;
}
if (editorCode) {
return;
}
const baseCode = activeChallenge.last_submitted_code ?? activeChallenge.starter_code ?? '';
setEditorCode(baseCode);
}, [activeChallenge]);
if (!state) {
return (
<div className="card" style={{ marginTop: '2rem' }}>
<p>
Не удалось загрузить задания для миссии. Попробуйте обновить страницу или обратитесь к HR, чтобы
проверить настройки миссии.
</p>
</div>
);
}
const handleRefresh = async () => {
if (!token) {
return;
}
const updated = await apiFetch<CodingMissionState>(`/api/missions/${missionId}/coding/challenges`, {
authToken: token
});
setState(updated);
};
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<RunResult>(
`/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 (
<div style={{ marginTop: '2rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{state.challenges.map((challenge) => {
const isActive = activeChallenge?.id === challenge.id && !missionCompleted;
return (
<div key={challenge.id} className="card" style={{ border: challenge.is_passed ? '1px solid rgba(85,239,196,0.4)' : undefined }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ marginBottom: '0.25rem' }}>
{challenge.order}. {challenge.title}
</h3>
{challenge.is_passed && <span style={{ color: '#55efc4' }}> Готово</span>}
</div>
<p style={{ color: 'var(--text-muted)' }}>{challenge.prompt}</p>
{!challenge.is_unlocked && !challenge.is_passed && (
<p style={{ marginTop: '0.75rem', color: 'var(--text-muted)' }}>
🔒 Задание откроется после успешного решения предыдущих пунктов.
</p>
)}
{challenge.is_passed && challenge.last_stdout && (
<div style={{ marginTop: '0.75rem' }}>
<small style={{ color: 'var(--text-muted)' }}>Последний вывод программы:</small>
<pre style={{ background: 'rgba(99, 110, 114, 0.2)', padding: '0.75rem', borderRadius: '12px', overflowX: 'auto' }}>
{challenge.last_stdout}
</pre>
</div>
)}
{isActive && (
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<label>
<span style={{ display: 'block', marginBottom: '0.5rem' }}>Редактор кода</span>
<textarea
value={editorCode}
onChange={(event) => setEditorCode(event.target.value)}
rows={12}
style={{ width: '100%', borderRadius: '12px', padding: '0.75rem', fontFamily: 'monospace' }}
disabled={loading || missionCompleted}
/>
</label>
<button
type="button"
className="primary"
onClick={handleRun}
disabled={loading || missionCompleted}
>
{missionCompleted ? 'Миссия завершена' : loading ? 'Выполняем код...' : 'Проверить'}
</button>
{status && (
<p
style={{
marginTop: '-0.25rem',
color: status.startsWith('Отлично') ? 'var(--accent-light)' : status.includes('Проверка') ? 'var(--error)' : 'var(--text-muted)'
}}
>
{status}
</p>
)}
{runResult && (
<div>
<details open>
<summary style={{ cursor: 'pointer', color: 'var(--accent)' }}>Результат последнего запуска</summary>
<div style={{ marginTop: '0.5rem' }}>
<strong>stdout:</strong>
<pre style={{ background: 'rgba(108, 92, 231, 0.15)', padding: '0.75rem', borderRadius: '12px', overflowX: 'auto' }}>
{runResult.stdout || '<пусто>'}
</pre>
<strong>stderr:</strong>
<pre style={{ background: 'rgba(225, 112, 85, 0.15)', padding: '0.75rem', borderRadius: '12px', overflowX: 'auto' }}>
{runResult.stderr || '<пусто>'}
</pre>
<p style={{ marginTop: '0.5rem', color: 'var(--text-muted)' }}>
Код завершился с статусом {runResult.exit_code}.
</p>
{runResult.expected_output && (
<div style={{ marginTop: '0.5rem' }}>
<strong>Ожидаемый вывод:</strong>
<pre style={{ background: 'rgba(99, 110, 114, 0.2)', padding: '0.75rem', borderRadius: '12px', overflowX: 'auto' }}>
{runResult.expected_output}
</pre>
</div>
)}
</div>
</details>
</div>
)}
</div>
)}
</div>
);
})}
{missionCompleted && (
<div className="card" style={{ border: '1px solid rgba(85,239,196,0.35)', background: 'rgba(85,239,196,0.12)' }}>
<h3>Все задания выполнены!</h3>
<p style={{ marginTop: '0.5rem', color: 'var(--text-muted)' }}>
Система уже зачислила опыт и ману за миссию. Можно вернуться к списку миссий и выбрать следующее испытание.
</p>
</div>
)}
</div>
);
}

View File

@ -14,6 +14,9 @@ export interface MissionSummary {
locked_reasons: string[];
is_completed: boolean;
requires_documents: boolean;
has_coding_challenges: boolean;
coding_challenge_count: number;
completed_coding_challenges: number;
}
const Card = styled.div`
@ -38,7 +41,9 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
const actionLabel = completed
? 'Миссия выполнена'
: mission.is_available
? 'Открыть брифинг'
? mission.has_coding_challenges
? 'Решать задачи'
: 'Открыть брифинг'
: 'Заблокировано';
return (
@ -54,6 +59,11 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
)}
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
{mission.has_coding_challenges && (
<p style={{ marginTop: '0.5rem', color: 'var(--accent-light)' }}>
💻 Прогресс: {mission.completed_coding_challenges}/{mission.coding_challenge_count} заданий
</p>
)}
<p style={{ marginTop: '1rem' }}>{mission.xp_reward} XP · {mission.mana_reward} </p>
{locked && mission.locked_reasons.length > 0 && (
<p style={{ color: 'var(--error)', fontSize: '0.85rem' }}>{mission.locked_reasons[0]}</p>

View File

@ -16,6 +16,7 @@ from app.core.security import get_password_hash
from app.db.session import SessionLocal
from app.models.artifact import Artifact, ArtifactRarity
from app.models.branch import Branch, BranchMission
from app.models.coding import CodingChallenge
from app.models.mission import Mission, MissionCompetencyReward, MissionDifficulty
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
from app.models.onboarding import OnboardingSlide
@ -161,7 +162,12 @@ def seed() -> None:
description="Путь кандидата от знакомства до выхода на орбиту",
category="quest",
)
session.add(branch)
python_branch = Branch(
title="Основы Python",
description="Мини-курс из 10 задач для проверки синтаксиса и базовой логики.",
category="training",
)
session.add_all([branch, python_branch])
session.flush()
# Миссии
@ -199,7 +205,21 @@ def seed() -> None:
difficulty=MissionDifficulty.HARD,
minimum_rank_id=ranks[1].id,
)
session.add_all([mission_documents, mission_resume, mission_interview, mission_onboarding])
mission_python_basics = Mission(
title="Основные знания Python",
description="Решите 10 небольших задач и докажите, что уверенно чувствуете себя в языке программирования.",
xp_reward=250,
mana_reward=120,
difficulty=MissionDifficulty.MEDIUM,
minimum_rank_id=ranks[0].id,
)
session.add_all([
mission_documents,
mission_resume,
mission_interview,
mission_onboarding,
mission_python_basics,
])
session.flush()
session.add_all(
@ -224,6 +244,11 @@ def seed() -> None:
competency_id=competencies[3].id,
level_delta=1,
),
MissionCompetencyReward(
mission_id=mission_python_basics.id,
competency_id=competencies[2].id,
level_delta=1,
),
]
)
@ -233,6 +258,94 @@ def seed() -> None:
BranchMission(branch_id=branch.id, mission_id=mission_resume.id, order=2),
BranchMission(branch_id=branch.id, mission_id=mission_interview.id, order=3),
BranchMission(branch_id=branch.id, mission_id=mission_onboarding.id, order=4),
BranchMission(branch_id=python_branch.id, mission_id=mission_python_basics.id, order=1),
]
)
python_challenges_specs = [
{
"order": 1,
"title": "Приветствие пилота",
"prompt": "Выведите в консоль точную фразу «Привет, Python!». Без дополнительных символов или пробелов.",
"starter_code": "# Напишите одну строку с функцией print\n",
"expected_output": "Привет, Python!",
},
{
"order": 2,
"title": "Сложение топлива",
"prompt": "Создайте переменные a и b, найдите их сумму и выведите результат в формате «Сумма: 12».",
"starter_code": "a = 7\nb = 5\n# Напечатайте строку в формате: Сумма: 12\n",
"expected_output": "Сумма: 12",
},
{
"order": 3,
"title": "Площадь отсека",
"prompt": "Перемножьте длину и ширину отсека и выведите строку «Площадь: 24».",
"starter_code": "length = 8\nwidth = 3\n# Вычислите площадь и выведите результат\n",
"expected_output": "Площадь: 24",
},
{
"order": 4,
"title": "Обратный отсчёт",
"prompt": "С помощью цикла for выведите числа от 1 до 5, каждое на новой строке.",
"starter_code": "for number in range(1, 6):\n # Напечатайте текущее число\n pass\n",
"expected_output": "1\n2\n3\n4\n5",
},
{
"order": 5,
"title": "Квадраты сигналов",
"prompt": "Создайте список квадратов чисел от 1 до 5 и выведите строку «Список квадратов: [1, 4, 9, 16, 25]».",
"starter_code": "levels = [1, 2, 3, 4, 5]\n# Соберите список квадратов и напечатайте его\n",
"expected_output": "Список квадратов: [1, 4, 9, 16, 25]",
},
{
"order": 6,
"title": "Длина сообщения",
"prompt": "Определите длину строки message и выведите её как «Количество символов: 25».",
"starter_code": "message = \"Пангалактический экспресс\"\n# Посчитайте длину и выведите результат\n",
"expected_output": "Количество символов: 25",
},
{
"order": 7,
"title": "Запасы склада",
"prompt": "Пройдитесь по словарю storage и выведите информацию в формате «мануал: 3» и «датчик: 5».",
"starter_code": "storage = {\"мануал\": 3, \"датчик\": 5}\n# Выведите данные из словаря построчно\n",
"expected_output": "мануал: 3\nдатчик: 5",
},
{
"order": 8,
"title": "Проверка чётности",
"prompt": "Для чисел 2 и 7 напечатайте на отдельных строках True, если число чётное, иначе False.",
"starter_code": "numbers = [2, 7]\nfor number in numbers:\n # Напечатайте True или False в зависимости от чётности\n pass\n",
"expected_output": "True\nFalse",
},
{
"order": 9,
"title": "Сумма диапазона",
"prompt": "Посчитайте сумму чисел от 1 до 10 и выведите строку «Сумма от 1 до 10: 55».",
"starter_code": "total = 0\nfor number in range(1, 11):\n # Добавляйте число к сумме\n pass\n# После цикла выведите итог\n",
"expected_output": "Сумма от 1 до 10: 55",
},
{
"order": 10,
"title": "Факториал 5",
"prompt": "Вычислите факториал числа 5 и выведите строку «Факториал 5: 120».",
"starter_code": "result = 1\nfor number in range(1, 6):\n # Умножайте result на текущее число\n pass\n# Выведите итоговое значение\n",
"expected_output": "Факториал 5: 120",
},
]
session.add_all(
[
CodingChallenge(
mission_id=mission_python_basics.id,
order=spec["order"],
title=spec["title"],
prompt=spec["prompt"],
starter_code=spec["starter_code"],
expected_output=spec["expected_output"],
)
for spec in python_challenges_specs
]
)