"""HR-панель и административные действия.""" from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import func from sqlalchemy.exc import NoResultFound from sqlalchemy.orm import Session, selectinload from app.api.deps import require_hr from app.db.session import get_db from app.models.artifact import Artifact from app.models.branch import Branch, BranchMission from app.models.mission import ( Mission, MissionCompetencyReward, MissionPrerequisite, MissionSubmission, SubmissionStatus, ) from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement from app.models.user import Competency, User, UserRole from app.schemas.artifact import ArtifactCreate, ArtifactRead, ArtifactUpdate from app.schemas.branch import BranchCreate, BranchMissionRead, BranchRead, BranchUpdate from app.schemas.mission import ( MissionBase, MissionCreate, MissionDetail, MissionSubmissionRead, MissionUpdate, ) from app.schemas.rank import ( RankBase, RankCreate, RankDetailed, RankRequirementCompetency, RankRequirementMission, RankUpdate, ) from app.schemas.user import CompetencyBase from app.services.mission import approve_submission, reject_submission from app.schemas.admin_stats import AdminDashboardStats, BranchCompletionStat, SubmissionStats router = APIRouter(prefix="/api/admin", tags=["admin"]) def _mission_to_detail(mission: Mission) -> MissionDetail: """Формируем детальную схему миссии.""" return 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, minimum_rank_id=mission.minimum_rank_id, artifact_id=mission.artifact_id, prerequisites=[link.required_mission_id for link in mission.prerequisites], competency_rewards=[ { "competency_id": reward.competency_id, "competency_name": reward.competency.name, "level_delta": reward.level_delta, } for reward in mission.competency_rewards ], created_at=mission.created_at, updated_at=mission.updated_at, ) def _rank_to_detailed(rank: Rank) -> RankDetailed: """Формируем ранг со списком требований.""" return RankDetailed( id=rank.id, title=rank.title, description=rank.description, required_xp=rank.required_xp, mission_requirements=[ RankRequirementMission(mission_id=req.mission_id, mission_title=req.mission.title) for req in rank.mission_requirements ], competency_requirements=[ RankRequirementCompetency( competency_id=req.competency_id, competency_name=req.competency.name, required_level=req.required_level, ) for req in rank.competency_requirements ], created_at=rank.created_at, updated_at=rank.updated_at, ) def _branch_to_read(branch: Branch) -> BranchRead: """Формируем схему ветки с отсортированными миссиями.""" missions = sorted(branch.missions, key=lambda item: item.order) return BranchRead( id=branch.id, title=branch.title, description=branch.description, category=branch.category, missions=[ BranchMissionRead( mission_id=item.mission_id, mission_title=item.mission.title if item.mission else "", order=item.order, is_completed=False, is_available=True, ) for item in missions ], total_missions=len(missions), completed_missions=0, ) def _load_rank(db: Session, rank_id: int) -> Rank: """Загружаем ранг с зависимостями.""" return ( db.query(Rank) .options( selectinload(Rank.mission_requirements).selectinload(RankMissionRequirement.mission), selectinload(Rank.competency_requirements).selectinload(RankCompetencyRequirement.competency), ) .filter(Rank.id == rank_id) .one() ) def _load_mission(db: Session, mission_id: int) -> Mission: """Загружаем миссию с зависимостями.""" return ( db.query(Mission) .options( selectinload(Mission.prerequisites), selectinload(Mission.competency_rewards).selectinload(MissionCompetencyReward.competency), selectinload(Mission.branches), ) .filter(Mission.id == mission_id) .one() ) @router.get("/missions", response_model=list[MissionBase], summary="Миссии (HR)") def admin_missions(*, db: Session = Depends(get_db), current_user=Depends(require_hr)) -> list[MissionBase]: """Список всех миссий для HR.""" missions = db.query(Mission).order_by(Mission.title).all() return [MissionBase.model_validate(mission) for mission in missions] @router.get("/missions/{mission_id}", response_model=MissionDetail, summary="Детали миссии") def admin_mission_detail( mission_id: int, *, db: Session = Depends(get_db), current_user=Depends(require_hr), ) -> MissionDetail: """Детальная карточка миссии.""" mission = ( db.query(Mission) .options( selectinload(Mission.prerequisites), selectinload(Mission.competency_rewards).selectinload(MissionCompetencyReward.competency), selectinload(Mission.branches), ) .filter(Mission.id == mission_id) .first() ) if not mission: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена") return _mission_to_detail(mission) @router.get("/branches", response_model=list[BranchRead], summary="Ветки миссий") def admin_branches(*, db: Session = Depends(get_db), current_user=Depends(require_hr)) -> list[BranchRead]: """Возвращаем ветки с миссиями.""" branches = ( db.query(Branch) .options(selectinload(Branch.missions).selectinload(BranchMission.mission)) .order_by(Branch.title) .all() ) return [_branch_to_read(branch) for branch in branches] @router.post("/branches", response_model=BranchRead, summary="Создать ветку") def create_branch( branch_in: BranchCreate, *, db: Session = Depends(get_db), current_user=Depends(require_hr), ) -> BranchRead: """Создаём новую ветку.""" branch = Branch( title=branch_in.title, description=branch_in.description, category=branch_in.category, ) db.add(branch) db.commit() db.refresh(branch) return _branch_to_read(branch) @router.put("/branches/{branch_id}", response_model=BranchRead, summary="Обновить ветку") def update_branch( branch_id: int, branch_in: BranchUpdate, *, db: Session = Depends(get_db), current_user=Depends(require_hr), ) -> BranchRead: """Редактируем ветку.""" branch = db.query(Branch).filter(Branch.id == branch_id).first() if not branch: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ветка не найдена") branch.title = branch_in.title branch.description = branch_in.description branch.category = branch_in.category db.commit() db.refresh(branch) return _branch_to_read(branch) @router.get( "/competencies", response_model=list[CompetencyBase], summary="Каталог компетенций", ) def list_competencies( *, db: Session = Depends(get_db), current_user=Depends(require_hr) ) -> list[CompetencyBase]: """Справочник компетенций для форм HR.""" competencies = db.query(Competency).order_by(Competency.name).all() return [CompetencyBase.model_validate(competency) for competency in competencies] @router.get("/artifacts", response_model=list[ArtifactRead], summary="Каталог артефактов") def list_artifacts( *, db: Session = Depends(get_db), current_user=Depends(require_hr) ) -> list[ArtifactRead]: """Справочник артефактов.""" artifacts = db.query(Artifact).order_by(Artifact.name).all() return [ArtifactRead.model_validate(artifact) for artifact in artifacts] @router.get("/stats", response_model=AdminDashboardStats, summary="Сводная аналитика") def dashboard_stats( *, db: Session = Depends(get_db), current_user=Depends(require_hr) ) -> AdminDashboardStats: """Основные метрики прогресса и активности пользователей.""" total_pilots = db.query(User).filter(User.role == UserRole.PILOT).count() approved_submissions = db.query(MissionSubmission).filter( MissionSubmission.status == SubmissionStatus.APPROVED ) active_pilots = ( approved_submissions.with_entities(MissionSubmission.user_id).distinct().count() ) completed_counts = approved_submissions.with_entities( MissionSubmission.user_id, func.count(MissionSubmission.id) ).group_by(MissionSubmission.user_id) total_completed = sum(row[1] for row in completed_counts) average_completed = total_completed / active_pilots if active_pilots else 0.0 submission_stats = SubmissionStats( pending=db.query(MissionSubmission).filter(MissionSubmission.status == SubmissionStatus.PENDING).count(), approved=approved_submissions.count(), rejected=db.query(MissionSubmission).filter(MissionSubmission.status == SubmissionStatus.REJECTED).count(), ) branches = ( db.query(Branch) .options(selectinload(Branch.missions)) .order_by(Branch.title) .all() ) branch_stats: list[BranchCompletionStat] = [] for branch in branches: total_missions = len(branch.missions) if total_missions == 0 or total_pilots == 0: branch_stats.append( BranchCompletionStat(branch_id=branch.id, branch_title=branch.title, completion_rate=0.0) ) continue approved_count = ( db.query(func.count(MissionSubmission.id)) .join(Mission, Mission.id == MissionSubmission.mission_id) .join(BranchMission, BranchMission.mission_id == Mission.id) .filter( BranchMission.branch_id == branch.id, MissionSubmission.status == SubmissionStatus.APPROVED, ) .scalar() ) denominator = total_missions * total_pilots rate = min(1.0, approved_count / denominator) if denominator else 0.0 branch_stats.append( BranchCompletionStat(branch_id=branch.id, branch_title=branch.title, completion_rate=rate) ) return AdminDashboardStats( total_users=total_pilots, active_pilots=active_pilots, average_completed_missions=round(average_completed, 2), submission_stats=submission_stats, branch_completion=branch_stats, ) @router.post("/artifacts", response_model=ArtifactRead, summary="Создать артефакт") def create_artifact( artifact_in: ArtifactCreate, *, db: Session = Depends(get_db), current_user=Depends(require_hr), ) -> ArtifactRead: """Добавляем новый артефакт в каталог.""" artifact = Artifact( name=artifact_in.name, description=artifact_in.description, rarity=artifact_in.rarity, image_url=artifact_in.image_url, ) db.add(artifact) db.commit() db.refresh(artifact) return ArtifactRead.model_validate(artifact) @router.put("/artifacts/{artifact_id}", response_model=ArtifactRead, summary="Обновить артефакт") def update_artifact( artifact_id: int, artifact_in: ArtifactUpdate, *, db: Session = Depends(get_db), current_user=Depends(require_hr), ) -> ArtifactRead: """Редактируем существующий артефакт.""" artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first() if not artifact: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Артефакт не найден") payload = artifact_in.model_dump(exclude_unset=True) for field, value in payload.items(): setattr(artifact, field, value) db.commit() db.refresh(artifact) return ArtifactRead.model_validate(artifact) @router.delete( "/artifacts/{artifact_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Удалить артефакт" ) def delete_artifact( artifact_id: int, *, db: Session = Depends(get_db), current_user=Depends(require_hr), ) -> None: """Удаляем артефакт, если он не привязан к миссиям.""" artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first() if not artifact: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Артефакт не найден") missions_with_artifact = db.query(Mission).filter(Mission.artifact_id == artifact_id).count() if missions_with_artifact: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Нельзя удалить артефакт, привязанный к миссиям", ) db.delete(artifact) db.commit() return None @router.post("/missions", response_model=MissionDetail, summary="Создать миссию") def create_mission_endpoint( mission_in: MissionCreate, *, db: Session = Depends(get_db), current_user=Depends(require_hr), ) -> MissionDetail: """Создаём новую миссию.""" mission = Mission( title=mission_in.title, description=mission_in.description, xp_reward=mission_in.xp_reward, mana_reward=mission_in.mana_reward, difficulty=mission_in.difficulty, minimum_rank_id=mission_in.minimum_rank_id, artifact_id=mission_in.artifact_id, ) db.add(mission) db.flush() for reward in mission_in.competency_rewards: mission.competency_rewards.append( MissionCompetencyReward( mission_id=mission.id, competency_id=reward.competency_id, level_delta=reward.level_delta, ) ) for prerequisite_id in mission_in.prerequisite_ids: mission.prerequisites.append( MissionPrerequisite(mission_id=mission.id, required_mission_id=prerequisite_id) ) if mission_in.branch_id: mission.branches.append( BranchMission( branch_id=mission_in.branch_id, mission_id=mission.id, order=mission_in.branch_order, ) ) db.commit() mission = _load_mission(db, mission.id) return _mission_to_detail(mission) @router.put("/missions/{mission_id}", response_model=MissionDetail, summary="Обновить миссию") def update_mission_endpoint( mission_id: int, mission_in: MissionUpdate, *, db: Session = Depends(get_db), current_user=Depends(require_hr), ) -> MissionDetail: """Редактируем миссию.""" mission = ( db.query(Mission) .options( selectinload(Mission.prerequisites), selectinload(Mission.competency_rewards), selectinload(Mission.branches), ) .filter(Mission.id == mission_id) .first() ) if not mission: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена") payload = mission_in.model_dump(exclude_unset=True) for attr in ["title", "description", "xp_reward", "mana_reward", "difficulty", "is_active"]: if attr in payload: setattr(mission, attr, payload[attr]) if "minimum_rank_id" in payload: mission.minimum_rank_id = payload["minimum_rank_id"] if "artifact_id" in payload: mission.artifact_id = payload["artifact_id"] if "competency_rewards" in payload: mission.competency_rewards.clear() for reward in payload["competency_rewards"]: mission.competency_rewards.append( MissionCompetencyReward( mission_id=mission.id, competency_id=reward.competency_id, level_delta=reward.level_delta, ) ) if "prerequisite_ids" in payload: mission.prerequisites.clear() for prerequisite_id in payload["prerequisite_ids"]: mission.prerequisites.append( MissionPrerequisite(mission_id=mission.id, required_mission_id=prerequisite_id) ) if "branch_id" in payload: mission.branches.clear() branch_id = payload["branch_id"] if branch_id is not None: order = payload.get("branch_order", 1) mission.branches.append( BranchMission(branch_id=branch_id, mission_id=mission.id, order=order) ) elif "branch_order" in payload and mission.branches: mission.branches[0].order = payload["branch_order"] db.commit() mission = _load_mission(db, mission.id) return _mission_to_detail(mission) @router.get("/ranks", response_model=list[RankBase], summary="Список рангов") def admin_ranks(*, db: Session = Depends(get_db), current_user=Depends(require_hr)) -> list[RankBase]: """Перечень рангов.""" ranks = db.query(Rank).order_by(Rank.required_xp).all() return [RankBase.model_validate(rank) for rank in ranks] @router.get("/ranks/{rank_id}", response_model=RankDetailed, summary="Детали ранга") def get_rank( rank_id: int, *, db: Session = Depends(get_db), current_user=Depends(require_hr), ) -> RankDetailed: """Возвращаем подробную информацию о ранге.""" try: rank = _load_rank(db, rank_id) except NoResultFound as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ранг не найден") from exc return _rank_to_detailed(rank) @router.post("/ranks", response_model=RankDetailed, summary="Создать ранг") def create_rank( rank_in: RankCreate, *, db: Session = Depends(get_db), current_user=Depends(require_hr), ) -> RankDetailed: """Создаём новый ранг с требованиями.""" rank = Rank(title=rank_in.title, description=rank_in.description, required_xp=rank_in.required_xp) db.add(rank) db.flush() for mission_id in rank_in.mission_ids: rank.mission_requirements.append( RankMissionRequirement(rank_id=rank.id, mission_id=mission_id) ) for item in rank_in.competency_requirements: rank.competency_requirements.append( RankCompetencyRequirement( rank_id=rank.id, competency_id=item.competency_id, required_level=item.required_level, ) ) db.commit() rank = _load_rank(db, rank.id) return _rank_to_detailed(rank) @router.put("/ranks/{rank_id}", response_model=RankDetailed, summary="Обновить ранг") def update_rank( rank_id: int, rank_in: RankUpdate, *, db: Session = Depends(get_db), current_user=Depends(require_hr), ) -> RankDetailed: """Редактируем параметры ранга.""" rank = ( db.query(Rank) .options( selectinload(Rank.mission_requirements), selectinload(Rank.competency_requirements), ) .filter(Rank.id == rank_id) .first() ) if not rank: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ранг не найден") rank.title = rank_in.title rank.description = rank_in.description rank.required_xp = rank_in.required_xp rank.mission_requirements.clear() for mission_id in rank_in.mission_ids: rank.mission_requirements.append( RankMissionRequirement(rank_id=rank.id, mission_id=mission_id) ) rank.competency_requirements.clear() for item in rank_in.competency_requirements: rank.competency_requirements.append( RankCompetencyRequirement( rank_id=rank.id, competency_id=item.competency_id, required_level=item.required_level, ) ) db.commit() rank = _load_rank(db, rank.id) return _rank_to_detailed(rank) @router.get( "/submissions", response_model=list[MissionSubmissionRead], summary="Очередь модерации", ) def moderation_queue( status_filter: SubmissionStatus = SubmissionStatus.PENDING, *, db: Session = Depends(get_db), current_user=Depends(require_hr), ) -> list[MissionSubmissionRead]: """Возвращаем отправки со статусом по умолчанию pending.""" submissions = ( db.query(MissionSubmission) .filter(MissionSubmission.status == status_filter) .order_by(MissionSubmission.created_at) .all() ) return [MissionSubmissionRead.model_validate(submission) for submission in submissions] @router.post( "/submissions/{submission_id}/approve", response_model=MissionSubmissionRead, summary="Одобрить миссию", ) def approve_submission_endpoint( submission_id: int, *, db: Session = Depends(get_db), current_user=Depends(require_hr), ) -> MissionSubmissionRead: """HR подтверждает выполнение.""" submission = db.query(MissionSubmission).filter(MissionSubmission.id == submission_id).first() if not submission: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Отправка не найдена") submission = approve_submission(db, submission) return MissionSubmissionRead.model_validate(submission) @router.post( "/submissions/{submission_id}/reject", response_model=MissionSubmissionRead, summary="Отклонить миссию", ) def reject_submission_endpoint( submission_id: int, comment: str | None = None, *, db: Session = Depends(get_db), current_user=Depends(require_hr), ) -> MissionSubmissionRead: """HR отклоняет выполнение.""" submission = db.query(MissionSubmission).filter(MissionSubmission.id == submission_id).first() if not submission: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Отправка не найдена") submission = reject_submission(db, submission, comment) return MissionSubmissionRead.model_validate(submission)