"""Маршруты для работы с миссиями.""" 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.branch import Branch, BranchMission from app.models.mission import Mission, MissionSubmission, SubmissionStatus from app.models.user import User, UserRole from app.schemas.branch import BranchMissionRead, BranchRead from app.schemas.mission import ( MissionBase, MissionDetail, MissionSubmissionRead, ) 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 @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), ) .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) 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 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).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, ) 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 if mission.id in completed_missions: data.is_completed = True data.is_available = False data.locked_reasons = ["Миссия уже завершена"] return data @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)