diff --git a/backend/alembic/versions/20240927_0007_python_mission_tables.py b/backend/alembic/versions/20240927_0007_python_mission_tables.py new file mode 100644 index 0000000..e20c9fb --- /dev/null +++ b/backend/alembic/versions/20240927_0007_python_mission_tables.py @@ -0,0 +1,64 @@ +"""Add python mission tables""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '20240927_0007' +down_revision = '20240927_0006' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'python_challenges', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('mission_id', sa.Integer(), sa.ForeignKey('missions.id', ondelete='CASCADE'), nullable=False), + sa.Column('order', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('input_data', sa.Text(), nullable=True), + sa.Column('expected_output', sa.Text(), nullable=False), + sa.Column('starter_code', sa.Text(), nullable=True), + 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(), server_onupdate=sa.func.now(), nullable=False), + sa.UniqueConstraint('mission_id', 'order', name='uq_python_challenge_mission_order'), + ) + + op.create_table( + 'python_user_progress', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('mission_id', sa.Integer(), sa.ForeignKey('missions.id', ondelete='CASCADE'), nullable=False), + sa.Column('current_order', sa.Integer(), nullable=False, server_default='0'), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), + 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(), server_onupdate=sa.func.now(), nullable=False), + sa.UniqueConstraint('user_id', 'mission_id', name='uq_python_progress_user_mission'), + ) + + op.create_table( + 'python_submissions', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('progress_id', sa.Integer(), sa.ForeignKey('python_user_progress.id', ondelete='CASCADE'), nullable=False), + sa.Column('challenge_id', sa.Integer(), sa.ForeignKey('python_challenges.id', ondelete='CASCADE'), nullable=False), + sa.Column('code', sa.Text(), nullable=False), + sa.Column('stdout', sa.Text(), nullable=True), + sa.Column('stderr', sa.Text(), nullable=True), + 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), + ) + + op.create_index('ix_python_submissions_progress_id', 'python_submissions', ['progress_id']) + op.create_index('ix_python_submissions_challenge_id', 'python_submissions', ['challenge_id']) + + +def downgrade() -> None: + op.drop_index('ix_python_submissions_challenge_id', table_name='python_submissions') + op.drop_index('ix_python_submissions_progress_id', table_name='python_submissions') + op.drop_table('python_submissions') + op.drop_table('python_user_progress') + op.drop_table('python_challenges') diff --git a/backend/alembic/versions/3c5430b2cbd3_merge_python_and_coding_heads.py b/backend/alembic/versions/3c5430b2cbd3_merge_python_and_coding_heads.py new file mode 100644 index 0000000..f2a2ec9 --- /dev/null +++ b/backend/alembic/versions/3c5430b2cbd3_merge_python_and_coding_heads.py @@ -0,0 +1,19 @@ +"""Merge heads: python and coding mission branches.""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "3c5430b2cbd3" +down_revision = ("20240927_0007", "20241005_0007") +branch_labels = None +depends_on = None + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py index c6c035c..59eb177 100644 --- a/backend/app/api/routes/__init__.py +++ b/backend/app/api/routes/__init__.py @@ -1,6 +1,6 @@ """Экспортируем роутеры для подключения в приложении.""" -from . import admin, auth, journal, missions, onboarding, store, users # noqa: F401 +from . import admin, auth, journal, missions, onboarding, store, users, python # noqa: F401 __all__ = [ "admin", @@ -10,4 +10,5 @@ __all__ = [ "onboarding", "store", "users", + "python", ] diff --git a/backend/app/api/routes/missions.py b/backend/app/api/routes/missions.py index ebb9d7a..57efe95 100644 --- a/backend/app/api/routes/missions.py +++ b/backend/app/api/routes/missions.py @@ -13,6 +13,7 @@ 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 +from app.models.python import PythonChallenge, PythonUserProgress from app.schemas.branch import BranchMissionRead, BranchRead from app.schemas.mission import ( MissionBase, diff --git a/backend/app/api/routes/python.py b/backend/app/api/routes/python.py new file mode 100644 index 0000000..70fcba3 --- /dev/null +++ b/backend/app/api/routes/python.py @@ -0,0 +1,46 @@ +"""API для миссии "Основы Python".""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.db.session import get_db +from app.models.mission import Mission +from app.models.user import User +from app.schemas.python import PythonMissionState, PythonSubmitRequest, PythonSubmissionRead +from app.services.python_mission import build_state, submit_code + +router = APIRouter(prefix="/api/python-mission", tags=["python-mission"]) + + +def _get_mission(db: Session, mission_id: int) -> Mission: + mission = db.query(Mission).filter(Mission.id == mission_id, Mission.is_active.is_(True)).first() + if not mission: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена") + return mission + + +@router.get("/{mission_id}", response_model=PythonMissionState) +def mission_state( + mission_id: int, + *, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> PythonMissionState: + mission = _get_mission(db, mission_id) + return build_state(db, current_user, mission) + + +@router.post("/{mission_id}/submit", response_model=PythonSubmissionRead) +def mission_submit( + mission_id: int, + payload: PythonSubmitRequest, + *, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> PythonSubmissionRead: + mission = _get_mission(db, mission_id) + submission = submit_code(db, current_user, mission, challenge_id=payload.challenge_id, code=payload.code) + return PythonSubmissionRead.model_validate(submission) diff --git a/backend/app/main.py b/backend/app/main.py index 25826c9..26d1d7a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,7 @@ from sqlalchemy import inspect, text from sqlalchemy.orm import Session from app import models # noqa: F401 - важно, чтобы Base знала обо всех моделях -from app.api.routes import admin, auth, journal, missions, onboarding, store, users +from app.api.routes import admin, auth, journal, missions, onboarding, store, users, python from app.core.config import settings from app.core.security import get_password_hash from app.db.session import SessionLocal, engine @@ -136,6 +136,7 @@ app.include_router(missions.router) app.include_router(journal.router) app.include_router(onboarding.router) app.include_router(store.router) +app.include_router(python.router) app.include_router(admin.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 14f3449..7376541 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -6,6 +6,7 @@ from .journal import JournalEntry # noqa: F401 from .mission import Mission, MissionCompetencyReward, MissionPrerequisite, MissionSubmission # noqa: F401 from .coding import CodingAttempt, CodingChallenge # noqa: F401 from .onboarding import OnboardingSlide, OnboardingState # noqa: F401 +from .python import PythonChallenge, PythonSubmission, PythonUserProgress # noqa: F401 from .rank import Rank, RankCompetencyRequirement, RankMissionRequirement # noqa: F401 from .store import Order, StoreItem # noqa: F401 from .user import Competency, User, UserArtifact, UserCompetency # noqa: F401 @@ -23,6 +24,9 @@ __all__ = [ "MissionSubmission", "OnboardingSlide", "OnboardingState", + "PythonChallenge", + "PythonSubmission", + "PythonUserProgress", "Rank", "RankCompetencyRequirement", "RankMissionRequirement", diff --git a/backend/app/models/python.py b/backend/app/models/python.py new file mode 100644 index 0000000..f57bd3e --- /dev/null +++ b/backend/app/models/python.py @@ -0,0 +1,59 @@ +"""Модели миссии по Python и пользовательского прогресса.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy import Boolean, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin + + +class PythonChallenge(Base, TimestampMixin): + """Задание в рамках миссии "Основы Python".""" + + __tablename__ = "python_challenges" + + id: Mapped[int] = mapped_column(primary_key=True) + mission_id: Mapped[int] = mapped_column(ForeignKey("missions.id", ondelete="CASCADE"), nullable=False) + order: Mapped[int] = mapped_column(Integer, nullable=False) + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False) + input_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + expected_output: Mapped[str] = mapped_column(Text, nullable=False) + starter_code: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + submissions: Mapped[list["PythonSubmission"]] = relationship("PythonSubmission", back_populates="challenge") + + +class PythonUserProgress(Base, TimestampMixin): + """Прогресс пользователя по задачам Python-миссии.""" + + __tablename__ = "python_user_progress" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + mission_id: Mapped[int] = mapped_column(ForeignKey("missions.id", ondelete="CASCADE"), nullable=False) + current_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + completed_at: Mapped[Optional[datetime]] = mapped_column(nullable=True) + + submissions: Mapped[list["PythonSubmission"]] = relationship("PythonSubmission", back_populates="progress") + + +class PythonSubmission(Base, TimestampMixin): + """Хранение попыток решения конкретной задачи.""" + + __tablename__ = "python_submissions" + + id: Mapped[int] = mapped_column(primary_key=True) + progress_id: Mapped[int] = mapped_column(ForeignKey("python_user_progress.id", ondelete="CASCADE"), nullable=False) + challenge_id: Mapped[int] = mapped_column(ForeignKey("python_challenges.id", ondelete="CASCADE"), nullable=False) + code: Mapped[str] = mapped_column(Text, nullable=False) + stdout: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + stderr: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + is_passed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + progress: Mapped[PythonUserProgress] = relationship("PythonUserProgress", back_populates="submissions") + challenge: Mapped[PythonChallenge] = relationship("PythonChallenge", back_populates="submissions") diff --git a/backend/app/schemas/python.py b/backend/app/schemas/python.py new file mode 100644 index 0000000..dc78c08 --- /dev/null +++ b/backend/app/schemas/python.py @@ -0,0 +1,56 @@ +"""Pydantic-схемы для Python-миссии.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + + +class PythonSubmissionRead(BaseModel): + """История попыток по конкретному заданию.""" + + id: int + challenge_id: int + code: str + stdout: Optional[str] + stderr: Optional[str] + is_passed: bool + created_at: datetime + + class Config: + from_attributes = True + + +class PythonChallengeRead(BaseModel): + """Описание задания, доступное пользователю.""" + + id: int + order: int + title: str + description: str + input_data: Optional[str] + starter_code: Optional[str] + + class Config: + from_attributes = True + + +class PythonMissionState(BaseModel): + """Сводка по прогрессу в Python-миссии.""" + + mission_id: int + total_challenges: int + completed_challenges: int + is_completed: bool + next_challenge: Optional[PythonChallengeRead] + last_submissions: list[PythonSubmissionRead] + last_code: Optional[str] = None + + +class PythonSubmitRequest(BaseModel): + """Запрос на проверку решения задачи.""" + + challenge_id: int + code: str diff --git a/backend/app/services/python_mission.py b/backend/app/services/python_mission.py new file mode 100644 index 0000000..4e78181 --- /dev/null +++ b/backend/app/services/python_mission.py @@ -0,0 +1,181 @@ +"""Логика миссии по Python.""" + +from __future__ import annotations + +import subprocess +import sys +from datetime import datetime +from textwrap import dedent +from typing import Optional + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.models.mission import Mission, MissionSubmission +from app.models.python import PythonChallenge, PythonSubmission, PythonUserProgress +from app.models.user import User +from app.schemas.python import PythonMissionState, PythonChallengeRead, PythonSubmissionRead +from app.services.mission import submit_mission + +EVAL_TIMEOUT_SECONDS = 3 + + +def _normalize_stdout(value: str | None) -> str: + if value is None: + return "" + return value.replace('\r\n', '\n').rstrip('\n').strip() + + +def get_progress(db: Session, user: User, mission: Mission) -> PythonUserProgress: + progress = ( + db.query(PythonUserProgress) + .filter(PythonUserProgress.user_id == user.id, PythonUserProgress.mission_id == mission.id) + .first() + ) + if not progress: + progress = PythonUserProgress(user_id=user.id, mission_id=mission.id, current_order=0) + db.add(progress) + db.commit() + db.refresh(progress) + return progress + + +def build_state(db: Session, user: User, mission: Mission) -> PythonMissionState: + progress = get_progress(db, user, mission) + challenges = ( + db.query(PythonChallenge) + .filter(PythonChallenge.mission_id == mission.id) + .order_by(PythonChallenge.order) + .all() + ) + total = len(challenges) + completed = progress.current_order + + next_challenge: Optional[PythonChallenge] = None + last_code: Optional[str] = None + last_submissions: list[PythonSubmission] = [] + + if completed < total: + next_challenge = challenges[completed] + last_submission = ( + db.query(PythonSubmission) + .filter( + PythonSubmission.progress_id == progress.id, + PythonSubmission.challenge_id == next_challenge.id, + ) + .order_by(PythonSubmission.created_at.desc()) + .first() + ) + if last_submission: + last_code = last_submission.code + else: + last_submission = ( + db.query(PythonSubmission) + .filter(PythonSubmission.progress_id == progress.id) + .order_by(PythonSubmission.created_at.desc()) + .first() + ) + if last_submission: + last_code = last_submission.code + + last_submissions = ( + db.query(PythonSubmission) + .filter(PythonSubmission.progress_id == progress.id) + .order_by(PythonSubmission.created_at.desc()) + .limit(5) + .all() + ) + + return PythonMissionState( + mission_id=mission.id, + total_challenges=total, + completed_challenges=completed, + is_completed=completed >= total, + next_challenge=PythonChallengeRead.model_validate(next_challenge) if next_challenge else None, + last_submissions=[PythonSubmissionRead.model_validate(item) for item in last_submissions], + last_code=last_code, + ) + + +def submit_code(db: Session, user: User, mission: Mission, challenge_id: int, code: str) -> PythonSubmission: + progress = get_progress(db, user, mission) + + challenge = ( + db.query(PythonChallenge) + .filter(PythonChallenge.id == challenge_id, PythonChallenge.mission_id == mission.id) + .first() + ) + if not challenge: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Задание не найдено") + + expected_order = progress.current_order + 1 + if challenge.order != expected_order: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Сначала выполните предыдущее задание", + ) + + prepared_code = dedent(code) + + try: + completed = subprocess.run( + [sys.executable, "-c", prepared_code], + input=(challenge.input_data or ""), + capture_output=True, + text=True, + timeout=EVAL_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Время выполнения превышено") + + stdout = completed.stdout or "" + stderr = completed.stderr or "" + + expected = _normalize_stdout(challenge.expected_output) + actual = _normalize_stdout(stdout) + + is_passed = completed.returncode == 0 and actual == expected + + submission = PythonSubmission( + progress_id=progress.id, + challenge_id=challenge.id, + code=prepared_code, + stdout=stdout, + stderr=stderr, + is_passed=is_passed, + ) + db.add(submission) + + if is_passed: + progress.current_order = challenge.order + total = ( + db.query(PythonChallenge) + .filter(PythonChallenge.mission_id == mission.id) + .count() + ) + if progress.current_order >= total: + progress.completed_at = datetime.utcnow() + ensure_mission_completed(db, user, mission) + + db.commit() + db.refresh(submission) + db.refresh(progress) + return submission + + +def ensure_mission_completed(db: Session, user: User, mission: Mission) -> None: + existing_submission = ( + db.query(MissionSubmission) + .filter(MissionSubmission.user_id == user.id, MissionSubmission.mission_id == mission.id) + .first() + ) + if existing_submission and existing_submission.status.value == "approved": + return + + submit_mission( + db=db, + user=user, + mission=mission, + comment="Задачи Python-миссии решены", + proof_url=None, + ) diff --git a/frontend/src/components/CodingMissionPanel.tsx b/frontend/src/components/CodingMissionPanel.tsx index cdeca0a..7a92a83 100644 --- a/frontend/src/components/CodingMissionPanel.tsx +++ b/frontend/src/components/CodingMissionPanel.tsx @@ -1,6 +1,6 @@ 'use client'; - import { useEffect, useMemo, useRef, useState } from 'react'; + import { apiFetch } from '../lib/api'; interface CodingChallengeState { @@ -52,11 +52,11 @@ export function CodingMissionPanel({ missionId, token, initialState, initialComp const [loading, setLoading] = useState(false); const previousChallengeIdRef = useRef(null); + const activeChallenge = useMemo(() => { if (!state || !state.challenges.length) { return null; } - if (state.current_challenge_id) { const current = state.challenges.find((challenge) => challenge.id === state.current_challenge_id); if (current) { diff --git a/scripts/seed_data.py b/scripts/seed_data.py index ee0d142..7bada50 100644 --- a/scripts/seed_data.py +++ b/scripts/seed_data.py @@ -21,6 +21,7 @@ from app.models.mission import Mission, MissionCompetencyReward, MissionDifficul from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement from app.models.onboarding import OnboardingSlide from app.models.store import StoreItem +from app.models.python import PythonChallenge from app.models.user import Competency, CompetencyCategory, User, UserCompetency, UserRole, UserArtifact from app.models.journal import JournalEntry, JournalEventType from app.main import run_migrations @@ -355,6 +356,7 @@ def seed() -> None: RankMissionRequirement(rank_id=ranks[1].id, mission_id=mission_resume.id), RankMissionRequirement(rank_id=ranks[2].id, mission_id=mission_interview.id), RankMissionRequirement(rank_id=ranks[2].id, mission_id=mission_onboarding.id), + RankMissionRequirement(rank_id=ranks[2].id, mission_id=mission_python_basics.id), ] )