diff --git a/README.md b/README.md index ac8cf77..0855de7 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,22 @@ - `docs/` — документация, дополнительный лор. - `docker-compose.yaml` — инфраструктура проекта. +## Основные пользовательские сценарии + +- Как кандидат: хочу проходить онбординг с лором и понимать своё место в программе. +- Как кандидат: хочу видеть прогресс-бар до следующего ранга и инструкции по ключевым веткам. +- Как кандидат: хочу получать награды (опыт, ману, артефакты) и видеть их в журнале. +- Как HR: хочу управлять миссиями, ветками, артефактами и требованиями рангов. +- Как HR: хочу видеть оперативную аналитику по активности и очереди модерации. + ## Быстрый старт в Docker 1. Установите Docker и Docker Compose. -2. Скопируйте `.env.example` в `.env` (файл появится после сборки) и при необходимости поменяйте настройки. +2. Скопируйте пример конфигурации и при необходимости измените значения: + ```bash + cp backend/.env.example backend/.env + cp frontend/.env.example frontend/.env + ``` 3. Запустите окружение: ```bash docker compose up --build @@ -22,17 +34,27 @@ - API: http://localhost:8000 (документация Swagger — `/docs`). - Фронтенд: http://localhost:3000. +Docker Compose автоматически переопределяет `ALABUGA_SQLITE_PATH=/data/app.db`, чтобы база сохранялась во внешнем volume. Для локального запуска вне Docker оставьте путь `./data/app.db` из примера. + ## Локальная разработка backend ```bash cd backend python -m venv .venv source .venv/bin/activate +export PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 pip install -r requirements-dev.txt -# применяем миграции и создаём демо-данные +# подготовьте переменные окружения (однократно) +cp .env.example .env + +# применяем миграции alembic upgrade head + +# создаём демо-данные (команда выполняется из корня репозитория) +cd .. python -m scripts.seed_data +cd backend # Запуск API uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 @@ -43,6 +65,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ```bash cd frontend npm install +cp .env.example .env npm run dev ``` @@ -53,6 +76,14 @@ npm run dev | Пилот | `candidate@alabuga.space` | `orbita123` | | HR | `hr@alabuga.space` | `orbita123` | +## Проверка функционала + +1. **Онбординг и лор**: перейдите в `/onboarding`, прочитайте слайды и отметьте шаги выполненными. Прогресс сохранится и откроет доступ к веткам миссий. +2. **Кандидат**: авторизуйтесь под пилотом, изучите дашборд (`/`), миссии (`/missions`) и журнал (`/journal`). Доступность миссий зависит от ранга и выполненных заданий. +3. **Выполнение миссии**: откройте карточку миссии, отправьте доказательство. Переключитесь на HR и одобрите выполнение — ранги, компетенции и мана обновятся автоматически. +4. **HR панель**: под пользователем `hr@alabuga.space` проверьте сводку, модерацию, редактирование веток/миссий/рангов, создание артефактов и аналитику (`/admin`). +5. **Магазин**: вернитесь к пилоту, оформите заказ в `/store` — запись появится в журнале и очереди HR. + ## Тестирование ```bash @@ -68,7 +99,17 @@ pytest - Начисление опыта, маны и повышение ранга по трём условиям из ТЗ. - Журнал событий, экспортируемый через API. - Магазин артефактов с оформлением заказа. -- Админ-панель для HR: создание миссий, очередь модерации, список рангов. +- Админ-панель для HR: модерация отчётов, управление ветками, миссиями и требованиями к рангам. +- Онбординг с сохранением прогресса и космическим лором. +- Таблица лидеров по опыту и мане за неделю/месяц/год. +- Аналитическая сводка для HR: активность пилотов, очередь модерации, завершённость веток. + +Админ-инструменты доступны на странице `/admin` (используйте демо-пользователя HR). Здесь можно: + +- создавать и редактировать ветки миссий с категориями и порядком; +- подготавливать новые миссии с наградами, зависимостями, ветками и компетенциями; +- настраивать ранги: требуемый опыт, обязательные миссии и уровни компетенций. +- управлять каталогом артефактов и назначать их миссиям. ## План развития diff --git a/backend/Dockerfile b/backend/Dockerfile index 261d010..fcd6d80 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,11 +2,14 @@ FROM python:3.13-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ - POETRY_VIRTUALENVS_CREATE=false + POETRY_VIRTUALENVS_CREATE=false \ + PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 WORKDIR /app -RUN apt-get update && apt-get install -y build-essential libpq-dev && rm -rf /var/lib/apt/lists/* +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential libpq-dev \ + && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* COPY requirements.txt requirements.txt RUN pip install --no-cache-dir -r requirements.txt diff --git a/backend/alembic/versions/20240611_0002_onboarding.py b/backend/alembic/versions/20240611_0002_onboarding.py new file mode 100644 index 0000000..2e1320d --- /dev/null +++ b/backend/alembic/versions/20240611_0002_onboarding.py @@ -0,0 +1,56 @@ +"""Onboarding models""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20240611_0002" +down_revision = "20240609_0001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "onboarding_slides", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("order", sa.Integer(), nullable=False), + sa.Column("title", sa.String(length=160), nullable=False), + sa.Column("body", sa.Text(), nullable=False), + sa.Column("media_url", sa.String(length=512)), + sa.Column("cta_text", sa.String(length=120)), + sa.Column("cta_link", sa.String(length=512)), + 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("order", name="uq_onboarding_slide_order"), + ) + + op.create_table( + "onboarding_states", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("last_completed_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("is_completed", 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), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + server_onupdate=sa.func.now(), + nullable=False, + ), + sa.UniqueConstraint("user_id", name="uq_onboarding_state_user"), + ) + + +def downgrade() -> None: + op.drop_table("onboarding_states") + op.drop_table("onboarding_slides") + diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py index 24c95ae..c6c035c 100644 --- a/backend/app/api/routes/__init__.py +++ b/backend/app/api/routes/__init__.py @@ -1,12 +1,13 @@ """Экспортируем роутеры для подключения в приложении.""" -from . import admin, auth, journal, missions, store, users # noqa: F401 +from . import admin, auth, journal, missions, onboarding, store, users # noqa: F401 __all__ = [ "admin", "auth", "journal", "missions", + "onboarding", "store", "users", ] diff --git a/backend/app/api/routes/admin.py b/backend/app/api/routes/admin.py index bc68638..8e876b6 100644 --- a/backend/app/api/routes/admin.py +++ b/backend/app/api/routes/admin.py @@ -2,75 +2,50 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session +from fastapi import APIRouter, Depends, HTTPException, Response, 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.branch import BranchMission -from app.models.mission import Mission, MissionCompetencyReward, MissionPrerequisite, MissionSubmission, SubmissionStatus -from app.models.rank import Rank -from app.schemas.mission import MissionBase, MissionCreate, MissionDetail, MissionSubmissionRead -from app.schemas.rank import RankBase +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"]) -@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.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: - db.add( - MissionCompetencyReward( - mission_id=mission.id, - competency_id=reward.competency_id, - level_delta=reward.level_delta, - ) - ) - - for prerequisite_id in mission_in.prerequisite_ids: - db.add( - MissionPrerequisite(mission_id=mission.id, required_mission_id=prerequisite_id) - ) - - if mission_in.branch_id: - db.add( - BranchMission( - branch_id=mission_in.branch_id, - mission_id=mission.id, - order=mission_in.branch_order, - ) - ) - - db.commit() - db.refresh(mission) +def _mission_to_detail(mission: Mission) -> MissionDetail: + """Формируем детальную схему миссии.""" return MissionDetail( id=mission.id, @@ -96,6 +71,455 @@ def create_mission_endpoint( ) +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), +) -> Response: + """Удаляем артефакт, если он не привязан к миссиям.""" + + 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 Response(status_code=status.HTTP_204_NO_CONTENT) + + +@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]: """Перечень рангов.""" @@ -104,6 +528,103 @@ def admin_ranks(*, db: Session = Depends(get_db), current_user=Depends(require_h 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], diff --git a/backend/app/api/routes/journal.py b/backend/app/api/routes/journal.py index 90bf3fb..1a03dfe 100644 --- a/backend/app/api/routes/journal.py +++ b/backend/app/api/routes/journal.py @@ -2,14 +2,17 @@ from __future__ import annotations -from fastapi import APIRouter, Depends +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import func from sqlalchemy.orm import Session from app.api.deps import get_current_user from app.db.session import get_db from app.models.journal import JournalEntry from app.models.user import User -from app.schemas.journal import JournalEntryRead +from app.schemas.journal import JournalEntryRead, LeaderboardEntry, LeaderboardResponse router = APIRouter(prefix="/api/journal", tags=["journal"]) @@ -27,3 +30,48 @@ def list_journal( .all() ) return [JournalEntryRead.model_validate(entry) for entry in entries] + + +@router.get("/leaderboard", response_model=LeaderboardResponse, summary="Таблица лидеров") +def leaderboard( + period: str = "week", + *, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> LeaderboardResponse: + """Возвращаем топ пилотов по опыту и мане за выбранный период.""" + + del current_user # информация используется только для авторизации + + periods = {"week": 7, "month": 30, "year": 365} + if period not in periods: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неизвестный период") + + since = datetime.now(timezone.utc) - timedelta(days=periods[period]) + + rows = ( + db.query( + User.id.label("user_id"), + User.full_name, + func.sum(JournalEntry.xp_delta).label("xp_sum"), + func.sum(JournalEntry.mana_delta).label("mana_sum"), + ) + .join(User, User.id == JournalEntry.user_id) + .filter(JournalEntry.created_at >= since) + .group_by(User.id, User.full_name) + .order_by(func.sum(JournalEntry.xp_delta).desc()) + .limit(5) + .all() + ) + + entries = [ + LeaderboardEntry( + user_id=row.user_id, + full_name=row.full_name, + xp_delta=int(row.xp_sum or 0), + mana_delta=int(row.mana_sum or 0), + ) + for row in rows + ] + + return LeaderboardResponse(period=period, entries=entries) diff --git a/backend/app/api/routes/missions.py b/backend/app/api/routes/missions.py index 0a95930..cb78e85 100644 --- a/backend/app/api/routes/missions.py +++ b/backend/app/api/routes/missions.py @@ -2,13 +2,17 @@ from __future__ import annotations +from collections import defaultdict + from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, selectinload from app.api.deps import get_current_user from app.db.session import get_db -from app.models.mission import Mission, MissionSubmission +from app.models.branch import Branch, BranchMission +from app.models.mission import Mission, MissionSubmission, SubmissionStatus from app.models.user import User +from app.schemas.branch import BranchMissionRead, BranchRead from app.schemas.mission import ( MissionBase, MissionDetail, @@ -20,19 +24,181 @@ from app.services.mission import submit_mission router = APIRouter(prefix="/api/missions", tags=["missions"]) +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]: """Возвращаем доступные миссии.""" - query = db.query(Mission).filter(Mission.is_active.is_(True)) - if current_user.current_rank_id: - query = query.filter( - (Mission.minimum_rank_id.is_(None)) | (Mission.minimum_rank_id <= current_user.current_rank_id) + 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), ) - missions = query.all() - return [MissionBase.model_validate(mission) for mission in missions] + .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.is_available = is_available + dto.locked_reasons = reasons + response.append(dto) + + return response @router.get("/{mission_id}", response_model=MissionDetail, summary="Карточка миссии") @@ -48,6 +214,25 @@ def get_mission( 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 = [ { @@ -66,6 +251,8 @@ def get_mission( 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, @@ -86,9 +273,30 @@ def submit( ) -> MissionSubmissionRead: """Пилот отправляет доказательство выполнения миссии.""" - mission = db.query(Mission).filter(Mission.id == mission_id).first() + 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)) submission = submit_mission( db=db, user=current_user, diff --git a/backend/app/api/routes/onboarding.py b/backend/app/api/routes/onboarding.py new file mode 100644 index 0000000..f23f4a4 --- /dev/null +++ b/backend/app/api/routes/onboarding.py @@ -0,0 +1,60 @@ +"""Онбординг и космический лор.""" + +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.user import User +from app.schemas.onboarding import ( + OnboardingCompleteRequest, + OnboardingOverview, + OnboardingSlideRead, + OnboardingStateRead, +) +from app.services.onboarding import complete_slide, get_overview + + +router = APIRouter(prefix="/api/onboarding", tags=["onboarding"]) + + +@router.get("/", response_model=OnboardingOverview, summary="Лор и прогресс онбординга") +def read_onboarding( + *, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> OnboardingOverview: + """Отдаём все слайды вместе с состоянием пользователя.""" + + slides, state, next_order = get_overview(db, current_user) + return OnboardingOverview( + slides=[OnboardingSlideRead.model_validate(slide) for slide in slides], + state=OnboardingStateRead( + last_completed_order=state.last_completed_order, + is_completed=state.is_completed, + ), + next_order=next_order, + ) + + +@router.post("/complete", response_model=OnboardingStateRead, summary="Завершаем шаг онбординга") +def complete_onboarding_step( + payload: OnboardingCompleteRequest, + *, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> OnboardingStateRead: + """Фиксируем прохождение очередного шага лора.""" + + try: + state = complete_slide(db, current_user, payload.order) + except ValueError as exc: # pragma: no cover - ошибка бизнес-логики + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + return OnboardingStateRead( + last_completed_order=state.last_completed_order, + is_completed=state.is_completed, + ) + diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index f0e3943..49b8371 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -7,8 +7,12 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_user from app.db.session import get_db +from app.models.rank import Rank from app.models.user import User +from app.schemas.progress import ProgressSnapshot +from app.schemas.rank import RankBase from app.schemas.user import UserProfile +from app.services.rank import build_progress_snapshot router = APIRouter(prefix="/api", tags=["profile"]) @@ -23,3 +27,26 @@ def get_profile( _ = current_user.competencies _ = current_user.artifacts return UserProfile.model_validate(current_user) + + +@router.get("/ranks", response_model=list[RankBase], summary="Перечень рангов") +def list_ranks( + *, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +) -> list[RankBase]: + """Возвращаем ранги по возрастанию требований.""" + + ranks = db.query(Rank).order_by(Rank.required_xp).all() + return [RankBase.model_validate(rank) for rank in ranks] + + +@router.get("/progress", response_model=ProgressSnapshot, summary="Прогресс до следующего ранга") +def get_progress( + *, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +) -> ProgressSnapshot: + """Возвращаем агрегированную информацию о выполненных условиях следующего ранга.""" + + db.refresh(current_user) + _ = current_user.submissions + _ = current_user.competencies + snapshot = build_progress_snapshot(current_user, db) + return snapshot diff --git a/backend/app/core/config.py b/backend/app/core/config.py index df08098..469614e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,15 +1,21 @@ """Конфигурация приложения и загрузка окружения.""" +from __future__ import annotations + from functools import lru_cache from pathlib import Path +from typing import List from pydantic_settings import BaseSettings, SettingsConfigDict +BASE_DIR = Path(__file__).resolve().parents[2] + + class Settings(BaseSettings): """Глобальные настройки сервиса.""" - model_config = SettingsConfigDict(env_file=".env", env_prefix="ALABUGA_", extra="ignore") + model_config = SettingsConfigDict(env_file=BASE_DIR / ".env", env_prefix="ALABUGA_", extra="ignore") project_name: str = "Alabuga Gamification API" environment: str = "local" @@ -17,7 +23,11 @@ class Settings(BaseSettings): jwt_algorithm: str = "HS256" access_token_expire_minutes: int = 60 * 12 - backend_cors_origins: list[str] = ["http://localhost:3000", "http://frontend:3000"] + backend_cors_origins: List[str] = [ + "http://localhost:3000", + "http://frontend:3000", + "http://0.0.0.0:3000", + ] sqlite_path: Path = Path("/data/app.db") @@ -33,6 +43,10 @@ def get_settings() -> Settings: """Кэшируем создание настроек, чтобы не читать файл каждый раз.""" settings = Settings() + + if not settings.sqlite_path.is_absolute(): + settings.sqlite_path = (BASE_DIR / settings.sqlite_path).resolve() + settings.sqlite_path.parent.mkdir(parents=True, exist_ok=True) return settings diff --git a/backend/app/main.py b/backend/app/main.py index 015453d..ebf8f3e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,7 @@ from __future__ import annotations from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api.routes import admin, auth, journal, missions, store, users +from app.api.routes import admin, auth, journal, missions, onboarding, store, users from app.core.config import settings from app.db.session import engine from app.models.base import Base @@ -32,6 +32,7 @@ app.include_router(auth.router) app.include_router(users.router) app.include_router(missions.router) app.include_router(journal.router) +app.include_router(onboarding.router) app.include_router(store.router) app.include_router(admin.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index efc876d..8e2ba38 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -4,6 +4,7 @@ from .artifact import Artifact # noqa: F401 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 .rank import Rank, RankCompetencyRequirement, RankMissionRequirement # noqa: F401 from .store import Order, StoreItem # noqa: F401 from .user import Competency, User, UserArtifact, UserCompetency # noqa: F401 @@ -17,6 +18,8 @@ __all__ = [ "MissionCompetencyReward", "MissionPrerequisite", "MissionSubmission", + "OnboardingSlide", + "OnboardingState", "Rank", "RankCompetencyRequirement", "RankMissionRequirement", diff --git a/backend/app/models/journal.py b/backend/app/models/journal.py index b093115..2028ed2 100644 --- a/backend/app/models/journal.py +++ b/backend/app/models/journal.py @@ -3,6 +3,7 @@ from __future__ import annotations from enum import Enum +from typing import Optional from sqlalchemy import Enum as SQLEnum, ForeignKey, Integer, JSON, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -30,7 +31,7 @@ class JournalEntry(Base, TimestampMixin): event_type: Mapped[JournalEventType] = mapped_column(SQLEnum(JournalEventType), nullable=False) title: Mapped[str] = mapped_column(String(160), nullable=False) description: Mapped[str] = mapped_column(Text, nullable=False) - payload: Mapped[dict | None] = mapped_column(JSON) + payload: Mapped[Optional[dict]] = mapped_column(JSON) xp_delta: Mapped[int] = mapped_column(Integer, default=0, nullable=False) mana_delta: Mapped[int] = mapped_column(Integer, default=0, nullable=False) diff --git a/backend/app/models/onboarding.py b/backend/app/models/onboarding.py new file mode 100644 index 0000000..a089fd2 --- /dev/null +++ b/backend/app/models/onboarding.py @@ -0,0 +1,40 @@ +"""Модели онбординга и лора.""" + +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin + + +class OnboardingSlide(Base, TimestampMixin): + """Контентный слайд онбординга, который видит пилот.""" + + __tablename__ = "onboarding_slides" + __table_args__ = (UniqueConstraint("order", name="uq_onboarding_slide_order"),) + + id: Mapped[int] = mapped_column(primary_key=True) + order: Mapped[int] = mapped_column(Integer, nullable=False) + title: Mapped[str] = mapped_column(String(160), nullable=False) + body: Mapped[str] = mapped_column(Text, nullable=False) + media_url: Mapped[Optional[str]] = mapped_column(String(512)) + cta_text: Mapped[Optional[str]] = mapped_column(String(120)) + cta_link: Mapped[Optional[str]] = mapped_column(String(512)) + + +class OnboardingState(Base, TimestampMixin): + """Прогресс пользователя по онбордингу.""" + + __tablename__ = "onboarding_states" + __table_args__ = (UniqueConstraint("user_id", name="uq_onboarding_state_user"),) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + last_completed_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + is_completed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + user = relationship("User", back_populates="onboarding_state") + diff --git a/backend/app/models/store.py b/backend/app/models/store.py index f290def..8c2251f 100644 --- a/backend/app/models/store.py +++ b/backend/app/models/store.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum -from typing import List +from typing import List, Optional from sqlalchemy import Enum as SQLEnum, ForeignKey, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -45,7 +45,7 @@ class Order(Base, TimestampMixin): status: Mapped[OrderStatus] = mapped_column( SQLEnum(OrderStatus), default=OrderStatus.CREATED, nullable=False ) - comment: Mapped[str | None] = mapped_column(Text) + comment: Mapped[Optional[str]] = mapped_column(Text) user = relationship("User", back_populates="orders") item = relationship("StoreItem", back_populates="orders") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 0360c2a..c962bc5 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -10,6 +10,8 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from app.models.base import Base, TimestampMixin +# Локальные импорты внизу файла, чтобы избежать циклов типов + class UserRole(str, Enum): """Типы ролей в системе.""" @@ -48,6 +50,9 @@ class User(Base, TimestampMixin): artifacts: Mapped[List["UserArtifact"]] = relationship( "UserArtifact", back_populates="user", cascade="all, delete-orphan" ) + onboarding_state: Mapped[Optional["OnboardingState"]] = relationship( + "OnboardingState", back_populates="user", cascade="all, delete-orphan", uselist=False + ) class CompetencyCategory(str, Enum): @@ -103,3 +108,7 @@ class UserArtifact(Base, TimestampMixin): user = relationship("User", back_populates="artifacts") artifact = relationship("Artifact", back_populates="pilots") + + +# Импорты в конце файла, чтобы отношения корректно инициализировались +from app.models.onboarding import OnboardingState # noqa: E402 pylint: disable=wrong-import-position diff --git a/backend/app/schemas/admin_stats.py b/backend/app/schemas/admin_stats.py new file mode 100644 index 0000000..fea8c99 --- /dev/null +++ b/backend/app/schemas/admin_stats.py @@ -0,0 +1,31 @@ +"""Сводные метрики для HR.""" + +from __future__ import annotations + +from pydantic import BaseModel + + +class SubmissionStats(BaseModel): + """Структура статистики по отправкам миссий.""" + + pending: int + approved: int + rejected: int + + +class BranchCompletionStat(BaseModel): + """Завершённость ветки.""" + + branch_id: int + branch_title: str + completion_rate: float + + +class AdminDashboardStats(BaseModel): + """Ответ с основными метриками.""" + + total_users: int + active_pilots: int + average_completed_missions: float + submission_stats: SubmissionStats + branch_completion: list[BranchCompletionStat] diff --git a/backend/app/schemas/artifact.py b/backend/app/schemas/artifact.py new file mode 100644 index 0000000..116b78b --- /dev/null +++ b/backend/app/schemas/artifact.py @@ -0,0 +1,38 @@ +"""Схемы артефактов.""" + +from __future__ import annotations + +from pydantic import BaseModel + +from app.models.artifact import ArtifactRarity + + +class ArtifactRead(BaseModel): + """Краткая информация об артефакте.""" + + id: int + name: str + description: str + rarity: ArtifactRarity + image_url: str | None = None + + class Config: + from_attributes = True + + +class ArtifactCreate(BaseModel): + """Создание артефакта.""" + + name: str + description: str + rarity: ArtifactRarity + image_url: str | None = None + + +class ArtifactUpdate(BaseModel): + """Обновление артефакта.""" + + name: str | None = None + description: str | None = None + rarity: ArtifactRarity | None = None + image_url: str | None = None diff --git a/backend/app/schemas/branch.py b/backend/app/schemas/branch.py index 8c79f91..d263b29 100644 --- a/backend/app/schemas/branch.py +++ b/backend/app/schemas/branch.py @@ -11,6 +11,8 @@ class BranchMissionRead(BaseModel): mission_id: int mission_title: str order: int + is_completed: bool = False + is_available: bool = False class BranchRead(BaseModel): @@ -21,6 +23,22 @@ class BranchRead(BaseModel): description: str category: str missions: list[BranchMissionRead] + total_missions: int = 0 + completed_missions: int = 0 class Config: from_attributes = True + + +class BranchCreate(BaseModel): + """Создание ветки.""" + + title: str + description: str + category: str + + +class BranchUpdate(BranchCreate): + """Обновление ветки.""" + + pass diff --git a/backend/app/schemas/journal.py b/backend/app/schemas/journal.py index 2f5a41c..8a84d61 100644 --- a/backend/app/schemas/journal.py +++ b/backend/app/schemas/journal.py @@ -24,3 +24,19 @@ class JournalEntryRead(BaseModel): class Config: from_attributes = True + + +class LeaderboardEntry(BaseModel): + """Участник таблицы лидеров.""" + + user_id: int + full_name: str + xp_delta: int + mana_delta: int + + +class LeaderboardResponse(BaseModel): + """Ответ для таблицы лидеров.""" + + period: str + entries: list[LeaderboardEntry] diff --git a/backend/app/schemas/mission.py b/backend/app/schemas/mission.py index 0aad92e..1eb7b15 100644 --- a/backend/app/schemas/mission.py +++ b/backend/app/schemas/mission.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import datetime from typing import Optional -from pydantic import BaseModel +from pydantic import BaseModel, Field from app.models.mission import MissionDifficulty, SubmissionStatus @@ -20,6 +20,8 @@ class MissionBase(BaseModel): mana_reward: int difficulty: MissionDifficulty is_active: bool + is_available: bool = True + locked_reasons: list[str] = Field(default_factory=list) class Config: from_attributes = True @@ -68,6 +70,23 @@ class MissionCreate(BaseModel): branch_order: int = 1 +class MissionUpdate(BaseModel): + """Схема обновления миссии.""" + + title: Optional[str] = None + description: Optional[str] = None + xp_reward: Optional[int] = None + mana_reward: Optional[int] = None + difficulty: Optional[MissionDifficulty] = None + minimum_rank_id: Optional[int | None] = None + artifact_id: Optional[int | None] = None + prerequisite_ids: Optional[list[int]] = None + competency_rewards: Optional[list[MissionCreateReward]] = None + branch_id: Optional[int | None] = None + branch_order: Optional[int] = None + is_active: Optional[bool] = None + + class MissionSubmissionCreate(BaseModel): """Отправка отчёта по миссии.""" @@ -78,6 +97,7 @@ class MissionSubmissionCreate(BaseModel): class MissionSubmissionRead(BaseModel): """Получение статуса отправки.""" + id: int mission_id: int status: SubmissionStatus comment: Optional[str] diff --git a/backend/app/schemas/onboarding.py b/backend/app/schemas/onboarding.py new file mode 100644 index 0000000..b3cab2f --- /dev/null +++ b/backend/app/schemas/onboarding.py @@ -0,0 +1,44 @@ +"""Схемы для онбординга.""" + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel + + +class OnboardingSlideRead(BaseModel): + """Отдельный слайд онбординга.""" + + id: int + order: int + title: str + body: str + media_url: Optional[str] + cta_text: Optional[str] + cta_link: Optional[str] + + class Config: + from_attributes = True + + +class OnboardingStateRead(BaseModel): + """Прогресс пользователя.""" + + last_completed_order: int + is_completed: bool + + +class OnboardingOverview(BaseModel): + """Полный ответ с прогрессом и контентом.""" + + slides: list[OnboardingSlideRead] + state: OnboardingStateRead + next_order: int | None + + +class OnboardingCompleteRequest(BaseModel): + """Запрос на фиксацию завершения шага.""" + + order: int + diff --git a/backend/app/schemas/progress.py b/backend/app/schemas/progress.py new file mode 100644 index 0000000..999ce91 --- /dev/null +++ b/backend/app/schemas/progress.py @@ -0,0 +1,59 @@ +"""Pydantic-схемы для отображения прогресса по рангу.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class ProgressRank(BaseModel): + """Краткое описание ранга, пригодное для отображения на дашборде.""" + + id: int + title: str + description: str + required_xp: int + + class Config: + from_attributes = True + + +class ProgressXPMetrics(BaseModel): + """Статистика по опыту: от базового значения до целевого порога.""" + + baseline: int = Field(description="Количество XP, с которого начинается текущий этап прогресса") + current: int = Field(description="Текущее значение XP пользователя") + target: int = Field(description="Порог XP, необходимый для следующего ранга") + remaining: int = Field(description="Сколько XP осталось набрать") + progress_percent: float = Field(description="Прогресс на отрезке от baseline до target в долях единицы") + + +class ProgressMissionRequirement(BaseModel): + """Статус обязательной миссии для следующего ранга.""" + + mission_id: int + mission_title: str + is_completed: bool + + +class ProgressCompetencyRequirement(BaseModel): + """Статус требования по компетенции.""" + + competency_id: int + competency_name: str + required_level: int + current_level: int + is_met: bool + + +class ProgressSnapshot(BaseModel): + """Итоговая структура прогресса: XP, обязательные миссии и компетенции.""" + + current_rank: ProgressRank | None + next_rank: ProgressRank | None + xp: ProgressXPMetrics + mission_requirements: list[ProgressMissionRequirement] + competency_requirements: list[ProgressCompetencyRequirement] + completed_missions: int + total_missions: int + met_competencies: int + total_competencies: int diff --git a/backend/app/schemas/rank.py b/backend/app/schemas/rank.py index 58d0e7b..d5e385f 100644 --- a/backend/app/schemas/rank.py +++ b/backend/app/schemas/rank.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime -from pydantic import BaseModel +from pydantic import BaseModel, Field class RankBase(BaseModel): @@ -41,3 +41,36 @@ class RankDetailed(RankBase): competency_requirements: list[RankRequirementCompetency] created_at: datetime updated_at: datetime + + +class RankRequirementMissionInput(BaseModel): + """Входная схема обязательной миссии.""" + + mission_id: int + + +class RankRequirementCompetencyInput(BaseModel): + """Входная схема требования к компетенции.""" + + competency_id: int + required_level: int = Field(ge=0) + + +class RankCreate(BaseModel): + """Создание нового ранга.""" + + title: str + description: str + required_xp: int = Field(ge=0) + mission_ids: list[int] = [] + competency_requirements: list[RankRequirementCompetencyInput] = [] + + +class RankUpdate(BaseModel): + """Обновление существующего ранга.""" + + title: str + description: str + required_xp: int = Field(ge=0) + mission_ids: list[int] = [] + competency_requirements: list[RankRequirementCompetencyInput] = [] diff --git a/backend/app/services/mission.py b/backend/app/services/mission.py index 85f9413..ee4cfa6 100644 --- a/backend/app/services/mission.py +++ b/backend/app/services/mission.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session from app.models.journal import JournalEventType from app.models.mission import Mission, MissionSubmission, SubmissionStatus -from app.models.user import User, UserCompetency +from app.models.user import User, UserArtifact, UserCompetency from app.services.journal import log_event from app.services.rank import apply_rank_upgrade @@ -82,6 +82,21 @@ def approve_submission(db: Session, submission: MissionSubmission) -> MissionSub _increase_competencies(db, user, submission.mission) + if submission.mission.artifact_id: + already_has = any( + artifact.artifact_id == submission.mission.artifact_id for artifact in user.artifacts + ) + if not already_has: + db.add(UserArtifact(user_id=user.id, artifact_id=submission.mission.artifact_id)) + log_event( + db, + user_id=user.id, + event_type=JournalEventType.MISSION_COMPLETED, + title=f"Получен артефакт за миссию «{submission.mission.title}»", + description="Новый артефакт добавлен в коллекцию.", + payload={"artifact_id": submission.mission.artifact_id}, + ) + db.add_all([submission, user]) db.commit() db.refresh(submission) diff --git a/backend/app/services/onboarding.py b/backend/app/services/onboarding.py new file mode 100644 index 0000000..097c44e --- /dev/null +++ b/backend/app/services/onboarding.py @@ -0,0 +1,61 @@ +"""Сервисный слой для онбординга.""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from app.models.onboarding import OnboardingSlide, OnboardingState +from app.models.user import User + + +def _ensure_state(db: Session, user: User) -> OnboardingState: + """Гарантируем наличие записи о прогрессе.""" + + if user.onboarding_state: + return user.onboarding_state + + state = OnboardingState(user_id=user.id, last_completed_order=0, is_completed=False) + db.add(state) + db.flush() + db.refresh(state) + return state + + +def get_overview(db: Session, user: User) -> tuple[list[OnboardingSlide], OnboardingState, int | None]: + """Возвращаем все слайды и текущий прогресс.""" + + slides = db.query(OnboardingSlide).order_by(OnboardingSlide.order).all() + state = _ensure_state(db, user) + + next_slide = next((slide for slide in slides if slide.order > state.last_completed_order), None) + next_order: int | None = next_slide.order if next_slide else None + return slides, state, next_order + + +def complete_slide(db: Session, user: User, order: int) -> OnboardingState: + """Фиксируем завершение шага, если это корректный порядок.""" + + slides = db.query(OnboardingSlide).order_by(OnboardingSlide.order).all() + if not slides: + raise ValueError("Онбординг ещё не настроен") + + state = _ensure_state(db, user) + + allowed_orders = [slide.order for slide in slides] + if order not in allowed_orders: + raise ValueError("Неизвестный шаг онбординга") + + if order <= state.last_completed_order: + return state + + expected_order = next((value for value in allowed_orders if value > state.last_completed_order), None) + if expected_order is None or order != expected_order: + raise ValueError("Сначала завершите предыдущие шаги") + + state.last_completed_order = order + state.is_completed = order == allowed_orders[-1] + db.add(state) + db.commit() + db.refresh(state) + return state + diff --git a/backend/app/services/rank.py b/backend/app/services/rank.py index 559e554..e0449f5 100644 --- a/backend/app/services/rank.py +++ b/backend/app/services/rank.py @@ -2,13 +2,20 @@ from __future__ import annotations -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, selectinload from app.models.journal import JournalEventType from app.models.mission import SubmissionStatus -from app.models.rank import Rank +from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement from app.models.user import User from app.services.journal import log_event +from app.schemas.progress import ( + ProgressCompetencyRequirement, + ProgressMissionRequirement, + ProgressRank, + ProgressSnapshot, + ProgressXPMetrics, +) def _eligible_rank(user: User, db: Session) -> Rank | None: @@ -60,3 +67,106 @@ def apply_rank_upgrade(user: User, db: Session) -> Rank | None: payload={"previous_rank_id": previous_rank_id, "new_rank_id": new_rank.id}, ) return new_rank + + +def build_progress_snapshot(user: User, db: Session) -> ProgressSnapshot: + """Собираем агрегированное представление прогресса пользователя.""" + + ranks = ( + db.query(Rank) + .options( + selectinload(Rank.mission_requirements).selectinload(RankMissionRequirement.mission), + selectinload(Rank.competency_requirements).selectinload(RankCompetencyRequirement.competency), + ) + .order_by(Rank.required_xp) + .all() + ) + + current_rank_obj = next((rank for rank in ranks if rank.id == user.current_rank_id), None) + + approved_missions = { + submission.mission_id + for submission in user.submissions + if submission.status == SubmissionStatus.APPROVED + } + competency_levels = {item.competency_id: item.level for item in user.competencies} + + highest_met_rank: Rank | None = None + next_rank_obj: Rank | None = None + for rank in ranks: + missions_ok = all(req.mission_id in approved_missions for req in rank.mission_requirements) + competencies_ok = all( + competency_levels.get(req.competency_id, 0) >= req.required_level + for req in rank.competency_requirements + ) + xp_ok = user.xp >= rank.required_xp + + if xp_ok and missions_ok and competencies_ok: + highest_met_rank = rank + continue + + if next_rank_obj is None: + next_rank_obj = rank + break + + baseline_rank = current_rank_obj or highest_met_rank + baseline_xp = baseline_rank.required_xp if baseline_rank else 0 + + if next_rank_obj: + target_xp = next_rank_obj.required_xp + remaining_xp = max(0, target_xp - user.xp) + denominator = max(target_xp - baseline_xp, 1) + progress_percent = min(1.0, max(0.0, (user.xp - baseline_xp) / denominator)) + else: + target_xp = max(user.xp, baseline_xp) + remaining_xp = 0 + progress_percent = 1.0 + + mission_requirements: list[ProgressMissionRequirement] = [] + if next_rank_obj: + for requirement in next_rank_obj.mission_requirements: + title = requirement.mission.title if requirement.mission else f"Миссия #{requirement.mission_id}" + mission_requirements.append( + ProgressMissionRequirement( + mission_id=requirement.mission_id, + mission_title=title, + is_completed=requirement.mission_id in approved_missions, + ) + ) + + competency_requirements: list[ProgressCompetencyRequirement] = [] + if next_rank_obj: + for requirement in next_rank_obj.competency_requirements: + current_level = competency_levels.get(requirement.competency_id, 0) + competency_requirements.append( + ProgressCompetencyRequirement( + competency_id=requirement.competency_id, + competency_name=requirement.competency.name if requirement.competency else "", + required_level=requirement.required_level, + current_level=current_level, + is_met=current_level >= requirement.required_level, + ) + ) + + completed_missions = sum(1 for item in mission_requirements if item.is_completed) + met_competencies = sum(1 for item in competency_requirements if item.is_met) + + xp_metrics = ProgressXPMetrics( + baseline=baseline_xp, + current=user.xp, + target=target_xp, + remaining=remaining_xp, + progress_percent=round(progress_percent, 4), + ) + + return ProgressSnapshot( + current_rank=ProgressRank.model_validate(current_rank_obj) if current_rank_obj else None, + next_rank=ProgressRank.model_validate(next_rank_obj) if next_rank_obj else None, + xp=xp_metrics, + mission_requirements=mission_requirements, + competency_requirements=competency_requirements, + completed_missions=completed_missions, + total_missions=len(mission_requirements), + met_competencies=met_competencies, + total_competencies=len(competency_requirements), + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index 257c771..a7fd47f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,16 +1,13 @@ fastapi==0.111.0 uvicorn[standard]==0.30.1 -SQLAlchemy==2.0.30 -alembic==1.13.1 -pydantic==2.7.4 -pydantic-settings==2.3.2 +SQLAlchemy>=2.0.36,<3 +alembic>=1.14.0,<2 +pydantic==2.9.2 +pydantic-settings==2.10.1 passlib[bcrypt]==1.7.4 python-jose[cryptography]==3.3.0 python-multipart==0.0.9 bcrypt==4.1.3 email-validator==2.1.1 -pandas==2.2.2 -openpyxl==3.1.3 fastapi-pagination==0.12.24 Jinja2==3.1.4 - diff --git a/backend/tests/test_mission_submission.py b/backend/tests/test_mission_submission.py index 5a1f901..2b01686 100644 --- a/backend/tests/test_mission_submission.py +++ b/backend/tests/test_mission_submission.py @@ -2,6 +2,7 @@ from __future__ import annotations +from app.models.artifact import Artifact, ArtifactRarity from app.models.mission import Mission, MissionSubmission, SubmissionStatus from app.models.user import User, UserRole from app.services.mission import approve_submission @@ -33,3 +34,44 @@ def test_approve_submission_rewards(db_session): assert user.xp == mission.xp_reward assert user.mana == mission.mana_reward assert submission.status == SubmissionStatus.APPROVED + + +def test_approve_submission_grants_artifact(db_session): + """При наличии артефакта пользователь получает его единожды.""" + + artifact = Artifact( + name="Значок испытателя", + description="Выдан за успешную миссию", + rarity=ArtifactRarity.RARE, + ) + mission = Mission( + title="Тестовая миссия", + description="Описание", + xp_reward=50, + mana_reward=20, + artifact=artifact, + ) + user = User( + email="artifact@alabuga.space", + full_name="Пилот", + role=UserRole.PILOT, + hashed_password="hash", + ) + db_session.add_all([artifact, mission, user]) + db_session.flush() + + submission = MissionSubmission(user_id=user.id, mission_id=mission.id) + db_session.add(submission) + db_session.commit() + db_session.refresh(submission) + + approve_submission(db_session, submission) + db_session.refresh(user) + + assert len(user.artifacts) == 1 + assert user.artifacts[0].artifact_id == artifact.id + + # Повторное одобрение не создаёт дубли + approve_submission(db_session, submission) + db_session.refresh(user) + assert len(user.artifacts) == 1 diff --git a/backend/tests/test_rank_engine.py b/backend/tests/test_rank_engine.py index 3150820..e3a5df2 100644 --- a/backend/tests/test_rank_engine.py +++ b/backend/tests/test_rank_engine.py @@ -3,9 +3,9 @@ from __future__ import annotations from app.models.mission import Mission, MissionSubmission, SubmissionStatus -from app.models.rank import Rank, RankMissionRequirement -from app.models.user import User, UserRole -from app.services.rank import apply_rank_upgrade +from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement +from app.models.user import Competency, CompetencyCategory, User, UserCompetency, UserRole +from app.services.rank import apply_rank_upgrade, build_progress_snapshot def test_rank_upgrade_after_requirements(db_session): @@ -46,3 +46,74 @@ def test_rank_upgrade_after_requirements(db_session): assert new_rank is not None assert user.current_rank_id == pilot.id + + +def test_progress_snapshot_highlights_remaining_conditions(db_session): + """Снэпшот прогресса показывает, что ещё нужно закрыть.""" + + novice = Rank(title="Новичок", description="Старт", required_xp=0) + pilot = Rank(title="Пилот", description="Готов к полёту", required_xp=100) + mission = Mission(title="Тренировка", description="Базовое обучение", xp_reward=40, mana_reward=0) + competency = Competency( + name="Коммуникация", + description="Чёткая передача информации", + category=CompetencyCategory.COMMUNICATION, + ) + + db_session.add_all([novice, pilot, mission, competency]) + db_session.flush() + + db_session.add_all( + [ + RankMissionRequirement(rank_id=pilot.id, mission_id=mission.id), + RankCompetencyRequirement( + rank_id=pilot.id, + competency_id=competency.id, + required_level=1, + ), + ] + ) + + user = User( + email="progress@alabuga.space", + full_name="Прогресс Тест", + role=UserRole.PILOT, + hashed_password="hash", + xp=60, + current_rank_id=novice.id, + ) + db_session.add(user) + db_session.flush() + + db_session.add(UserCompetency(user_id=user.id, competency_id=competency.id, level=0)) + db_session.commit() + db_session.refresh(user) + + snapshot = build_progress_snapshot(user, db_session) + + assert snapshot.next_rank and snapshot.next_rank.title == "Пилот" + assert snapshot.xp.remaining == 40 + assert snapshot.completed_missions == 0 + assert snapshot.total_missions == 1 + assert snapshot.met_competencies == 0 + assert snapshot.total_competencies == 1 + + submission = MissionSubmission( + user_id=user.id, + mission_id=mission.id, + status=SubmissionStatus.APPROVED, + awarded_xp=mission.xp_reward, + ) + user.xp = 120 + user_competency = user.competencies[0] + user_competency.level = 2 + + db_session.add_all([submission, user, user_competency]) + db_session.commit() + db_session.refresh(user) + + snapshot_after = build_progress_snapshot(user, db_session) + + assert snapshot_after.completed_missions == snapshot_after.total_missions + assert snapshot_after.met_competencies == snapshot_after.total_competencies + assert snapshot_after.xp.remaining == 0 diff --git a/docker-compose.yaml b/docker-compose.yaml index 3c29645..e9a735c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -13,7 +13,7 @@ services: env_file: - backend/.env.example environment: - ALABUGA_ENVIRONMENT=docker + ALABUGA_ENVIRONMENT: docker depends_on: [] frontend: @@ -23,9 +23,12 @@ services: ports: - '3000:3000' environment: - NEXT_PUBLIC_API_URL=http://backend:8000 - NEXT_PUBLIC_DEMO_EMAIL=candidate@alabuga.space - NEXT_PUBLIC_DEMO_PASSWORD=orbita123 + NEXT_PUBLIC_API_URL: http://localhost:8000 + NEXT_INTERNAL_API_URL: http://backend:8000 + NEXT_PUBLIC_DEMO_EMAIL: candidate@alabuga.space + NEXT_PUBLIC_DEMO_PASSWORD: orbita123 + NEXT_PUBLIC_DEMO_HR_EMAIL: hr@alabuga.space + NEXT_PUBLIC_DEMO_HR_PASSWORD: orbita123 volumes: - ./frontend:/app - /app/node_modules diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 18ffaf9..07b6f9d 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1,7 +1,13 @@ +import { AdminBranchManager } from '../../components/admin/AdminBranchManager'; +import { AdminMissionManager } from '../../components/admin/AdminMissionManager'; +import { AdminRankManager } from '../../components/admin/AdminRankManager'; +import { AdminArtifactManager } from '../../components/admin/AdminArtifactManager'; +import { AdminSubmissionCard } from '../../components/admin/AdminSubmissionCard'; import { apiFetch } from '../../lib/api'; import { getDemoToken } from '../../lib/demo-auth'; interface Submission { + id: number; mission_id: number; status: string; comment: string | null; @@ -11,42 +17,151 @@ interface Submission { updated_at: string; } -async function fetchModerationQueue() { - const token = await getDemoToken(); - const submissions = await apiFetch('/api/admin/submissions', { authToken: token }); - return submissions; +interface MissionSummary { + id: number; + title: string; + description: string; + xp_reward: number; + mana_reward: number; + difficulty: 'easy' | 'medium' | 'hard'; + is_active: boolean; +} + +interface BranchSummary { + id: number; + title: string; + description: string; + category: string; + missions: Array<{ mission_id: number; mission_title: string; order: number }>; +} + +interface RankSummary { + id: number; + title: string; + description: string; + required_xp: number; +} + +interface CompetencySummary { + id: number; + name: string; + description: string; + category: string; +} + +interface ArtifactSummary { + id: number; + name: string; + description: string; + rarity: string; + image_url?: string | null; +} + +interface SubmissionStats { + pending: number; + approved: number; + rejected: number; +} + +interface BranchCompletionStat { + branch_id: number; + branch_title: string; + completion_rate: number; +} + +interface AdminStats { + total_users: number; + active_pilots: number; + average_completed_missions: number; + submission_stats: SubmissionStats; + branch_completion: BranchCompletionStat[]; } export default async function AdminPage() { - const submissions = await fetchModerationQueue(); + const token = await getDemoToken('hr'); + + const [submissions, missions, branches, ranks, competencies, artifacts, stats] = await Promise.all([ + apiFetch('/api/admin/submissions', { authToken: token }), + apiFetch('/api/admin/missions', { authToken: token }), + apiFetch('/api/admin/branches', { authToken: token }), + apiFetch('/api/admin/ranks', { authToken: token }), + apiFetch('/api/admin/competencies', { authToken: token }), + apiFetch('/api/admin/artifacts', { authToken: token }), + apiFetch('/api/admin/stats', { authToken: token }) + ]); return (
-

