Added coderedactor

This commit is contained in:
danilgryaznev 2025-09-29 11:59:25 -06:00
parent 2aea7e840e
commit 9eb5a92f95
11 changed files with 481 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

@ -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",
]

View File

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

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

View File

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

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

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

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

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