Add futures

This commit is contained in:
danilgryaznev 2025-09-25 04:55:43 +02:00
parent 89734706b8
commit 1ab0cb1f1f
39 changed files with 1643 additions and 91 deletions

View File

@ -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). Здесь можно:
- создавать и редактировать ветки миссий с категориями и порядком;
- подготавливать новые миссии с наградами, зависимостями, ветками и компетенциями;
- настраивать ранги: требуемый опыт, обязательные миссии и уровни компетенций.
- управлять каталогом артефактов и назначать их миссиям.
## План развития

View File

@ -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")

View File

@ -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",
]

View File

@ -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,

View File

@ -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)

View File

@ -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,

View File

@ -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,
)

View File

@ -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")

View File

@ -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)

View File

@ -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",

View File

@ -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)

View File

@ -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")

View File

@ -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")

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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]

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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<Submission[]>('/api/admin/submissions', { authToken: token }),
apiFetch<MissionSummary[]>('/api/admin/missions', { authToken: token }),
apiFetch<BranchSummary[]>('/api/admin/branches', { authToken: token }),
apiFetch<RankSummary[]>('/api/admin/ranks', { authToken: token }),
apiFetch<CompetencySummary[]>('/api/admin/competencies', { authToken: token }),
apiFetch<ArtifactSummary[]>('/api/admin/artifacts', { authToken: token })
apiFetch<ArtifactSummary[]>('/api/admin/artifacts', { authToken: token }),
apiFetch<AdminStats>('/api/admin/stats', { authToken: token })
]);
return (
@ -73,6 +98,46 @@ export default async function AdminPage() {
</p>
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem' }}>
<div className="card" style={{ gridColumn: '1 / -1', display: 'grid', gap: '1rem' }}>
<h3>Сводка</h3>
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '1rem' }}>
<div className="card" style={{ marginBottom: 0 }}>
<span className="badge">Пилоты</span>
<p style={{ fontSize: '2rem', margin: '1rem 0 0' }}>{stats.total_users}</p>
<small style={{ color: 'var(--text-muted)' }}>Всего зарегистрировано</small>
</div>
<div className="card" style={{ marginBottom: 0 }}>
<span className="badge">Активность</span>
<p style={{ fontSize: '2rem', margin: '1rem 0 0' }}>{stats.active_pilots}</p>
<small style={{ color: 'var(--text-muted)' }}>Закрыли хотя бы одну миссию</small>
</div>
<div className="card" style={{ marginBottom: 0 }}>
<span className="badge">Средний прогресс</span>
<p style={{ fontSize: '2rem', margin: '1rem 0 0' }}>{stats.average_completed_missions.toFixed(1)}</p>
<small style={{ color: 'var(--text-muted)' }}>Миссий на пилота</small>
</div>
</div>
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '1rem' }}>
<div className="card" style={{ marginBottom: 0 }}>
<strong>Очередь модерации</strong>
<p style={{ marginTop: '0.5rem' }}>На проверке: {stats.submission_stats.pending}</p>
<small style={{ color: 'var(--text-muted)' }}>
Одобрено: {stats.submission_stats.approved} · Отклонено: {stats.submission_stats.rejected}
</small>
</div>
<div className="card" style={{ marginBottom: 0 }}>
<strong>Завершённость веток</strong>
<ul style={{ listStyle: 'none', margin: '0.75rem 0 0', padding: 0 }}>
{stats.branch_completion.map((branchStat) => (
<li key={branchStat.branch_id} style={{ marginBottom: '0.25rem' }}>
{branchStat.branch_title}: {(branchStat.completion_rate * 100).toFixed(0)}%
</li>
))}
</ul>
</div>
</div>
</div>
<div className="card" style={{ gridColumn: '1 / -1' }}>
<h3>Очередь модерации</h3>
<p style={{ color: 'var(--text-muted)' }}>
@ -80,22 +145,7 @@ export default async function AdminPage() {
</p>
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1rem' }}>
{submissions.map((submission) => (
<div key={`${submission.mission_id}-${submission.updated_at}`} className="card">
<h4>Миссия #{submission.mission_id}</h4>
<p>Статус: {submission.status}</p>
{submission.comment && <p>Комментарий пилота: {submission.comment}</p>}
{submission.proof_url && (
<p>
Доказательство:{' '}
<a href={submission.proof_url} target="_blank" rel="noreferrer">
открыть
</a>
</p>
)}
<small style={{ color: 'var(--text-muted)' }}>
Обновлено: {new Date(submission.updated_at).toLocaleString('ru-RU')}
</small>
</div>
<AdminSubmissionCard key={submission.id} submission={submission} token={token} />
))}
{submissions.length === 0 && <p>Очередь пуста все миссии проверены.</p>}
</div>
@ -111,6 +161,7 @@ export default async function AdminPage() {
artifacts={artifacts}
/>
<AdminRankManager token={token} ranks={ranks} missions={missions} competencies={competencies} />
<AdminArtifactManager token={token} artifacts={artifacts} />
</div>
</section>
);

View File

@ -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<JournalEntry[]>('/api/journal/', { authToken: token });
return entries;
const [entries, week, month, year] = await Promise.all([
apiFetch<JournalEntry[]>('/api/journal/', { authToken: token }),
apiFetch<LeaderboardResponse>('/api/journal/leaderboard?period=week', { authToken: token }),
apiFetch<LeaderboardResponse>('/api/journal/leaderboard?period=month', { authToken: token }),
apiFetch<LeaderboardResponse>('/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 (
<section>
<h2>Бортовой журнал</h2>
<p style={{ color: 'var(--text-muted)' }}>
Здесь фиксируется каждый шаг: отправка миссий, повышение ранга, получение артефактов и покупки в магазине.
</p>
<JournalTimeline entries={entries} />
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr', gap: '2rem' }}>
<div>
<h2>Бортовой журнал</h2>
<p style={{ color: 'var(--text-muted)' }}>
Здесь фиксируется каждый шаг: отправка миссий, повышение ранга, получение артефактов и покупки в магазине.
</p>
<JournalTimeline entries={entries} />
</div>
<aside className="card" style={{ position: 'sticky', top: '1.5rem' }}>
<h3>ТОП экипажа</h3>
{leaderboards.map((board) => (
<div key={board.period} style={{ marginBottom: '1rem' }}>
<strong style={{ textTransform: 'capitalize' }}>
{board.period === 'week' ? 'Неделя' : board.period === 'month' ? 'Месяц' : 'Год'}
</strong>
<ul style={{ listStyle: 'none', padding: 0, margin: '0.5rem 0 0' }}>
{board.entries.length === 0 && <li style={{ color: 'var(--text-muted)' }}>Пока нет лидеров.</li>}
{board.entries.map((entry, index) => (
<li key={entry.user_id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.9rem' }}>
<span>
#{index + 1} {entry.full_name}
</span>
<span>{entry.xp_delta} XP · {entry.mana_delta} </span>
</li>
))}
</ul>
</div>
))}
</aside>
</section>
);
}

View File

@ -10,17 +10,29 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<body>
<body style={{ backgroundAttachment: 'fixed' }}>
<StyledComponentsRegistry>
<header style={{ padding: '1.5rem', display: 'flex', justifyContent: 'space-between' }}>
<header
style={{
padding: '1.5rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background:
'linear-gradient(135deg, rgba(108,92,231,0.25), rgba(0,184,148,0.15))',
borderBottom: '1px solid rgba(162, 155, 254, 0.2)',
boxShadow: '0 10px 30px rgba(0,0,0,0.25)'
}}
>
<div>
<h1 style={{ margin: 0 }}>Mission Control</h1>
<h1 style={{ margin: 0, letterSpacing: '0.08em', textTransform: 'uppercase' }}>Mission Control</h1>
<p style={{ margin: 0, color: 'var(--text-muted)' }}>
Путь пилота от искателя до члена экипажа
Путь пилота от искателя до командира космической эскадры
</p>
</div>
<nav style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<nav style={{ display: 'flex', gap: '1rem', alignItems: 'center', fontWeight: 500 }}>
<a href="/">Дашборд</a>
<a href="/onboarding">Онбординг</a>
<a href="/missions">Миссии</a>
<a href="/journal">Журнал</a>
<a href="/store">Магазин</a>

View File

@ -17,6 +17,8 @@ interface MissionDetail {
competency_name: string;
level_delta: number;
}>;
is_available: boolean;
locked_reasons: string[];
}
async function fetchMission(id: number) {
@ -41,6 +43,16 @@ export default async function MissionPage({ params }: MissionPageProps) {
<p style={{ marginTop: '1rem' }}>
Награда: {mission.xp_reward} XP · {mission.mana_reward}
</p>
{!mission.is_available && mission.locked_reasons.length > 0 && (
<div className="card" style={{ border: '1px solid rgba(255, 118, 117, 0.5)', background: 'rgba(255,118,117,0.1)' }}>
<strong>Миссия заблокирована</strong>
<ul style={{ marginTop: '0.5rem' }}>
{mission.locked_reasons.map((reason) => (
<li key={reason}>{reason}</li>
))}
</ul>
</div>
)}
<div className="card">
<h3>Компетенции</h3>
<ul style={{ listStyle: 'none', padding: 0 }}>
@ -52,7 +64,7 @@ export default async function MissionPage({ params }: MissionPageProps) {
{mission.competency_rewards.length === 0 && <li>Нет прокачки компетенций.</li>}
</ul>
</div>
<MissionSubmissionForm missionId={mission.id} token={token} />
<MissionSubmissionForm missionId={mission.id} token={token} locked={!mission.is_available} />
</section>
);
}

View File

@ -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<MissionSummary[]>('/api/missions/', { authToken: token });
return missions;
const [missions, branches] = await Promise.all([
apiFetch<MissionSummary[]>('/api/missions/', { authToken: token }),
apiFetch<BranchOverview[]>('/api/missions/branches', { authToken: token })
]);
return { missions, branches };
}
export default async function MissionsPage() {
const missions = await fetchMissions();
const { missions, branches } = await fetchMissions();
return (
<section>
@ -18,6 +39,45 @@ export default async function MissionsPage() {
Список обновляется в реальном времени и зависит от вашего ранга и прогресса. HR может добавлять новые
задания в админ-панели.
</p>
<div className="grid" style={{ marginBottom: '2rem' }}>
{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 (
<div key={branch.id} className="card">
<h3 style={{ marginBottom: '0.5rem' }}>{branch.title}</h3>
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{branch.description}</p>
<div style={{ marginTop: '1rem' }}>
<small>Прогресс ветки: {progress}%</small>
<div
style={{
marginTop: '0.5rem',
height: '8px',
borderRadius: '999px',
background: 'rgba(162, 155, 254, 0.2)',
overflow: 'hidden'
}}
>
<div
style={{
width: `${progress}%`,
height: '100%',
background: 'linear-gradient(90deg, var(--accent), #00b894)'
}}
/>
</div>
</div>
{nextMission && (
<p style={{ marginTop: '1rem', color: 'var(--text-muted)' }}>
Следующая миссия: «{nextMission.mission_title}»
</p>
)}
</div>
);
})}
</div>
<MissionList missions={missions} />
</section>
);

View File

@ -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<OnboardingResponse>('/api/onboarding/', { authToken: token });
return { token, data };
}
export default async function OnboardingPage() {
const { token, data } = await fetchOnboarding();
return (
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr', alignItems: 'start', gap: '2rem' }}>
<div>
<OnboardingCarousel
token={token}
slides={data.slides}
initialCompletedOrder={data.state.last_completed_order}
nextOrder={data.next_order}
/>
</div>
<aside className="card" style={{ position: 'sticky', top: '1.5rem' }}>
<h3>Бортовой навигатор</h3>
<p style={{ color: 'var(--text-muted)', lineHeight: 1.6 }}>
Онбординг знакомит вас с космической культурой «Алабуги» и объясняет, как миссии превращают карьерные шаги в
единый маршрут. Каждый шаг открывает новые подсказки и призы в магазине.
</p>
<ul style={{ listStyle: 'none', margin: '1rem 0 0', padding: 0, color: 'var(--text-muted)' }}>
<li> Читайте лор и переходите к миссиям напрямую.</li>
<li> Следите за прогрессом статус сохраняется автоматически.</li>
<li> Доступ к веткам миссий открывается по мере выполнения шагов.</li>
</ul>
</aside>
</section>
);
}

View File

@ -22,18 +22,29 @@ interface RankResponse {
id: number;
title: string;
description: string;
required_xp: number;
}
async function fetchProfile() {
const token = await getDemoToken();
const profile = await apiFetch<ProfileResponse>('/api/me', { authToken: token });
const ranks = await apiFetch<RankResponse[]>('/api/ranks', { authToken: token });
const currentRank = ranks.find((rank) => rank.id === profile.current_rank_id);
return { token, profile, currentRank };
const orderedRanks = [...ranks].sort((a, b) => a.required_xp - b.required_xp);
const currentIndex = Math.max(
orderedRanks.findIndex((rank) => rank.id === profile.current_rank_id),
0
);
const currentRank = orderedRanks[currentIndex] ?? null;
const nextRank = orderedRanks[currentIndex + 1] ?? null;
const baselineXp = currentRank ? currentRank.required_xp : 0;
const progress = Math.max(profile.xp - baselineXp, 0);
const target = nextRank ? nextRank.required_xp - baselineXp : 0;
return { token, profile, currentRank, nextRank, progress, target };
}
export default async function DashboardPage() {
const { token, profile, currentRank } = await fetchProfile();
const { token, profile, currentRank, nextRank, progress, target } = await fetchProfile();
return (
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr' }}>
@ -45,6 +56,9 @@ export default async function DashboardPage() {
rank={currentRank}
competencies={profile.competencies}
artifacts={profile.artifacts}
nextRankTitle={nextRank?.title}
xpProgress={progress}
xpTarget={target}
/>
</div>
<aside className="card">

View File

@ -10,6 +10,8 @@ export interface MissionSummary {
mana_reward: number;
difficulty: string;
is_active: boolean;
is_available: boolean;
locked_reasons: string[];
}
const Card = styled.div`
@ -31,14 +33,24 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
<span className="badge">{mission.difficulty}</span>
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
<p style={{ marginTop: '1rem' }}>
{mission.xp_reward} XP · {mission.mana_reward}
</p>
<a className="primary" style={{ display: 'inline-block', marginTop: '1rem' }} href={`/missions/${mission.id}`}>
Открыть брифинг
</a>
</Card>
))}
</div>
<p style={{ marginTop: '1rem' }}>{mission.xp_reward} XP · {mission.mana_reward} </p>
{!mission.is_available && mission.locked_reasons.length > 0 && (
<p style={{ color: 'var(--error)', fontSize: '0.85rem' }}>{mission.locked_reasons[0]}</p>
)}
<a
className={mission.is_available ? 'primary' : 'secondary'}
style={{
display: 'inline-block',
marginTop: '1rem',
pointerEvents: mission.is_available ? 'auto' : 'none',
opacity: mission.is_available ? 1 : 0.5
}}
href={mission.is_available ? `/missions/${mission.id}` : '#'}
>
{mission.is_available ? 'Открыть брифинг' : 'Заблокировано'}
</a>
</Card>
))}
</div>
);
}

View File

@ -6,9 +6,10 @@ import { apiFetch } from '../lib/api';
interface MissionSubmissionFormProps {
missionId: number;
token?: string;
locked?: boolean;
}
export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFormProps) {
export function MissionSubmissionForm({ missionId, token, locked = false }: MissionSubmissionFormProps) {
const [comment, setComment] = useState('');
const [proofUrl, setProofUrl] = useState('');
const [status, setStatus] = useState<string | null>(null);
@ -21,6 +22,11 @@ export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFor
return;
}
if (locked) {
setStatus('Миссия пока недоступна — выполните предыдущие условия.');
return;
}
try {
setLoading(true);
setStatus(null);
@ -52,6 +58,7 @@ export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFor
rows={4}
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
placeholder="Опишите, что сделали."
disabled={locked}
/>
</label>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
@ -62,10 +69,11 @@ export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFor
onChange={(event) => setProofUrl(event.target.value)}
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
placeholder="https://..."
disabled={locked}
/>
</label>
<button className="primary" type="submit" disabled={loading}>
{loading ? 'Отправляем...' : 'Отправить HR'}
<button className="primary" type="submit" disabled={loading || locked}>
{locked ? 'Недоступно' : loading ? 'Отправляем...' : 'Отправить HR'}
</button>
{status && <p style={{ marginTop: '1rem', color: 'var(--accent-light)' }}>{status}</p>}
</form>

View File

@ -0,0 +1,123 @@
'use client';
import { useState } from 'react';
import { apiFetch } from '../lib/api';
export interface OnboardingSlide {
id: number;
order: number;
title: string;
body: string;
media_url?: string | null;
cta_text?: string | null;
cta_link?: string | null;
}
interface OnboardingCarouselProps {
token?: string;
slides: OnboardingSlide[];
initialCompletedOrder: number;
nextOrder: number | null;
}
export function OnboardingCarousel({ token, slides, initialCompletedOrder, nextOrder }: OnboardingCarouselProps) {
const startIndex = nextOrder ? Math.max(slides.findIndex((slide) => slide.order === nextOrder), 0) : slides.length - 1;
const [currentIndex, setCurrentIndex] = useState(Math.max(startIndex, 0));
const [completedOrder, setCompletedOrder] = useState(initialCompletedOrder);
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const currentSlide = slides[currentIndex];
const allCompleted = completedOrder >= (slides.at(-1)?.order ?? 0);
async function handleComplete() {
if (!token || !currentSlide) {
setStatus('Не удалось определить пользователя. Попробуйте обновить страницу.');
return;
}
try {
setLoading(true);
await apiFetch('/api/onboarding/complete', {
method: 'POST',
body: JSON.stringify({ order: currentSlide.order }),
authToken: token
});
setCompletedOrder(currentSlide.order);
setStatus('Шаг отмечен как выполненный!');
if (currentIndex < slides.length - 1) {
setCurrentIndex(currentIndex + 1);
}
} catch (error) {
if (error instanceof Error) {
setStatus(error.message);
}
} finally {
setLoading(false);
}
}
if (!currentSlide) {
return <p>Онбординг недоступен. Свяжитесь с HR.</p>;
}
return (
<div className="card" style={{ display: 'grid', gap: '1.5rem', background: 'rgba(17, 22, 51, 0.9)' }}>
<header>
<span className="badge">Шаг {currentIndex + 1} из {slides.length}</span>
<h2 style={{ margin: '0.75rem 0' }}>{currentSlide.title}</h2>
<p style={{ color: 'var(--text-muted)', lineHeight: 1.6 }}>{currentSlide.body}</p>
</header>
{currentSlide.media_url && (
<div
style={{
borderRadius: '16px',
overflow: 'hidden',
border: '1px solid rgba(162, 155, 254, 0.25)'
}}
>
<img src={currentSlide.media_url} alt={currentSlide.title} style={{ width: '100%', height: 'auto' }} />
</div>
)}
<footer style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{currentSlide.cta_text && currentSlide.cta_link && (
<a className="primary" href={currentSlide.cta_link}>
{currentSlide.cta_text}
</a>
)}
{!allCompleted && (
<button className="primary" onClick={handleComplete} disabled={loading}>
{loading ? 'Сохраняем...' : 'Отметить как выполнено'}
</button>
)}
{status && <small style={{ color: 'var(--accent-light)' }}>{status}</small>}
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '0.5rem' }}>
<button
className="secondary"
onClick={() => setCurrentIndex(Math.max(currentIndex - 1, 0))}
disabled={currentIndex === 0}
style={{ cursor: currentIndex === 0 ? 'not-allowed' : 'pointer' }}
>
Назад
</button>
<button
className="secondary"
onClick={() => setCurrentIndex(Math.min(currentIndex + 1, slides.length - 1))}
disabled={currentIndex === slides.length - 1}
style={{ cursor: currentIndex === slides.length - 1 ? 'not-allowed' : 'pointer' }}
>
Далее
</button>
</div>
{allCompleted && <p style={{ color: 'var(--success)', marginTop: '0.5rem' }}>Вы прошли весь онбординг!</p>}
</footer>
</div>
);
}

View File

@ -28,6 +28,9 @@ export interface ProfileProps {
rank?: Rank;
competencies: Competency[];
artifacts: Artifact[];
nextRankTitle?: string;
xpProgress: number;
xpTarget: number;
}
const Card = styled.div`
@ -54,8 +57,18 @@ const ProgressBar = styled.div<{ value: number }>`
}
`;
export function ProgressOverview({ fullName, xp, mana, rank, competencies, artifacts }: ProfileProps) {
const xpPercent = Math.min(100, (xp / 500) * 100);
export function ProgressOverview({
fullName,
xp,
mana,
rank,
competencies,
artifacts,
nextRankTitle,
xpProgress,
xpTarget
}: ProfileProps) {
const xpPercent = xpTarget > 0 ? Math.min(100, (xpProgress / xpTarget) * 100) : 100;
return (
<Card>
@ -64,7 +77,13 @@ export function ProgressOverview({ fullName, xp, mana, rank, competencies, artif
<div style={{ marginTop: '1rem' }}>
<strong>Опыт:</strong>
<ProgressBar value={xpPercent} />
<small style={{ color: 'var(--text-muted)' }}>{xp} / 500 XP до следующей цели</small>
{nextRankTitle ? (
<small style={{ color: 'var(--text-muted)' }}>
Осталось {Math.max(xpTarget - xpProgress, 0)} XP до ранга «{nextRankTitle}»
</small>
) : (
<small style={{ color: 'var(--text-muted)' }}>Вы достигли максимального ранга в демо-версии</small>
)}
</div>
<div style={{ marginTop: '1rem' }}>
<strong>Мана:</strong>

View File

@ -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<number | 'new'>('new');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [rarity, setRarity] = useState('rare');
const [imageUrl, setImageUrl] = useState('');
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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<HTMLFormElement>) => {
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 (
<div className="card">
<h3>Артефакты</h3>
<p style={{ color: 'var(--text-muted)' }}>
Подготовьте коллекционные награды за миссии: укажите название, редкость и изображение. Артефакты можно привязывать
в карточке миссии.
</p>
<form onSubmit={handleSubmit} className="admin-form">
<label>
Выбранный артефакт
<select value={selectedId === 'new' ? 'new' : String(selectedId)} onChange={(event) => handleSelect(event.target.value)}>
<option value="new">Новый артефакт</option>
{artifacts.map((artifact) => (
<option key={artifact.id} value={artifact.id}>
{artifact.name}
</option>
))}
</select>
</label>
<label>
Название
<input value={name} onChange={(event) => setName(event.target.value)} required />
</label>
<label>
Описание
<textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={3} required />
</label>
<label>
Редкость
<select value={rarity} onChange={(event) => setRarity(event.target.value)}>
{RARITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
Изображение (URL)
<input value={imageUrl} onChange={(event) => setImageUrl(event.target.value)} placeholder="https://..." />
</label>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<button type="submit" className="primary" disabled={loading}>
{selectedId === 'new' ? 'Создать артефакт' : 'Сохранить изменения'}
</button>
{selectedId !== 'new' && (
<button type="button" className="secondary" onClick={handleDelete} disabled={loading} style={{ cursor: loading ? 'not-allowed' : 'pointer' }}>
Удалить
</button>
)}
</div>
{status && <p style={{ color: 'var(--success)', marginTop: '0.5rem' }}>{status}</p>}
{error && <p style={{ color: 'var(--error)', marginTop: '0.5rem' }}>{error}</p>}
</form>
</div>
);
}

View File

@ -0,0 +1,82 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiFetch } from '../../lib/api';
type Submission = {
mission_id: number;
status: string;
comment: string | null;
proof_url: string | null;
awarded_xp: number;
awarded_mana: number;
updated_at: string;
id: number;
};
interface Props {
submission: Submission;
token: string;
}
export function AdminSubmissionCard({ submission, token }: Props) {
const router = useRouter();
const [loading, setLoading] = useState<'approve' | 'reject' | null>(null);
const [error, setError] = useState<string | null>(null);
const handleAction = async (action: 'approve' | 'reject') => {
setLoading(action);
setError(null);
try {
await apiFetch(`/api/admin/submissions/${submission.id}/${action}`, {
method: 'POST',
authToken: token
});
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось выполнить действие');
} finally {
setLoading(null);
}
};
return (
<div className="card" style={{ marginBottom: 0 }}>
<h4>Миссия #{submission.mission_id}</h4>
<p>Статус: {submission.status}</p>
{submission.comment && <p>Комментарий пилота: {submission.comment}</p>}
{submission.proof_url && (
<p>
Доказательство:{' '}
<a href={submission.proof_url} target="_blank" rel="noreferrer">
открыть
</a>
</p>
)}
<small style={{ color: 'var(--text-muted)' }}>
Обновлено: {new Date(submission.updated_at).toLocaleString('ru-RU')}
</small>
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.75rem' }}>
<button
className="primary"
onClick={() => handleAction('approve')}
disabled={loading === 'approve'}
>
{loading === 'approve' ? 'Одобряем...' : 'Одобрить'}
</button>
<button
className="secondary"
onClick={() => handleAction('reject')}
disabled={loading === 'reject'}
style={{ cursor: loading === 'reject' ? 'not-allowed' : 'pointer' }}
>
{loading === 'reject' ? 'Отклоняем...' : 'Отклонить'}
</button>
</div>
{error && <p style={{ color: 'var(--error)', marginTop: '0.5rem' }}>{error}</p>}
</div>
);
}

View File

@ -21,6 +21,15 @@ export async function apiFetch<T>(path: string, options: RequestOptions = {}): P
const text = await response.text();
throw new Error(`API error ${response.status}: ${text}`);
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return response.json() as Promise<T>;
}
const raw = await response.text();
return raw as unknown as T;
}

View File

@ -6,6 +6,8 @@
--accent-light: #a29bfe;
--text: #f5f6fa;
--text-muted: #b2bec3;
--success: #00b894;
--error: #ff7675;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
@ -17,6 +19,18 @@ body {
var(--bg);
color: var(--text);
min-height: 100vh;
position: relative;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(#ffffff33 1px, transparent 0);
background-size: 60px 60px;
opacity: 0.25;
pointer-events: none;
mix-blend-mode: screen;
}
a {
@ -41,6 +55,28 @@ main {
margin-bottom: 1.5rem;
}
.admin-form {
display: grid;
gap: 1rem;
}
.admin-form label {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.9rem;
}
.admin-form input,
.admin-form textarea,
.admin-form select {
border-radius: 12px;
border: 1px solid rgba(162, 155, 254, 0.3);
background: rgba(8, 11, 26, 0.6);
padding: 0.75rem;
color: var(--text);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
@ -71,3 +107,13 @@ main {
opacity: 0.4;
cursor: not-allowed;
}
.secondary {
padding: 0.75rem 1.5rem;
border-radius: 12px;
border: 1px solid rgba(162, 155, 254, 0.4);
background: transparent;
color: var(--text-muted);
font-weight: 500;
cursor: pointer;
}

View File

@ -18,6 +18,7 @@ from app.models.artifact import Artifact, ArtifactRarity
from app.models.branch import Branch, BranchMission
from app.models.mission import Mission, MissionCompetencyReward, MissionDifficulty
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
from app.models.onboarding import OnboardingSlide
from app.models.store import StoreItem
from app.models.user import Competency, CompetencyCategory, User, UserCompetency, UserRole
@ -250,6 +251,32 @@ def seed() -> None:
]
)
session.add_all(
[
OnboardingSlide(
order=1,
title="Добро пожаловать в орбитальный флот",
body="Узнайте, как миссии помогают связать карьерные шаги в единую траекторию.",
media_url="https://images.nasa.gov/details-PIA12235",
cta_text="Перейти к миссиям",
cta_link="/missions",
),
OnboardingSlide(
order=2,
title="Получайте опыт и ману",
body="Выполняя задания, вы накапливаете опыт для повышения ранга и ману для магазина.",
media_url="https://images.nasa.gov/details-PIA23499",
),
OnboardingSlide(
order=3,
title="Повышайте ранг до члена экипажа",
body="Закройте ключевые миссии ветки «Получение оффера» и прокачайте компетенции.",
cta_text="Открыть ветку",
cta_link="/missions",
),
]
)
session.commit()
DATA_SENTINEL.write_text("seeded")
print("Seed data created")