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:
parent
9eb5a92f95
commit
8d51c932ce
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.api.deps import get_current_user
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
|
from app.models.coding import CodingAttempt, CodingChallenge
|
||||||
from app.models.branch import Branch, BranchMission
|
from app.models.branch import Branch, BranchMission
|
||||||
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
|
|
@ -19,6 +20,13 @@ from app.schemas.mission import (
|
||||||
MissionDetail,
|
MissionDetail,
|
||||||
MissionSubmissionRead,
|
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.mission import UNSET, submit_mission
|
||||||
from app.services.storage import delete_submission_document, save_submission_document
|
from app.services.storage import delete_submission_document, save_submission_document
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
@ -88,6 +96,91 @@ def _mission_availability(
|
||||||
return is_available, reasons
|
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="Список веток миссий")
|
@router.get("/branches", response_model=list[BranchRead], summary="Список веток миссий")
|
||||||
def list_branches(
|
def list_branches(
|
||||||
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||||
|
|
@ -180,6 +273,7 @@ def list_missions(
|
||||||
.options(
|
.options(
|
||||||
selectinload(Mission.prerequisites),
|
selectinload(Mission.prerequisites),
|
||||||
selectinload(Mission.minimum_rank),
|
selectinload(Mission.minimum_rank),
|
||||||
|
selectinload(Mission.coding_challenges),
|
||||||
)
|
)
|
||||||
.filter(Mission.is_active.is_(True))
|
.filter(Mission.is_active.is_(True))
|
||||||
.order_by(Mission.id)
|
.order_by(Mission.id)
|
||||||
|
|
@ -188,6 +282,11 @@ def list_missions(
|
||||||
|
|
||||||
mission_titles = {mission.id: mission.title for mission in missions}
|
mission_titles = {mission.id: mission.title for mission in missions}
|
||||||
completed_missions = _load_user_progress(current_user)
|
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] = []
|
response: list[MissionBase] = []
|
||||||
for mission in missions:
|
for mission in missions:
|
||||||
|
|
@ -239,7 +338,12 @@ def get_mission(
|
||||||
) -> MissionDetail:
|
) -> 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:
|
if not mission:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена")
|
||||||
|
|
||||||
|
|
@ -253,6 +357,7 @@ def get_mission(
|
||||||
branch_dependencies = _build_branch_dependencies(branches)
|
branch_dependencies = _build_branch_dependencies(branches)
|
||||||
completed_missions = _load_user_progress(current_user)
|
completed_missions = _load_user_progress(current_user)
|
||||||
mission_titles = dict(db.query(Mission.id, Mission.title).all())
|
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(
|
is_available, reasons = _mission_availability(
|
||||||
mission=mission,
|
mission=mission,
|
||||||
|
|
@ -319,6 +424,124 @@ def get_mission(
|
||||||
return data
|
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="Отправляем отчёт")
|
@router.post("/{mission_id}/submit", response_model=MissionSubmissionRead, summary="Отправляем отчёт")
|
||||||
async def submit(
|
async def submit(
|
||||||
mission_id: int,
|
mission_id: int,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from .artifact import Artifact # noqa: F401
|
||||||
from .branch import Branch, BranchMission # noqa: F401
|
from .branch import Branch, BranchMission # noqa: F401
|
||||||
from .journal import JournalEntry # noqa: F401
|
from .journal import JournalEntry # noqa: F401
|
||||||
from .mission import Mission, MissionCompetencyReward, MissionPrerequisite, MissionSubmission # 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 .onboarding import OnboardingSlide, OnboardingState # noqa: F401
|
||||||
from .python import PythonChallenge, PythonSubmission, PythonUserProgress # noqa: F401
|
from .python import PythonChallenge, PythonSubmission, PythonUserProgress # noqa: F401
|
||||||
from .rank import Rank, RankCompetencyRequirement, RankMissionRequirement # noqa: F401
|
from .rank import Rank, RankCompetencyRequirement, RankMissionRequirement # noqa: F401
|
||||||
|
|
@ -15,6 +16,8 @@ __all__ = [
|
||||||
"Branch",
|
"Branch",
|
||||||
"BranchMission",
|
"BranchMission",
|
||||||
"JournalEntry",
|
"JournalEntry",
|
||||||
|
"CodingChallenge",
|
||||||
|
"CodingAttempt",
|
||||||
"Mission",
|
"Mission",
|
||||||
"MissionCompetencyReward",
|
"MissionCompetencyReward",
|
||||||
"MissionPrerequisite",
|
"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 __future__ import annotations
|
||||||
|
|
||||||
from enum import Enum
|
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 import Boolean, Enum as SQLEnum, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin
|
from app.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.coding import CodingChallenge
|
||||||
|
|
||||||
|
|
||||||
class MissionDifficulty(str, Enum):
|
class MissionDifficulty(str, Enum):
|
||||||
"""Условные уровни сложности."""
|
"""Условные уровни сложности."""
|
||||||
|
|
@ -56,6 +59,12 @@ class Mission(Base, TimestampMixin):
|
||||||
rank_requirements: Mapped[List["RankMissionRequirement"]] = relationship(
|
rank_requirements: Mapped[List["RankMissionRequirement"]] = relationship(
|
||||||
"RankMissionRequirement", back_populates="mission"
|
"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):
|
class MissionCompetencyReward(Base, TimestampMixin):
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
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 import Boolean, DateTime, Enum as SQLEnum, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
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):
|
class UserRole(str, Enum):
|
||||||
"""Типы ролей в системе."""
|
"""Типы ролей в системе."""
|
||||||
|
|
||||||
|
|
@ -61,6 +64,9 @@ class User(Base, TimestampMixin):
|
||||||
onboarding_state: Mapped[Optional["OnboardingState"]] = relationship(
|
onboarding_state: Mapped[Optional["OnboardingState"]] = relationship(
|
||||||
"OnboardingState", back_populates="user", cascade="all, delete-orphan", uselist=False
|
"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):
|
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
|
||||||
|
|
||||||
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 { apiFetch } from '../../../lib/api';
|
||||||
import { requireSession } from '../../../lib/auth/session';
|
import { requireSession } from '../../../lib/auth/session';
|
||||||
import { MissionSubmissionForm } from '../../../components/MissionSubmissionForm';
|
import { MissionSubmissionForm } from '../../../components/MissionSubmissionForm';
|
||||||
|
import { CodingMissionPanel } from '../../../components/CodingMissionPanel';
|
||||||
|
|
||||||
interface MissionDetail {
|
interface MissionDetail {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -21,6 +22,9 @@ interface MissionDetail {
|
||||||
locked_reasons: string[];
|
locked_reasons: string[];
|
||||||
is_completed: boolean;
|
is_completed: boolean;
|
||||||
requires_documents: boolean;
|
requires_documents: boolean;
|
||||||
|
has_coding_challenges: boolean;
|
||||||
|
coding_challenge_count: number;
|
||||||
|
completed_coding_challenges: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMission(id: number, token: string) {
|
async function fetchMission(id: number, token: string) {
|
||||||
|
|
@ -46,11 +50,40 @@ interface MissionPageProps {
|
||||||
params: { id: string };
|
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) {
|
export default async function MissionPage({ params }: MissionPageProps) {
|
||||||
const missionId = Number(params.id);
|
const missionId = Number(params.id);
|
||||||
// Даже при прямом переходе на URL миссия доступна только авторизованным пользователям.
|
// Даже при прямом переходе на URL миссия доступна только авторизованным пользователям.
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
const { mission, submission } = await fetchMission(missionId, session.token);
|
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 (
|
return (
|
||||||
<section>
|
<section>
|
||||||
|
|
@ -60,6 +93,11 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
||||||
<p style={{ marginTop: '1rem' }}>
|
<p style={{ marginTop: '1rem' }}>
|
||||||
Награда: {mission.xp_reward} XP · {mission.mana_reward} ⚡
|
Награда: {mission.xp_reward} XP · {mission.mana_reward} ⚡
|
||||||
</p>
|
</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 && (
|
{mission.is_completed && (
|
||||||
<div
|
<div
|
||||||
className="card"
|
className="card"
|
||||||
|
|
@ -71,7 +109,9 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
||||||
>
|
>
|
||||||
<strong>Миссия завершена</strong>
|
<strong>Миссия завершена</strong>
|
||||||
<p style={{ marginTop: '0.5rem', color: 'var(--text-muted)' }}>
|
<p style={{ marginTop: '0.5rem', color: 'var(--text-muted)' }}>
|
||||||
HR уже подтвердил выполнение. Вы можете просмотреть прикреплённые документы ниже.
|
{mission.has_coding_challenges
|
||||||
|
? 'Система проверила код автоматически и уже начислила награды.'
|
||||||
|
: 'HR уже подтвердил выполнение. Вы можете просмотреть прикреплённые документы ниже.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -96,14 +136,23 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
||||||
{mission.competency_rewards.length === 0 && <li>Нет прокачки компетенций.</li>}
|
{mission.competency_rewards.length === 0 && <li>Нет прокачки компетенций.</li>}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<MissionSubmissionForm
|
{mission.has_coding_challenges ? (
|
||||||
missionId={mission.id}
|
<CodingMissionPanel
|
||||||
token={session.token}
|
missionId={mission.id}
|
||||||
locked={!mission.is_available && !mission.is_completed}
|
token={session.token}
|
||||||
completed={mission.is_completed}
|
initialState={codingState}
|
||||||
requiresDocuments={mission.requires_documents}
|
initialCompleted={mission.is_completed}
|
||||||
submission={submission ?? undefined}
|
/>
|
||||||
/>
|
) : (
|
||||||
|
<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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
266
frontend/src/components/CodingMissionPanel.tsx
Normal file
266
frontend/src/components/CodingMissionPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -14,6 +14,9 @@ export interface MissionSummary {
|
||||||
locked_reasons: string[];
|
locked_reasons: string[];
|
||||||
is_completed: boolean;
|
is_completed: boolean;
|
||||||
requires_documents: boolean;
|
requires_documents: boolean;
|
||||||
|
has_coding_challenges: boolean;
|
||||||
|
coding_challenge_count: number;
|
||||||
|
completed_coding_challenges: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Card = styled.div`
|
const Card = styled.div`
|
||||||
|
|
@ -38,7 +41,9 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
|
||||||
const actionLabel = completed
|
const actionLabel = completed
|
||||||
? 'Миссия выполнена'
|
? 'Миссия выполнена'
|
||||||
: mission.is_available
|
: mission.is_available
|
||||||
? 'Открыть брифинг'
|
? mission.has_coding_challenges
|
||||||
|
? 'Решать задачи'
|
||||||
|
: 'Открыть брифинг'
|
||||||
: 'Заблокировано';
|
: 'Заблокировано';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -54,6 +59,11 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
|
||||||
)}
|
)}
|
||||||
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
|
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
|
||||||
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
|
<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>
|
<p style={{ marginTop: '1rem' }}>{mission.xp_reward} XP · {mission.mana_reward} ⚡</p>
|
||||||
{locked && mission.locked_reasons.length > 0 && (
|
{locked && mission.locked_reasons.length > 0 && (
|
||||||
<p style={{ color: 'var(--error)', fontSize: '0.85rem' }}>{mission.locked_reasons[0]}</p>
|
<p style={{ color: 'var(--error)', fontSize: '0.85rem' }}>{mission.locked_reasons[0]}</p>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from app.core.security import get_password_hash
|
||||||
from app.db.session import SessionLocal
|
from app.db.session import SessionLocal
|
||||||
from app.models.artifact import Artifact, ArtifactRarity
|
from app.models.artifact import Artifact, ArtifactRarity
|
||||||
from app.models.branch import Branch, BranchMission
|
from app.models.branch import Branch, BranchMission
|
||||||
|
from app.models.coding import CodingChallenge
|
||||||
from app.models.mission import Mission, MissionCompetencyReward, MissionDifficulty
|
from app.models.mission import Mission, MissionCompetencyReward, MissionDifficulty
|
||||||
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
||||||
from app.models.onboarding import OnboardingSlide
|
from app.models.onboarding import OnboardingSlide
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user