diff --git a/README.md b/README.md
index 35afe15..0855de7 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,14 @@
- `docs/` — документация, дополнительный лор.
- `docker-compose.yaml` — инфраструктура проекта.
+## Основные пользовательские сценарии
+
+- Как кандидат: хочу проходить онбординг с лором и понимать своё место в программе.
+- Как кандидат: хочу видеть прогресс-бар до следующего ранга и инструкции по ключевым веткам.
+- Как кандидат: хочу получать награды (опыт, ману, артефакты) и видеть их в журнале.
+- Как HR: хочу управлять миссиями, ветками, артефактами и требованиями рангов.
+- Как HR: хочу видеть оперативную аналитику по активности и очереди модерации.
+
## Быстрый старт в Docker
1. Установите Docker и Docker Compose.
@@ -68,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
@@ -84,12 +100,16 @@ pytest
- Журнал событий, экспортируемый через API.
- Магазин артефактов с оформлением заказа.
- Админ-панель для HR: модерация отчётов, управление ветками, миссиями и требованиями к рангам.
+- Онбординг с сохранением прогресса и космическим лором.
+- Таблица лидеров по опыту и мане за неделю/месяц/год.
+- Аналитическая сводка для HR: активность пилотов, очередь модерации, завершённость веток.
Админ-инструменты доступны на странице `/admin` (используйте демо-пользователя HR). Здесь можно:
- создавать и редактировать ветки миссий с категориями и порядком;
- подготавливать новые миссии с наградами, зависимостями, ветками и компетенциями;
- настраивать ранги: требуемый опыт, обязательные миссии и уровни компетенций.
+- управлять каталогом артефактов и назначать их миссиям.
## План развития
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 bb01e4a..d6e529e 100644
--- a/backend/app/api/routes/admin.py
+++ b/backend/app/api/routes/admin.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import func
from sqlalchemy.exc import NoResultFound
from sqlalchemy.orm import Session, selectinload
@@ -18,8 +19,8 @@ from app.models.mission import (
SubmissionStatus,
)
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
-from app.models.user import Competency
-from app.schemas.artifact import ArtifactRead
+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,
@@ -38,6 +39,7 @@ from app.schemas.rank import (
)
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"])
@@ -108,9 +110,13 @@ def _branch_to_read(branch: Branch) -> BranchRead:
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,
)
@@ -255,6 +261,144 @@ def list_artifacts(
return [ArtifactRead.model_validate(artifact) for artifact in artifacts]
+@router.get("/stats", response_model=AdminDashboardStats, summary="Сводная аналитика")
+def dashboard_stats(
+ *, db: Session = Depends(get_db), current_user=Depends(require_hr)
+) -> AdminDashboardStats:
+ """Основные метрики прогресса и активности пользователей."""
+
+ total_pilots = db.query(User).filter(User.role == UserRole.PILOT).count()
+ approved_submissions = db.query(MissionSubmission).filter(
+ MissionSubmission.status == SubmissionStatus.APPROVED
+ )
+ active_pilots = (
+ approved_submissions.with_entities(MissionSubmission.user_id).distinct().count()
+ )
+
+ completed_counts = approved_submissions.with_entities(
+ MissionSubmission.user_id, func.count(MissionSubmission.id)
+ ).group_by(MissionSubmission.user_id)
+
+ total_completed = sum(row[1] for row in completed_counts)
+ average_completed = total_completed / active_pilots if active_pilots else 0.0
+
+ submission_stats = SubmissionStats(
+ pending=db.query(MissionSubmission).filter(MissionSubmission.status == SubmissionStatus.PENDING).count(),
+ approved=approved_submissions.count(),
+ rejected=db.query(MissionSubmission).filter(MissionSubmission.status == SubmissionStatus.REJECTED).count(),
+ )
+
+ branches = (
+ db.query(Branch)
+ .options(selectinload(Branch.missions))
+ .order_by(Branch.title)
+ .all()
+ )
+ branch_stats: list[BranchCompletionStat] = []
+ for branch in branches:
+ total_missions = len(branch.missions)
+ if total_missions == 0 or total_pilots == 0:
+ branch_stats.append(
+ BranchCompletionStat(branch_id=branch.id, branch_title=branch.title, completion_rate=0.0)
+ )
+ continue
+
+ approved_count = (
+ db.query(func.count(MissionSubmission.id))
+ .join(Mission, Mission.id == MissionSubmission.mission_id)
+ .join(BranchMission, BranchMission.mission_id == Mission.id)
+ .filter(
+ BranchMission.branch_id == branch.id,
+ MissionSubmission.status == SubmissionStatus.APPROVED,
+ )
+ .scalar()
+ )
+ denominator = total_missions * total_pilots
+ rate = min(1.0, approved_count / denominator) if denominator else 0.0
+ branch_stats.append(
+ BranchCompletionStat(branch_id=branch.id, branch_title=branch.title, completion_rate=rate)
+ )
+
+ return AdminDashboardStats(
+ total_users=total_pilots,
+ active_pilots=active_pilots,
+ average_completed_missions=round(average_completed, 2),
+ submission_stats=submission_stats,
+ branch_completion=branch_stats,
+ )
+
+
+@router.post("/artifacts", response_model=ArtifactRead, summary="Создать артефакт")
+def create_artifact(
+ artifact_in: ArtifactCreate,
+ *,
+ db: Session = Depends(get_db),
+ current_user=Depends(require_hr),
+) -> ArtifactRead:
+ """Добавляем новый артефакт в каталог."""
+
+ artifact = Artifact(
+ name=artifact_in.name,
+ description=artifact_in.description,
+ rarity=artifact_in.rarity,
+ image_url=artifact_in.image_url,
+ )
+ db.add(artifact)
+ db.commit()
+ db.refresh(artifact)
+ return ArtifactRead.model_validate(artifact)
+
+
+@router.put("/artifacts/{artifact_id}", response_model=ArtifactRead, summary="Обновить артефакт")
+def update_artifact(
+ artifact_id: int,
+ artifact_in: ArtifactUpdate,
+ *,
+ db: Session = Depends(get_db),
+ current_user=Depends(require_hr),
+) -> ArtifactRead:
+ """Редактируем существующий артефакт."""
+
+ artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
+ if not artifact:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Артефакт не найден")
+
+ payload = artifact_in.model_dump(exclude_unset=True)
+ for field, value in payload.items():
+ setattr(artifact, field, value)
+
+ db.commit()
+ db.refresh(artifact)
+ return ArtifactRead.model_validate(artifact)
+
+
+@router.delete(
+ "/artifacts/{artifact_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Удалить артефакт"
+)
+def delete_artifact(
+ artifact_id: int,
+ *,
+ db: Session = Depends(get_db),
+ current_user=Depends(require_hr),
+) -> None:
+ """Удаляем артефакт, если он не привязан к миссиям."""
+
+ artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
+ if not artifact:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Артефакт не найден")
+
+ missions_with_artifact = db.query(Mission).filter(Mission.artifact_id == artifact_id).count()
+ if missions_with_artifact:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Нельзя удалить артефакт, привязанный к миссиям",
+ )
+
+ db.delete(artifact)
+ db.commit()
+ return None
+
+
@router.post("/missions", response_model=MissionDetail, summary="Создать миссию")
def create_mission_endpoint(
mission_in: MissionCreate,
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 c3cf0e2..cb78e85 100644
--- a/backend/app/api/routes/missions.py
+++ b/backend/app/api/routes/missions.py
@@ -2,13 +2,15 @@
from __future__ import annotations
+from collections import defaultdict
+
from fastapi import APIRouter, Depends, HTTPException, status
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.mission import Mission, MissionSubmission, SubmissionStatus
from app.models.user import User
from app.schemas.branch import BranchMissionRead, BranchRead
from app.schemas.mission import (
@@ -22,12 +24,73 @@ 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))
@@ -35,23 +98,60 @@ def list_branches(
.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)
- ],
- )
+ 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="Список миссий")
@@ -60,13 +160,45 @@ def list_missions(
) -> 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="Карточка миссии")
@@ -82,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 = [
{
@@ -100,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,
@@ -120,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/core/config.py b/backend/app/core/config.py
index 495559b..c4b0a3f 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -1,7 +1,10 @@
"""Конфигурация приложения и загрузка окружения."""
+from __future__ import annotations
+
from functools import lru_cache
from pathlib import Path
+from typing import List
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -20,7 +23,7 @@ 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"]
sqlite_path: Path = Path("/data/app.db")
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
index b955bd7..116b78b 100644
--- a/backend/app/schemas/artifact.py
+++ b/backend/app/schemas/artifact.py
@@ -14,6 +14,25 @@ class ArtifactRead(BaseModel):
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 3ac776d..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,8 @@ class BranchRead(BaseModel):
description: str
category: str
missions: list[BranchMissionRead]
+ total_missions: int = 0
+ completed_missions: int = 0
class Config:
from_attributes = True
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 4aa2e00..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
@@ -95,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/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/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/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx
index ee41ccc..30a032a 100644
--- a/frontend/src/app/admin/page.tsx
+++ b/frontend/src/app/admin/page.tsx
@@ -1,10 +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;
@@ -51,18 +54,40 @@ interface ArtifactSummary {
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 token = await getDemoToken('hr');
- const [submissions, missions, branches, ranks, competencies, artifacts] = await Promise.all([
+ const [submissions, missions, branches, ranks, competencies, artifacts, stats] = await Promise.all([
apiFetch
{stats.total_users}
+ Всего зарегистрировано +{stats.active_pilots}
+ Закрыли хотя бы одну миссию +{stats.average_completed_missions.toFixed(1)}
+ Миссий на пилота +На проверке: {stats.submission_stats.pending}
+ + Одобрено: {stats.submission_stats.approved} · Отклонено: {stats.submission_stats.rejected} + +@@ -80,22 +145,7 @@ export default async function AdminPage() {
Статус: {submission.status}
- {submission.comment &&Комментарий пилота: {submission.comment}
} - {submission.proof_url && ( -- Доказательство:{' '} - - открыть - -
- )} - - Обновлено: {new Date(submission.updated_at).toLocaleString('ru-RU')} - -Очередь пуста — все миссии проверены.
}- Здесь фиксируется каждый шаг: отправка миссий, повышение ранга, получение артефактов и покупки в магазине. -
-+ Здесь фиксируется каждый шаг: отправка миссий, повышение ранга, получение артефактов и покупки в магазине. +
+- Путь пилота от искателя до члена экипажа + Путь пилота от искателя до командира космической эскадры
{branch.description}
++ Следующая миссия: «{nextMission.mission_title}» +
+ )} +