HR-панель: очередь модерации

+

HR-панель

- Демонстрационная выборка отправленных миссий. В реальном приложении добавим карточки с деталями пилота и - кнопки approve/reject непосредственно из UI. + Управляйте миссиями, ветками и рангами, а также следите за очередью модерации отчётов.

-
- {submissions.map((submission) => ( -
-

Миссия #{submission.mission_id}

-

Статус: {submission.status}

- {submission.comment &&

Комментарий пилота: {submission.comment}

} - {submission.proof_url && ( -

- Доказательство:{' '} - - открыть - -

- )} - - Обновлено: {new Date(submission.updated_at).toLocaleString('ru-RU')} - + +
+
+

Сводка

+
+
+ Пилоты +

{stats.total_users}

+ Всего зарегистрировано +
+
+ Активность +

{stats.active_pilots}

+ Закрыли хотя бы одну миссию +
+
+ Средний прогресс +

{stats.average_completed_missions.toFixed(1)}

+ Миссий на пилота +
- ))} - {submissions.length === 0 &&

Очередь пуста — все миссии проверены.

} +
+
+ Очередь модерации +

На проверке: {stats.submission_stats.pending}

+ + Одобрено: {stats.submission_stats.approved} · Отклонено: {stats.submission_stats.rejected} + +
+
+ Завершённость веток +
    + {stats.branch_completion.map((branchStat) => ( +
  • + {branchStat.branch_title}: {(branchStat.completion_rate * 100).toFixed(0)}% +
  • + ))} +
