From 56373c1d45ca5323fc2822034b4b3b0774e96f1b Mon Sep 17 00:00:00 2001 From: danilgryaznev Date: Sun, 21 Sep 2025 19:59:36 +0200 Subject: [PATCH 1/8] merge --- README.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ac8cf77..a40a984 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,11 @@ ## Быстрый старт в 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,6 +26,8 @@ - API: http://localhost:8000 (документация Swagger — `/docs`). - Фронтенд: http://localhost:3000. +Docker Compose автоматически переопределяет `ALABUGA_SQLITE_PATH=/data/app.db`, чтобы база сохранялась во внешнем volume. Для локального запуска вне Docker оставьте путь `./data/app.db` из примера. + ## Локальная разработка backend ```bash @@ -30,9 +36,16 @@ python -m venv .venv source .venv/bin/activate 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 +56,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 ``` From 1b46293ce218ce9e16d71deec41209472dabadfc Mon Sep 17 00:00:00 2001 From: danilgryaznev Date: Mon, 22 Sep 2025 20:55:42 +0200 Subject: [PATCH 2/8] First working --- README.md | 92 ------------------------------------------------------- 1 file changed, 92 deletions(-) diff --git a/README.md b/README.md index a40a984..e69de29 100644 --- a/README.md +++ b/README.md @@ -1,92 +0,0 @@ -# Alabuga Gamification Platform - -Проект реализует прототип геймифицированного модуля для кадровой системы «Алабуги». Мы создаём космический лор, ранги, миссии, журнал событий и магазин артефактов. Репозиторий содержит backend на FastAPI (Python 3.13) и фронтенд на Next.js (TypeScript). - -## Содержимое репозитория - -- `backend/` — FastAPI, SQLAlchemy, Alembic, бизнес-логика геймификации. -- `frontend/` — Next.js с SSR, дизайн в космической стилистике. -- `scripts/` — служебные скрипты (сидирование демо-данных). -- `docs/` — документация, дополнительный лор. -- `docker-compose.yaml` — инфраструктура проекта. - -## Быстрый старт в Docker - -1. Установите Docker и Docker Compose. -2. Скопируйте пример конфигурации и при необходимости измените значения: - ```bash - cp backend/.env.example backend/.env - cp frontend/.env.example frontend/.env - ``` -3. Запустите окружение: - ```bash - docker compose up --build - ``` -4. После запуска будут доступны сервисы: - - 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 -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 -``` - -## Локальная разработка фронтенда - -```bash -cd frontend -npm install -cp .env.example .env -npm run dev -``` - -## Пользовательские учётные записи (сидированные) - -| Роль | Email | Пароль | -| --- | --- | --- | -| Пилот | `candidate@alabuga.space` | `orbita123` | -| HR | `hr@alabuga.space` | `orbita123` | - -## Тестирование - -```bash -cd backend -pytest -``` - -## Основные сценарии, реализованные в бэкенде - -- Авторизация по email/паролю с JWT. -- Получение профиля пилота со списком компетенций и артефактов. -- Получение миссий, отправка отчётов, модерация HR. -- Начисление опыта, маны и повышение ранга по трём условиям из ТЗ. -- Журнал событий, экспортируемый через API. -- Магазин артефактов с оформлением заказа. -- Админ-панель для HR: создание миссий, очередь модерации, список рангов. - -## План развития - -- Реализовать фронтенд-интерфейс всех экранов. -- Добавить экспорт CSV и агрегаты аналитики в API. -- Настроить CI/CD и автотесты во фреймворках фронтенда. -- Расширить документацию в `docs/` (описание лора, сценарии e2e). From 746e62d617c069b30a8e584ed844f73c541e3cca Mon Sep 17 00:00:00 2001 From: danilgryaznev Date: Mon, 22 Sep 2025 20:55:55 +0200 Subject: [PATCH 3/8] First working version --- README.md | 93 +++++++++++++++++++++++++++++++++ backend/Dockerfile | 3 +- backend/app/api/routes/users.py | 12 +++++ backend/app/core/config.py | 9 +++- backend/requirements.txt | 11 ++-- docker-compose.yaml | 8 +-- frontend/src/app/page.tsx | 2 +- scripts/seed_data.py | 3 +- 8 files changed, 126 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e69de29..8718b25 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,93 @@ +# Alabuga Gamification Platform + +Проект реализует прототип геймифицированного модуля для кадровой системы «Алабуги». Мы создаём космический лор, ранги, миссии, журнал событий и магазин артефактов. Репозиторий содержит backend на FastAPI (Python 3.13) и фронтенд на Next.js (TypeScript). + +## Содержимое репозитория + +- `backend/` — FastAPI, SQLAlchemy, Alembic, бизнес-логика геймификации. +- `frontend/` — Next.js с SSR, дизайн в космической стилистике. +- `scripts/` — служебные скрипты (сидирование демо-данных). +- `docs/` — документация, дополнительный лор. +- `docker-compose.yaml` — инфраструктура проекта. + +## Быстрый старт в Docker + +1. Установите Docker и Docker Compose. +2. Скопируйте пример конфигурации и при необходимости измените значения: + ```bash + cp backend/.env.example backend/.env + cp frontend/.env.example frontend/.env + ``` +3. Запустите окружение: + ```bash + docker compose up --build + ``` +4. После запуска будут доступны сервисы: + - 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 +``` + +## Локальная разработка фронтенда + +```bash +cd frontend +npm install +cp .env.example .env +npm run dev +``` + +## Пользовательские учётные записи (сидированные) + +| Роль | Email | Пароль | +| --- | --- | --- | +| Пилот | `candidate@alabuga.space` | `orbita123` | +| HR | `hr@alabuga.space` | `orbita123` | + +## Тестирование + +```bash +cd backend +pytest +``` + +## Основные сценарии, реализованные в бэкенде + +- Авторизация по email/паролю с JWT. +- Получение профиля пилота со списком компетенций и артефактов. +- Получение миссий, отправка отчётов, модерация HR. +- Начисление опыта, маны и повышение ранга по трём условиям из ТЗ. +- Журнал событий, экспортируемый через API. +- Магазин артефактов с оформлением заказа. +- Админ-панель для HR: создание миссий, очередь модерации, список рангов. + +## План развития + +- Реализовать фронтенд-интерфейс всех экранов. +- Добавить экспорт CSV и агрегаты аналитики в API. +- Настроить CI/CD и автотесты во фреймворках фронтенда. +- Расширить документацию в `docs/` (описание лора, сценарии e2e). diff --git a/backend/Dockerfile b/backend/Dockerfile index 261d010..698bbc2 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,7 +2,8 @@ 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 diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index f0e3943..06dab78 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -7,7 +7,9 @@ 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.rank import RankBase from app.schemas.user import UserProfile router = APIRouter(prefix="/api", tags=["profile"]) @@ -23,3 +25,13 @@ 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] diff --git a/backend/app/core/config.py b/backend/app/core/config.py index df08098..495559b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -6,10 +6,13 @@ from pathlib import Path 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" @@ -33,6 +36,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/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/docker-compose.yaml b/docker-compose.yaml index 3c29645..9e97bbc 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,9 @@ 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://backend:8000 + NEXT_PUBLIC_DEMO_EMAIL: candidate@alabuga.space + NEXT_PUBLIC_DEMO_PASSWORD: orbita123 volumes: - ./frontend:/app - /app/node_modules diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index afc31df..3622591 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -27,7 +27,7 @@ interface RankResponse { 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 ranks = await apiFetch('/api/ranks', { authToken: token }); const currentRank = ranks.find((rank) => rank.id === profile.current_rank_id); return { token, profile, currentRank }; } diff --git a/scripts/seed_data.py b/scripts/seed_data.py index 4d693d4..2fff2a8 100644 --- a/scripts/seed_data.py +++ b/scripts/seed_data.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import Session ROOT = Path(__file__).resolve().parents[1] sys.path.append(str(ROOT / 'backend')) +from app.core.config import settings from app.core.security import get_password_hash from app.db.session import SessionLocal, engine from app.models.artifact import Artifact, ArtifactRarity @@ -20,7 +21,7 @@ from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirem from app.models.store import StoreItem from app.models.user import Competency, CompetencyCategory, User, UserCompetency, UserRole -DATA_SENTINEL = Path("/data/.seeded") +DATA_SENTINEL = settings.sqlite_path.parent / ".seeded" def ensure_database() -> None: From ac7ead09c9fef33e33c315ee05cf507315b217a6 Mon Sep 17 00:00:00 2001 From: danilgryaznev Date: Tue, 23 Sep 2025 20:40:25 +0200 Subject: [PATCH 4/8] working --- docker-compose.yaml | 2 ++ frontend/src/app/admin/page.tsx | 2 +- frontend/src/lib/demo-auth.ts | 30 +++++++++++++++++++++++------- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 9e97bbc..f1f1616 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -26,6 +26,8 @@ services: NEXT_PUBLIC_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..1ce86c6 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -12,7 +12,7 @@ interface Submission { } async function fetchModerationQueue() { - const token = await getDemoToken(); + const token = await getDemoToken('hr'); const submissions = await apiFetch('/api/admin/submissions', { authToken: token }); return submissions; } diff --git a/frontend/src/lib/demo-auth.ts b/frontend/src/lib/demo-auth.ts index ce23ccb..1f582e3 100644 --- a/frontend/src/lib/demo-auth.ts +++ b/frontend/src/lib/demo-auth.ts @@ -1,20 +1,36 @@ import { apiFetch } from './api'; -let cachedToken: string | null = null; +type DemoRole = 'pilot' | 'hr'; -export async function getDemoToken() { +const tokenCache: Partial> = {}; + +function resolveCredentials(role: DemoRole) { + if (role === 'hr') { + const email = process.env.NEXT_PUBLIC_DEMO_HR_EMAIL ?? 'hr@alabuga.space'; + const password = + process.env.NEXT_PUBLIC_DEMO_HR_PASSWORD ?? process.env.NEXT_PUBLIC_DEMO_PASSWORD ?? 'orbita123'; + return { email, password }; + } + + return { + email: process.env.NEXT_PUBLIC_DEMO_EMAIL ?? 'candidate@alabuga.space', + password: process.env.NEXT_PUBLIC_DEMO_PASSWORD ?? 'orbita123' + }; +} + +export async function getDemoToken(role: DemoRole = 'pilot') { + const cachedToken = tokenCache[role]; if (cachedToken) { return cachedToken; } - const email = process.env.NEXT_PUBLIC_DEMO_EMAIL ?? 'candidate@alabuga.space'; - const password = process.env.NEXT_PUBLIC_DEMO_PASSWORD ?? 'orbita123'; + const credentials = resolveCredentials(role); const data = await apiFetch<{ access_token: string }>('/auth/login', { method: 'POST', - body: JSON.stringify({ email, password }) + body: JSON.stringify(credentials) }); - cachedToken = data.access_token; - return cachedToken; + tokenCache[role] = data.access_token; + return data.access_token; } From 89734706b873b2fb0b4461a93191374bdddaf6da Mon Sep 17 00:00:00 2001 From: danilgryaznev Date: Wed, 24 Sep 2025 20:18:46 +0200 Subject: [PATCH 5/8] Add creat missiom --- README.md | 8 +- backend/app/api/routes/admin.py | 497 +++++++++++++++--- backend/app/api/routes/missions.py | 36 +- backend/app/schemas/artifact.py | 19 + backend/app/schemas/branch.py | 14 + backend/app/schemas/mission.py | 17 + backend/app/schemas/rank.py | 35 +- frontend/src/app/admin/page.tsx | 118 ++++- .../components/admin/AdminBranchManager.tsx | 143 +++++ .../components/admin/AdminMissionManager.tsx | 411 +++++++++++++++ .../src/components/admin/AdminRankManager.tsx | 279 ++++++++++ 11 files changed, 1487 insertions(+), 90 deletions(-) create mode 100644 backend/app/schemas/artifact.py create mode 100644 frontend/src/components/admin/AdminBranchManager.tsx create mode 100644 frontend/src/components/admin/AdminMissionManager.tsx create mode 100644 frontend/src/components/admin/AdminRankManager.tsx diff --git a/README.md b/README.md index 8718b25..35afe15 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,13 @@ pytest - Начисление опыта, маны и повышение ранга по трём условиям из ТЗ. - Журнал событий, экспортируемый через API. - Магазин артефактов с оформлением заказа. -- Админ-панель для HR: создание миссий, очередь модерации, список рангов. +- Админ-панель для HR: модерация отчётов, управление ветками, миссиями и требованиями к рангам. + +Админ-инструменты доступны на странице `/admin` (используйте демо-пользователя HR). Здесь можно: + +- создавать и редактировать ветки миссий с категориями и порядком; +- подготавливать новые миссии с наградами, зависимостями, ветками и компетенциями; +- настраивать ранги: требуемый опыт, обязательные миссии и уровни компетенций. ## План развития diff --git a/backend/app/api/routes/admin.py b/backend/app/api/routes/admin.py index bc68638..bb01e4a 100644 --- a/backend/app/api/routes/admin.py +++ b/backend/app/api/routes/admin.py @@ -3,74 +3,47 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session +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 +from app.schemas.artifact import ArtifactRead +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 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 +69,313 @@ 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, + ) + for item in missions + ], + ) + + +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.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 +384,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/missions.py b/backend/app/api/routes/missions.py index 0a95930..c3cf0e2 100644 --- a/backend/app/api/routes/missions.py +++ b/backend/app/api/routes/missions.py @@ -3,12 +3,14 @@ from __future__ import annotations 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.branch import Branch, BranchMission from app.models.mission import Mission, MissionSubmission from app.models.user import User +from app.schemas.branch import BranchMissionRead, BranchRead from app.schemas.mission import ( MissionBase, MissionDetail, @@ -20,6 +22,38 @@ from app.services.mission import submit_mission router = APIRouter(prefix="/api/missions", tags=["missions"]) +@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]: + """Возвращаем ветки с упорядоченными миссиями.""" + + branches = ( + db.query(Branch) + .options(selectinload(Branch.missions).selectinload(BranchMission.mission)) + .order_by(Branch.title) + .all() + ) + + 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, + ) + for item in sorted(branch.missions, key=lambda link: link.order) + ], + ) + for branch in branches + ] + + @router.get("/", response_model=list[MissionBase], summary="Список миссий") def list_missions( *, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) diff --git a/backend/app/schemas/artifact.py b/backend/app/schemas/artifact.py new file mode 100644 index 0000000..b955bd7 --- /dev/null +++ b/backend/app/schemas/artifact.py @@ -0,0 +1,19 @@ +"""Схемы артефактов.""" + +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 + + class Config: + from_attributes = True diff --git a/backend/app/schemas/branch.py b/backend/app/schemas/branch.py index 8c79f91..3ac776d 100644 --- a/backend/app/schemas/branch.py +++ b/backend/app/schemas/branch.py @@ -24,3 +24,17 @@ class BranchRead(BaseModel): class Config: from_attributes = True + + +class BranchCreate(BaseModel): + """Создание ветки.""" + + title: str + description: str + category: str + + +class BranchUpdate(BranchCreate): + """Обновление ветки.""" + + pass diff --git a/backend/app/schemas/mission.py b/backend/app/schemas/mission.py index 0aad92e..4aa2e00 100644 --- a/backend/app/schemas/mission.py +++ b/backend/app/schemas/mission.py @@ -68,6 +68,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): """Отправка отчёта по миссии.""" 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/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 1ce86c6..ee41ccc 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1,3 +1,6 @@ +import { AdminBranchManager } from '../../components/admin/AdminBranchManager'; +import { AdminMissionManager } from '../../components/admin/AdminMissionManager'; +import { AdminRankManager } from '../../components/admin/AdminRankManager'; import { apiFetch } from '../../lib/api'; import { getDemoToken } from '../../lib/demo-auth'; @@ -11,42 +14,103 @@ interface Submission { updated_at: string; } -async function fetchModerationQueue() { - const token = await getDemoToken('hr'); - 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: string; + 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; } export default async function AdminPage() { - const submissions = await fetchModerationQueue(); + const token = await getDemoToken('hr'); + + const [submissions, missions, branches, ranks, competencies, artifacts] = 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 }) + ]); 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')} - + +
+
+

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

