alabuga/backend/app/api/routes/missions.py
danilgryaznev 8d51c932ce 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.
2025-09-29 12:11:55 -06:00

732 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Маршруты для работы с миссиями."""
from __future__ import annotations
from collections import defaultdict
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session, selectinload
from app.api.deps import get_current_user
from app.db.session import get_db
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,
MissionDetail,
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.storage import delete_submission_document, save_submission_document
from app.core.config import settings
router = APIRouter(prefix="/api/missions", tags=["missions"])
# Для миссии #1 требуется обязательное прикрепление документов.
REQUIRED_DOCUMENT_MISSIONS = {1}
def _load_user_progress(user: User) -> set[int]:
"""Возвращаем идентификаторы успешно завершённых миссий."""
completed = {
submission.mission_id
for submission in user.submissions
if submission.status == SubmissionStatus.APPROVED
}
return completed
def _build_branch_dependencies(branches: list[Branch]) -> dict[int, set[int]]:
"""Строим карту зависимостей миссий по веткам."""
dependencies: dict[int, set[int]] = defaultdict(set)
for branch in branches:
ordered = sorted(branch.missions, key=lambda item: item.order)
previous: list[int] = []
for link in ordered:
if previous:
dependencies[link.mission_id].update(previous)
previous.append(link.mission_id)
return dependencies
def _mission_availability(
*,
mission: Mission,
user: User,
completed_missions: set[int],
branch_dependencies: dict[int, set[int]],
mission_titles: dict[int, str],
) -> tuple[bool, list[str]]:
"""Определяем, доступна ли миссия и формируем причины блокировки."""
reasons: list[str] = []
if mission.minimum_rank and user.xp < mission.minimum_rank.required_xp:
reasons.append(f"Требуется ранг «{mission.minimum_rank.title}»")
missing_explicit = [
req.required_mission_id
for req in mission.prerequisites
if req.required_mission_id not in completed_missions
]
for mission_id in missing_explicit:
reasons.append(f"Завершите миссию «{mission_titles.get(mission_id, '#'+str(mission_id))}»")
for mission_id in branch_dependencies.get(mission.id, set()):
if mission_id not in completed_missions:
reasons.append(
"Продолжение ветки откроется после миссии «"
f"{mission_titles.get(mission_id, '#'+str(mission_id))}»"
)
is_available = mission.is_active and not 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="Список веток миссий")
def list_branches(
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
) -> list[BranchRead]:
"""Возвращаем ветки с упорядоченными миссиями."""
db.refresh(current_user)
_ = current_user.submissions
branches = (
db.query(Branch)
.options(selectinload(Branch.missions).selectinload(BranchMission.mission))
.order_by(Branch.title)
.all()
)
completed_missions = _load_user_progress(current_user)
branch_dependencies = _build_branch_dependencies(branches)
mission_titles = {
item.mission_id: item.mission.title if item.mission else ""
for branch in branches
for item in branch.missions
}
mission_titles.update(dict(db.query(Mission.id, Mission.title).all()))
response: list[BranchRead] = []
for branch in branches:
ordered_links = sorted(branch.missions, key=lambda link: link.order)
completed_count = sum(1 for link in ordered_links if link.mission_id in completed_missions)
total = len(ordered_links)
missions_payload = []
for link in ordered_links:
mission_obj = link.mission
mission_title = mission_obj.title if mission_obj else ""
is_completed = link.mission_id in completed_missions
if mission_obj:
is_available, _ = _mission_availability(
mission=mission_obj,
user=current_user,
completed_missions=completed_missions,
branch_dependencies=branch_dependencies,
mission_titles=mission_titles,
)
else:
is_available = False
missions_payload.append(
BranchMissionRead(
mission_id=link.mission_id,
mission_title=mission_title,
order=link.order,
is_completed=is_completed,
is_available=is_available,
)
)
response.append(
BranchRead(
id=branch.id,
title=branch.title,
description=branch.description,
category=branch.category,
missions=missions_payload,
total_missions=total,
completed_missions=completed_count,
)
)
return response
@router.get("/", response_model=list[MissionBase], summary="Список миссий")
def list_missions(
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
) -> list[MissionBase]:
"""Возвращаем доступные миссии."""
db.refresh(current_user)
_ = current_user.submissions
branches = (
db.query(Branch)
.options(selectinload(Branch.missions))
.all()
)
branch_dependencies = _build_branch_dependencies(branches)
missions = (
db.query(Mission)
.options(
selectinload(Mission.prerequisites),
selectinload(Mission.minimum_rank),
selectinload(Mission.coding_challenges),
)
.filter(Mission.is_active.is_(True))
.order_by(Mission.id)
.all()
)
mission_titles = {mission.id: mission.title for mission in missions}
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] = []
for mission in missions:
is_available, reasons = _mission_availability(
mission=mission,
user=current_user,
completed_missions=completed_missions,
branch_dependencies=branch_dependencies,
mission_titles=mission_titles,
)
dto = MissionBase.model_validate(mission)
dto.requires_documents = mission.id in REQUIRED_DOCUMENT_MISSIONS
if mission.id in completed_missions:
dto.is_completed = True
dto.is_available = False
dto.locked_reasons = ["Миссия уже завершена"]
else:
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
@router.get("/{mission_id}", response_model=MissionDetail, summary="Карточка миссии")
def get_mission(
mission_id: int,
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> MissionDetail:
"""Возвращаем подробную информацию о миссии."""
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="Миссия не найдена")
db.refresh(current_user)
_ = current_user.submissions
branches = (
db.query(Branch)
.options(selectinload(Branch.missions))
.all()
)
branch_dependencies = _build_branch_dependencies(branches)
completed_missions = _load_user_progress(current_user)
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(
mission=mission,
user=current_user,
completed_missions=completed_missions,
branch_dependencies=branch_dependencies,
mission_titles=mission_titles,
)
prerequisites = [link.required_mission_id for link in mission.prerequisites]
rewards = [
{
"competency_id": reward.competency_id,
"competency_name": reward.competency.name,
"level_delta": reward.level_delta,
}
for reward in mission.competency_rewards
]
data = MissionDetail(
id=mission.id,
title=mission.title,
description=mission.description,
xp_reward=mission.xp_reward,
mana_reward=mission.mana_reward,
difficulty=mission.difficulty,
is_active=mission.is_active,
is_available=is_available,
locked_reasons=reasons,
minimum_rank_id=mission.minimum_rank_id,
artifact_id=mission.artifact_id,
prerequisites=prerequisites,
competency_rewards=rewards,
created_at=mission.created_at,
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
data.locked_reasons = ["Миссия уже завершена"]
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="Отправляем отчёт")
async def submit(
mission_id: int,
*,
comment: str | None = Form(None),
proof_url: str | None = Form(None),
resume_link: str | None = Form(None),
passport: UploadFile | None = File(None),
photo: UploadFile | None = File(None),
resume_file: UploadFile | None = File(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> MissionSubmissionRead:
"""Пилот отправляет доказательство выполнения миссии и сопроводительные документы."""
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="Миссия не найдена")
db.refresh(current_user)
_ = current_user.submissions
branches = (
db.query(Branch)
.options(selectinload(Branch.missions))
.all()
)
branch_dependencies = _build_branch_dependencies(branches)
completed_missions = _load_user_progress(current_user)
mission_titles = dict(db.query(Mission.id, Mission.title).all())
is_available, reasons = _mission_availability(
mission=mission,
user=current_user,
completed_missions=completed_missions,
branch_dependencies=branch_dependencies,
mission_titles=mission_titles,
)
if not is_available:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="; ".join(reasons))
existing_submission = (
db.query(MissionSubmission)
.filter(MissionSubmission.user_id == current_user.id, MissionSubmission.mission_id == mission.id)
.first()
)
def _has_upload(upload: UploadFile | None) -> bool:
return bool(upload and upload.filename)
passport_required = mission.id in REQUIRED_DOCUMENT_MISSIONS
photo_required = mission.id in REQUIRED_DOCUMENT_MISSIONS
resume_required = mission.id in REQUIRED_DOCUMENT_MISSIONS
if passport_required and not (
(existing_submission and existing_submission.passport_path) or _has_upload(passport)
):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Загрузите скан паспорта кандидата.")
if photo_required and not (
(existing_submission and existing_submission.photo_path) or _has_upload(photo)
):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Добавьте актуальную фотографию кандидата.")
existing_resume_sources = bool(
existing_submission
and (existing_submission.resume_path or existing_submission.resume_link)
)
resume_link_trimmed = (resume_link or "").strip()
resume_file_provided = _has_upload(resume_file)
if resume_required and not (existing_resume_sources or resume_link_trimmed or resume_file_provided):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Добавьте ссылку на резюме или загрузите файл с резюме.",
)
new_passport_path = None
new_photo_path = None
new_resume_path = None
try:
if _has_upload(passport):
new_passport_path = save_submission_document(
upload=passport,
user_id=current_user.id,
mission_id=mission.id,
kind="passport",
)
if _has_upload(photo):
new_photo_path = save_submission_document(
upload=photo,
user_id=current_user.id,
mission_id=mission.id,
kind="photo",
)
if resume_file_provided:
new_resume_path = save_submission_document(
upload=resume_file,
user_id=current_user.id,
mission_id=mission.id,
kind="resume",
)
submission = submit_mission(
db=db,
user=current_user,
mission=mission,
comment=(comment or "").strip() or None,
proof_url=(proof_url or "").strip() or None,
passport_path=new_passport_path if new_passport_path is not None else UNSET,
photo_path=new_photo_path if new_photo_path is not None else UNSET,
resume_path=new_resume_path if new_resume_path is not None else UNSET,
resume_link=(resume_link_trimmed or None) if resume_link is not None else UNSET,
)
except Exception:
delete_submission_document(new_passport_path)
delete_submission_document(new_photo_path)
delete_submission_document(new_resume_path)
raise
return MissionSubmissionRead.model_validate(submission)
@router.get(
"/{mission_id}/submission",
response_model=MissionSubmissionRead | None,
summary="Получаем текущую отправку",
)
def get_submission(
mission_id: int,
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> MissionSubmissionRead | None:
"""Возвращаем статус отправленной миссии."""
submission = (
db.query(MissionSubmission)
.filter(MissionSubmission.user_id == current_user.id, MissionSubmission.mission_id == mission_id)
.first()
)
if not submission:
return None
return MissionSubmissionRead.model_validate(submission)
@router.get(
"/submissions/{submission_id}/files/{document}",
summary="Скачиваем загруженные файлы",
)
def download_submission_file(
submission_id: int,
document: str,
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> FileResponse:
"""Возвращаем файл паспорта, фото или резюме."""
submission = db.query(MissionSubmission).filter(MissionSubmission.id == submission_id).first()
if not submission:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Отправка не найдена")
if submission.user_id != current_user.id and current_user.role != UserRole.HR:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Нет доступа к файлу")
attribute_map = {
"passport": submission.passport_path,
"photo": submission.photo_path,
"resume": submission.resume_path,
}
relative_path = attribute_map.get(document)
if not relative_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Файл не найден")
file_path = settings.uploads_path / relative_path
resolved = file_path.resolve()
base = settings.uploads_path.resolve()
if not resolved.is_relative_to(base):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Файл не найден")
if not resolved.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Файл не найден")
return FileResponse(resolved, filename=resolved.name)