+
+
+
+ +
+

Очередь модерации

+

+ Список последующих отправок миссий. Для полноты UX можно добавить действия approve/reject прямо отсюда. +

+
+ {submissions.map((submission) => ( + + ))} + {submissions.length === 0 &&

Очередь пуста — все миссии проверены.

} +
+
+ + + + +
); diff --git a/frontend/src/app/journal/page.tsx b/frontend/src/app/journal/page.tsx index 453f26d..dcc3513 100644 --- a/frontend/src/app/journal/page.tsx +++ b/frontend/src/app/journal/page.tsx @@ -12,22 +12,62 @@ interface JournalEntry { created_at: string; } +interface LeaderboardEntry { + user_id: number; + full_name: string; + xp_delta: number; + mana_delta: number; +} + +interface LeaderboardResponse { + period: string; + entries: LeaderboardEntry[]; +} + async function fetchJournal() { const token = await getDemoToken(); - const entries = await apiFetch('/api/journal/', { authToken: token }); - return entries; + const [entries, week, month, year] = await Promise.all([ + apiFetch('/api/journal/', { authToken: token }), + apiFetch('/api/journal/leaderboard?period=week', { authToken: token }), + apiFetch('/api/journal/leaderboard?period=month', { authToken: token }), + apiFetch('/api/journal/leaderboard?period=year', { authToken: token }) + ]); + return { entries, leaderboards: [week, month, year] }; } export default async function JournalPage() { - const entries = await fetchJournal(); + const { entries, leaderboards } = await fetchJournal(); return ( -
-

Бортовой журнал

-

- Здесь фиксируется каждый шаг: отправка миссий, повышение ранга, получение артефактов и покупки в магазине. -

- +
+
+

Бортовой журнал

+

+ Здесь фиксируется каждый шаг: отправка миссий, повышение ранга, получение артефактов и покупки в магазине. +

+ +
+
); } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 2301609..4249975 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -10,17 +10,29 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + -
+
-

