Merge branch 'main' into codex/add-python-programming-mission-module-ygunqb

This commit is contained in:
Danil Gryaznev 2025-09-29 12:44:11 -06:00 committed by GitHub
commit bebfb769c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 438 additions and 4 deletions

View File

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

View File

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

View File

@ -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__ = [ __all__ = [
"admin", "admin",
@ -10,4 +10,5 @@ __all__ = [
"onboarding", "onboarding",
"store", "store",
"users", "users",
"python",
] ]

View File

@ -13,6 +13,7 @@ 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
from app.models.python import PythonChallenge, PythonUserProgress
from app.schemas.branch import BranchMissionRead, BranchRead from app.schemas.branch import BranchMissionRead, BranchRead
from app.schemas.mission import ( from app.schemas.mission import (
MissionBase, MissionBase,

View File

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

View File

@ -13,7 +13,7 @@ from sqlalchemy import inspect, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import models # noqa: F401 - важно, чтобы Base знала обо всех моделях 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.config import settings
from app.core.security import get_password_hash from app.core.security import get_password_hash
from app.db.session import SessionLocal, engine from app.db.session import SessionLocal, engine
@ -136,6 +136,7 @@ app.include_router(missions.router)
app.include_router(journal.router) app.include_router(journal.router)
app.include_router(onboarding.router) app.include_router(onboarding.router)
app.include_router(store.router) app.include_router(store.router)
app.include_router(python.router)
app.include_router(admin.router) app.include_router(admin.router)

View File

@ -6,6 +6,7 @@ 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 .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 .rank import Rank, RankCompetencyRequirement, RankMissionRequirement # noqa: F401 from .rank import Rank, RankCompetencyRequirement, RankMissionRequirement # noqa: F401
from .store import Order, StoreItem # noqa: F401 from .store import Order, StoreItem # noqa: F401
from .user import Competency, User, UserArtifact, UserCompetency # noqa: F401 from .user import Competency, User, UserArtifact, UserCompetency # noqa: F401
@ -23,6 +24,9 @@ __all__ = [
"MissionSubmission", "MissionSubmission",
"OnboardingSlide", "OnboardingSlide",
"OnboardingState", "OnboardingState",
"PythonChallenge",
"PythonSubmission",
"PythonUserProgress",
"Rank", "Rank",
"RankCompetencyRequirement", "RankCompetencyRequirement",
"RankMissionRequirement", "RankMissionRequirement",

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { apiFetch } from '../lib/api'; import { apiFetch } from '../lib/api';
interface CodingChallengeState { interface CodingChallengeState {
@ -52,11 +52,11 @@ export function CodingMissionPanel({ missionId, token, initialState, initialComp
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const previousChallengeIdRef = useRef<number | null>(null); const previousChallengeIdRef = useRef<number | null>(null);
const activeChallenge = useMemo(() => { const activeChallenge = useMemo(() => {
if (!state || !state.challenges.length) { if (!state || !state.challenges.length) {
return null; return null;
} }
if (state.current_challenge_id) { if (state.current_challenge_id) {
const current = state.challenges.find((challenge) => challenge.id === state.current_challenge_id); const current = state.challenges.find((challenge) => challenge.id === state.current_challenge_id);
if (current) { if (current) {

View File

@ -21,6 +21,7 @@ from app.models.mission import Mission, MissionCompetencyReward, MissionDifficul
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
from app.models.store import StoreItem 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.user import Competency, CompetencyCategory, User, UserCompetency, UserRole, UserArtifact
from app.models.journal import JournalEntry, JournalEventType from app.models.journal import JournalEntry, JournalEventType
from app.main import run_migrations 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[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_interview.id),
RankMissionRequirement(rank_id=ranks[2].id, mission_id=mission_onboarding.id), RankMissionRequirement(rank_id=ranks[2].id, mission_id=mission_onboarding.id),
RankMissionRequirement(rank_id=ranks[2].id, mission_id=mission_python_basics.id),
] ]
) )