Add coding mission tables and models for programming challenges

- Introduced `coding_challenges` and `coding_attempts` tables in the database schema.
- Created corresponding SQLAlchemy models for `CodingChallenge` and `CodingAttempt`.
- Implemented service functions for evaluating coding challenges and managing user attempts.
- Added Pydantic schemas for API interactions related to coding missions.
- Updated frontend components to support coding challenges, including a new `CodingMissionPanel` for user interaction.
- Enhanced mission list and detail views to display coding challenge progress.
This commit is contained in:
danilgryaznev 2025-09-29 12:11:55 -06:00
parent 9eb5a92f95
commit 8d51c932ce
14 changed files with 1118 additions and 13 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
@ -19,6 +20,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
@ -88,6 +96,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)
@ -180,6 +273,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)
@ -188,6 +282,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:
@ -239,7 +338,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="Миссия не найдена")
@ -253,6 +357,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,
@ -319,6 +424,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 .python import PythonChallenge, PythonSubmission, PythonUserProgress # noqa: F401
from .rank import Rank, RankCompetencyRequirement, RankMissionRequirement # noqa: F401
@ -15,6 +16,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

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