Mission Control

+

Mission Control

- Путь пилота от искателя до члена экипажа + Путь пилота от искателя до командира космической эскадры

-
); } diff --git a/frontend/src/app/missions/page.tsx b/frontend/src/app/missions/page.tsx index aaec8bf..4918de2 100644 --- a/frontend/src/app/missions/page.tsx +++ b/frontend/src/app/missions/page.tsx @@ -2,14 +2,35 @@ import { apiFetch } from '../../lib/api'; import { getDemoToken } from '../../lib/demo-auth'; import { MissionList, MissionSummary } from '../../components/MissionList'; +interface BranchMission { + mission_id: number; + mission_title: string; + order: number; + is_completed: boolean; + is_available: boolean; +} + +interface BranchOverview { + id: number; + title: string; + description: string; + category: string; + missions: BranchMission[]; + total_missions: number; + completed_missions: number; +} + async function fetchMissions() { const token = await getDemoToken(); - const missions = await apiFetch('/api/missions/', { authToken: token }); - return missions; + const [missions, branches] = await Promise.all([ + apiFetch('/api/missions/', { authToken: token }), + apiFetch('/api/missions/branches', { authToken: token }) + ]); + return { missions, branches }; } export default async function MissionsPage() { - const missions = await fetchMissions(); + const { missions, branches } = await fetchMissions(); return (
@@ -18,6 +39,45 @@ export default async function MissionsPage() { Список обновляется в реальном времени и зависит от вашего ранга и прогресса. HR может добавлять новые задания в админ-панели.

+
+ {branches.map((branch) => { + const progress = branch.total_missions + ? Math.round((branch.completed_missions / branch.total_missions) * 100) + : 0; + const nextMission = branch.missions.find((mission) => !mission.is_completed); + return ( +
+

{branch.title}

+

{branch.description}

+
+ Прогресс ветки: {progress}% +
+
+
+
+ {nextMission && ( +

+ Следующая миссия: «{nextMission.mission_title}» +

+ )} +
+ ); + })} +
); diff --git a/frontend/src/app/onboarding/page.tsx b/frontend/src/app/onboarding/page.tsx new file mode 100644 index 0000000..76d6f87 --- /dev/null +++ b/frontend/src/app/onboarding/page.tsx @@ -0,0 +1,50 @@ +import { apiFetch } from '../../lib/api'; +import { getDemoToken } from '../../lib/demo-auth'; +import { OnboardingCarousel, OnboardingSlide } from '../../components/OnboardingCarousel'; + +interface OnboardingState { + last_completed_order: number; + is_completed: boolean; +} + +interface OnboardingResponse { + slides: OnboardingSlide[]; + state: OnboardingState; + next_order: number | null; +} + +async function fetchOnboarding() { + const token = await getDemoToken(); + const data = await apiFetch('/api/onboarding/', { authToken: token }); + return { token, data }; +} + +export default async function OnboardingPage() { + const { token, data } = await fetchOnboarding(); + + return ( +
+
+ +
+ +
+ ); +} + diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index afc31df..33c69ce 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -6,7 +6,6 @@ interface ProfileResponse { full_name: string; xp: number; mana: number; - current_rank_id: number | null; competencies: Array<{ competency: { id: number; name: string }; level: number; @@ -18,40 +17,63 @@ interface ProfileResponse { }>; } -interface RankResponse { - id: number; - title: string; - description: string; +interface ProgressResponse { + current_rank: { id: number; title: string; description: string; required_xp: number } | null; + next_rank: { id: number; title: string; description: string; required_xp: number } | null; + xp: { + baseline: number; + current: number; + target: number; + remaining: number; + progress_percent: number; + }; + mission_requirements: Array<{ mission_id: number; mission_title: string; is_completed: boolean }>; + competency_requirements: Array<{ + competency_id: number; + competency_name: string; + required_level: number; + current_level: number; + is_met: boolean; + }>; + completed_missions: number; + total_missions: number; + met_competencies: number; + total_competencies: number; } async function fetchProfile() { const token = await getDemoToken(); - const profile = await apiFetch('/api/me', { authToken: token }); - const ranks = await apiFetch('/api/admin/ranks', { authToken: token }); - const currentRank = ranks.find((rank) => rank.id === profile.current_rank_id); - return { token, profile, currentRank }; + const [profile, progress] = await Promise.all([ + apiFetch('/api/me', { authToken: token }), + apiFetch('/api/progress', { authToken: token }) + ]); + + return { token, profile, progress }; } export default async function DashboardPage() { - const { token, profile, currentRank } = await fetchProfile(); + const { token, profile, progress } = await fetchProfile(); return (
+ +
+
+ Ключевые миссии +

+ {progress.completed_missions}/{progress.total_missions} выполнено. +

+ + {progress.mission_requirements.length === 0 && ( + + Все миссии для ранга уже зачтены. + + )} + {progress.mission_requirements.map((mission) => ( + + {mission.mission_title} + + {mission.is_completed ? 'готово' : 'ожидает'} + + + ))} + +
+
+ Компетенции ранга +

+ {progress.met_competencies}/{progress.total_competencies} требований закрыто. +

+ + {progress.competency_requirements.length === 0 && ( + + Дополнительные требования к компетенциям отсутствуют. + + )} + {progress.competency_requirements.map((competency) => { + const percentage = competency.required_level + ? Math.min(100, (competency.current_level / competency.required_level) * 100) + : 100; + const delta = Math.max(0, competency.required_level - competency.current_level); + + return ( + +
+ {competency.competency_name} + + Уровень {competency.current_level} / {competency.required_level} + + +
+ + {competency.is_met ? 'готово' : `нужно +${delta}`} + +
+ ); + })} +
+
+
+ +
+
+ Мана экипажа +

{mana} ⚡

+ Тратьте в магазине на мерч и бонусы. +
+
+ Текущие компетенции +
    + {competencies.map((item) => ( +
  • + {item.competency.name} + уровень {item.level} +
  • + ))} + {competencies.length === 0 &&
  • Компетенции ещё не открыты.
  • } +
+
+
+ +
+ Артефакты
- {artifacts.length === 0 &&

Ещё нет трофеев — выполните миссии!

} + {artifacts.length === 0 &&

Выполните миссии, чтобы собрать коллекцию трофеев.

} {artifacts.map((artifact) => ( -
+
{artifact.rarity}

{artifact.name}

))}
-
+
); } diff --git a/frontend/src/components/admin/AdminArtifactManager.tsx b/frontend/src/components/admin/AdminArtifactManager.tsx new file mode 100644 index 0000000..a60605a --- /dev/null +++ b/frontend/src/components/admin/AdminArtifactManager.tsx @@ -0,0 +1,192 @@ +'use client'; + +import { FormEvent, useState } from 'react'; +import { useRouter } from 'next/navigation'; + +import { apiFetch } from '../../lib/api'; + +type Artifact = { + id: number; + name: string; + description: string; + rarity: string; + image_url?: string | null; +}; + +const RARITY_OPTIONS = [ + { value: 'common', label: 'Обычный' }, + { value: 'rare', label: 'Редкий' }, + { value: 'epic', label: 'Эпический' }, + { value: 'legendary', label: 'Легендарный' } +]; + +interface Props { + token: string; + artifacts: Artifact[]; +} + +export function AdminArtifactManager({ token, artifacts }: Props) { + const router = useRouter(); + const [selectedId, setSelectedId] = useState('new'); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [rarity, setRarity] = useState('rare'); + const [imageUrl, setImageUrl] = useState(''); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const resetForm = () => { + setName(''); + setDescription(''); + setRarity('rare'); + setImageUrl(''); + }; + + const handleSelect = (value: string) => { + if (value === 'new') { + setSelectedId('new'); + resetForm(); + setStatus(null); + setError(null); + return; + } + + const id = Number(value); + const artifact = artifacts.find((item) => item.id === id); + if (!artifact) { + return; + } + + setSelectedId(id); + setName(artifact.name); + setDescription(artifact.description); + setRarity(artifact.rarity); + setImageUrl(artifact.image_url ?? ''); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setLoading(true); + setStatus(null); + setError(null); + + const payload = { + name, + description, + rarity, + image_url: imageUrl || null + }; + + try { + if (selectedId === 'new') { + await apiFetch('/api/admin/artifacts', { + method: 'POST', + body: JSON.stringify(payload), + authToken: token + }); + setStatus('Артефакт создан'); + resetForm(); + } else { + await apiFetch(`/api/admin/artifacts/${selectedId}`, { + method: 'PUT', + body: JSON.stringify(payload), + authToken: token + }); + setStatus('Артефакт обновлён'); + } + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Не удалось сохранить артефакт'); + } finally { + setLoading(false); + } + }; + + const handleDelete = async () => { + if (selectedId === 'new') { + return; + } + setLoading(true); + setStatus(null); + setError(null); + try { + await apiFetch(`/api/admin/artifacts/${selectedId}`, { + method: 'DELETE', + authToken: token + }); + setStatus('Артефакт удалён'); + setSelectedId('new'); + resetForm(); + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Не удалось удалить артефакт'); + } finally { + setLoading(false); + } + }; + + return ( +
+

Артефакты

+

+ Подготовьте коллекционные награды за миссии: укажите название, редкость и изображение. Артефакты можно привязывать + в карточке миссии. +

+
+ + + + +