+

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

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

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

+

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

+ {submission.comment &&

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

} + {submission.proof_url && ( +

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

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

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

}
- ))} - {submissions.length === 0 &&

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

} +
+ + + +
); diff --git a/frontend/src/components/admin/AdminBranchManager.tsx b/frontend/src/components/admin/AdminBranchManager.tsx new file mode 100644 index 0000000..1ab8afd --- /dev/null +++ b/frontend/src/components/admin/AdminBranchManager.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { FormEvent, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; + +import { apiFetch } from '../../lib/api'; + +type Branch = { + id: number; + title: string; + description: string; + category: string; +}; + +interface Props { + token: string; + branches: Branch[]; +} + +const DEFAULT_CATEGORY_OPTIONS = ['quest', 'recruiting', 'lecture', 'simulator']; + +export function AdminBranchManager({ token, branches }: Props) { + const router = useRouter(); + const [selectedId, setSelectedId] = useState('new'); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [category, setCategory] = useState('quest'); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + + const categoryOptions = useMemo(() => { + const existing = new Set(DEFAULT_CATEGORY_OPTIONS); + branches.forEach((branch) => existing.add(branch.category)); + return Array.from(existing.values()); + }, [branches]); + + const resetForm = () => { + setTitle(''); + setDescription(''); + setCategory(categoryOptions[0] ?? 'quest'); + }; + + const handleSelect = (value: string) => { + if (value === 'new') { + setSelectedId('new'); + resetForm(); + return; + } + + const id = Number(value); + const branch = branches.find((item) => item.id === id); + if (!branch) { + setSelectedId('new'); + resetForm(); + return; + } + + setSelectedId(id); + setTitle(branch.title); + setDescription(branch.description); + setCategory(branch.category); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setStatus(null); + setError(null); + + const payload = { title, description, category }; + + try { + if (selectedId === 'new') { + await apiFetch('/api/admin/branches', { + method: 'POST', + body: JSON.stringify(payload), + authToken: token + }); + setStatus('Ветка создана'); + } else { + await apiFetch(`/api/admin/branches/${selectedId}`, { + method: 'PUT', + body: JSON.stringify(payload), + authToken: token + }); + setStatus('Ветка обновлена'); + } + router.refresh(); + if (selectedId === 'new') { + resetForm(); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Не удалось сохранить ветку'); + } + }; + + return ( +
+

Управление ветками

+

+ Создавайте или обновляйте ветки, чтобы миссии были организованы по сюжетам и категориям. +

+
+ + + + +