Fix coding mission panel state handling
This commit is contained in:
parent
2aea7e840e
commit
5d7dba5ecb
68
backend/alembic/versions/20241005_0007_coding_missions.py
Normal file
68
backend/alembic/versions/20241005_0007_coding_missions.py
Normal 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")
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
52
backend/app/models/coding.py
Normal file
52
backend/app/models/coding.py
Normal 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")
|
||||
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
55
backend/app/schemas/coding.py
Normal file
55
backend/app/schemas/coding.py
Normal 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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
202
backend/app/services/coding.py
Normal file
202
backend/app/services/coding.py
Normal 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}
|
||||
|
||||
54
backend/app/utils/python_runner.py
Normal file
54
backend/app/utils/python_runner.py
Normal 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,
|
||||
)
|
||||
|
||||
107
backend/tests/test_coding_mission.py
Normal file
107
backend/tests/test_coding_mission.py
Normal 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
|
||||
|
||||
|
|
@ -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,14 +136,23 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
|||
{mission.competency_rewards.length === 0 && <li>Нет прокачки компетенций.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
<MissionSubmissionForm
|
||||
missionId={mission.id}
|
||||
token={session.token}
|
||||
locked={!mission.is_available && !mission.is_completed}
|
||||
completed={mission.is_completed}
|
||||
requiresDocuments={mission.requires_documents}
|
||||
submission={submission ?? undefined}
|
||||
/>
|
||||
{mission.has_coding_challenges ? (
|
||||
<CodingMissionPanel
|
||||
missionId={mission.id}
|
||||
token={session.token}
|
||||
initialState={codingState}
|
||||
initialCompleted={mission.is_completed}
|
||||
/>
|
||||
) : (
|
||||
<MissionSubmissionForm
|
||||
missionId={mission.id}
|
||||
token={session.token}
|
||||
locked={!mission.is_available && !mission.is_completed}
|
||||
completed={mission.is_completed}
|
||||
requiresDocuments={mission.requires_documents}
|
||||
submission={submission ?? undefined}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
275
frontend/src/components/CodingMissionPanel.tsx
Normal file
275
frontend/src/components/CodingMissionPanel.tsx
Normal file
|
|
@ -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<CodingMissionState | null>(initialState);
|
||||
const [missionCompleted, setMissionCompleted] = useState(initialState?.is_mission_completed || initialCompleted);
|
||||
const [editorCode, setEditorCode] = useState<string>('');
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [runResult, setRunResult] = useState<RunResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const previousChallengeIdRef = useRef<number | null>(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 (
|
||||
<div className="card" style={{ marginTop: '2rem' }}>
|
||||
<p>
|
||||
Не удалось загрузить задания для миссии. Попробуйте обновить страницу или обратитесь к HR, чтобы
|
||||
проверить настройки миссии.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const updated = await apiFetch<CodingMissionState>(`/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<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ export function OnboardingCarousel({ token, slides, initialCompletedOrder, nextO
|
|||
border: '1px solid rgba(162, 155, 254, 0.25)'
|
||||
}}
|
||||
>
|
||||
{/* В медиаконтенте могут встречаться внешние изображения, которые Next.js не умеет оптимизировать автоматически. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={currentSlide.media_url} alt={currentSlide.title} style={{ width: '100%', height: 'auto' }} />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user