From 9eb5a92f956865689e132221b7d214242b5e62e6 Mon Sep 17 00:00:00 2001 From: danilgryaznev Date: Mon, 29 Sep 2025 11:59:25 -0600 Subject: [PATCH] Added coderedactor --- .../20240927_0007_python_mission_tables.py | 64 +++++++ backend/app/api/routes/__init__.py | 3 +- backend/app/api/routes/missions.py | 40 ++++ backend/app/api/routes/python.py | 46 +++++ backend/app/main.py | 3 +- backend/app/models/__init__.py | 4 + backend/app/models/python.py | 59 ++++++ backend/app/schemas/mission.py | 3 + backend/app/schemas/python.py | 56 ++++++ backend/app/services/python_mission.py | 181 ++++++++++++++++++ scripts/seed_data.py | 26 ++- 11 files changed, 481 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/20240927_0007_python_mission_tables.py create mode 100644 backend/app/api/routes/python.py create mode 100644 backend/app/models/python.py create mode 100644 backend/app/schemas/python.py create mode 100644 backend/app/services/python_mission.py 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/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 e95a010..d60b25e 100644 --- a/backend/app/api/routes/missions.py +++ b/backend/app/api/routes/missions.py @@ -12,6 +12,7 @@ from app.db.session import get_db 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, @@ -207,6 +208,23 @@ def list_missions( dto.is_completed = False dto.is_available = is_available dto.locked_reasons = reasons + + total_python = ( + db.query(PythonChallenge) + .filter(PythonChallenge.mission_id == mission.id) + .count() + ) + if total_python: + progress = ( + db.query(PythonUserProgress) + .filter( + PythonUserProgress.user_id == current_user.id, + PythonUserProgress.mission_id == mission.id, + ) + .first() + ) + dto.python_challenges_total = total_python + dto.python_completed_challenges = progress.current_order if progress else 0 response.append(dto) return response @@ -272,6 +290,28 @@ def get_mission( updated_at=mission.updated_at, ) data.requires_documents = mission.id in REQUIRED_DOCUMENT_MISSIONS + + total_python = ( + db.query(PythonChallenge) + .filter(PythonChallenge.mission_id == mission.id) + .count() + ) + if total_python: + progress = ( + db.query(PythonUserProgress) + .filter( + PythonUserProgress.user_id == current_user.id, + PythonUserProgress.mission_id == mission.id, + ) + .first() + ) + data.python_challenges_total = total_python + data.python_completed_challenges = progress.current_order if progress else 0 + if progress and progress.current_order >= total_python: + data.is_completed = True + data.is_available = False + data.locked_reasons = ["Миссия уже завершена"] + if mission.id in completed_missions: data.is_completed = True data.is_available = False 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 8e2ba38..3df00c3 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,6 +5,7 @@ from .branch import Branch, BranchMission # noqa: F401 from .journal import JournalEntry # noqa: F401 from .mission import Mission, MissionCompetencyReward, MissionPrerequisite, MissionSubmission # 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 @@ -20,6 +21,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/mission.py b/backend/app/schemas/mission.py index 8d2917d..1ae62cc 100644 --- a/backend/app/schemas/mission.py +++ b/backend/app/schemas/mission.py @@ -24,6 +24,9 @@ class MissionBase(BaseModel): locked_reasons: list[str] = Field(default_factory=list) is_completed: bool = False requires_documents: bool = False + python_challenges_total: Optional[int] = None + python_completed_challenges: Optional[int] = None + requires_documents: bool = False is_completed: bool = False class Config: 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/scripts/seed_data.py b/scripts/seed_data.py index f3e6444..b6f2627 100644 --- a/scripts/seed_data.py +++ b/scripts/seed_data.py @@ -20,6 +20,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 @@ -161,7 +162,12 @@ def seed() -> None: description="Путь кандидата от знакомства до выхода на орбиту", category="quest", ) - session.add(branch) + python_branch = Branch( + title="Галактокод", + description="Практика языка Python по мотивам путеводителя.", + category="training", + ) + session.add_all([branch, python_branch]) session.flush() # Миссии @@ -199,7 +205,16 @@ 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 = Mission( + title="Основы Python", + description="Решите последовательность задач, чтобы доказать владение базовыми конструкциями языка.", + xp_reward=250, + mana_reward=120, + difficulty=MissionDifficulty.MEDIUM, + minimum_rank_id=ranks[0].id, + artifact_id=artifact_by_name["Путеводитель по Галактике"].id, + ) + session.add_all([mission_documents, mission_resume, mission_interview, mission_onboarding, mission_python]) session.flush() session.add_all( @@ -224,6 +239,11 @@ def seed() -> None: competency_id=competencies[3].id, level_delta=1, ), + MissionCompetencyReward( + mission_id=mission_python.id, + competency_id=competencies[2].id, + level_delta=1, + ), ] ) @@ -233,6 +253,7 @@ 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.id, order=1), ] ) @@ -242,6 +263,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.id), ] )