This commit is contained in:
danilgryaznev 2025-09-21 19:30:55 +02:00
parent ce3d1e70f5
commit e050bd46ef
80 changed files with 9223 additions and 81 deletions

18
.gitignore vendored Normal file
View File

@ -0,0 +1,18 @@
__pycache__/
*.py[cod]
*.sqlite3
*.db
.env
.env.*
.vscode/
.idea/
.DS_Store
backend/.venv/
backend/venv/
backend/.mypy_cache/
backend/.pytest_cache/
backend/data/
frontend/node_modules/
frontend/.next/
frontend/out/
frontend/.turbo/

18
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,18 @@
repos:
- repo: https://github.com/psf/black
rev: 24.4.2
hooks:
- id: black
additional_dependencies: ["click<8.1.0"]
files: ^backend/
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
files: ^backend/
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.7
hooks:
- id: ruff
args: ["--fix"]
files: ^backend/

View File

@ -0,0 +1,78 @@
# Alabuga Gamification Platform
Проект реализует прототип геймифицированного модуля для кадровой системы «Алабуги». Мы создаём космический лор, ранги, миссии, журнал событий и магазин артефактов. Репозиторий содержит backend на FastAPI (Python 3.13) и фронтенд на Next.js (TypeScript).
## Содержимое репозитория
- `backend/` — FastAPI, SQLAlchemy, Alembic, бизнес-логика геймификации.
- `frontend/` — Next.js с SSR, дизайн в космической стилистике.
- `scripts/` — служебные скрипты (сидирование демо-данных).
- `docs/` — документация, дополнительный лор.
- `docker-compose.yaml` — инфраструктура проекта.
## Быстрый старт в Docker
1. Установите Docker и Docker Compose.
2. Скопируйте `.env.example` в `.env` (файл появится после сборки) и при необходимости поменяйте настройки.
3. Запустите окружение:
```bash
docker compose up --build
```
4. После запуска будут доступны сервисы:
- API: http://localhost:8000 (документация Swagger — `/docs`).
- Фронтенд: http://localhost:3000.
## Локальная разработка backend
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements-dev.txt
# применяем миграции и создаём демо-данные
alembic upgrade head
python -m scripts.seed_data
# Запуск API
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
## Локальная разработка фронтенда
```bash
cd frontend
npm install
npm run dev
```
## Пользовательские учётные записи (сидированные)
| Роль | Email | Пароль |
| --- | --- | --- |
| Пилот | `candidate@alabuga.space` | `orbita123` |
| HR | `hr@alabuga.space` | `orbita123` |
## Тестирование
```bash
cd backend
pytest
```
## Основные сценарии, реализованные в бэкенде
- Авторизация по email/паролю с JWT.
- Получение профиля пилота со списком компетенций и артефактов.
- Получение миссий, отправка отчётов, модерация HR.
- Начисление опыта, маны и повышение ранга по трём условиям из ТЗ.
- Журнал событий, экспортируемый через API.
- Магазин артефактов с оформлением заказа.
- Админ-панель для HR: создание миссий, очередь модерации, список рангов.
## План развития
- Реализовать фронтенд-интерфейс всех экранов.
- Добавить экспорт CSV и агрегаты аналитики в API.
- Настроить CI/CD и автотесты во фреймворках фронтенда.
- Расширить документацию в `docs/` (описание лора, сценарии e2e).

23
backend/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
POETRY_VIRTUALENVS_CREATE=false
WORKDIR /app
RUN apt-get update && apt-get install -y build-essential libpq-dev && rm -rf /var/lib/apt/lists/*
COPY requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app
RUN adduser --disabled-password --gecos '' appuser && \
mkdir -p /data && chown -R appuser:appuser /data && chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

35
backend/alembic.ini Normal file
View File

@ -0,0 +1,35 @@
[alembic]
script_location = alembic
sqlalchemy.url = sqlite+pysqlite:////data/app.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers = console
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

53
backend/alembic/env.py Normal file
View File

@ -0,0 +1,53 @@
"""Alembic environment."""
from __future__ import annotations
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from app.core.config import settings
from app.models.base import Base
from app.db import base as models # noqa: F401 импортирует все модели
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,13 @@
"""Generic Alembic revision script."""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@ -0,0 +1,287 @@
"""initial schema"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20240609_0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
user_role = sa.Enum("pilot", "hr", "admin", name="userrole")
competency_category = sa.Enum(
"communication", "analytics", "teamwork", "leadership", "technology", "culture", name="competencycategory"
)
artifact_rarity = sa.Enum("common", "rare", "epic", "legendary", name="artifactrarity")
mission_difficulty = sa.Enum("easy", "medium", "hard", name="missiondifficulty")
submission_status = sa.Enum("pending", "approved", "rejected", name="submissionstatus")
order_status = sa.Enum("created", "approved", "rejected", "delivered", name="orderstatus")
journal_event = sa.Enum(
"rank_up", "mission_completed", "order_created", "order_approved", "skill_up", name="journaleventtype"
)
op.create_table(
"ranks",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("title", sa.String(length=120), nullable=False, unique=True),
sa.Column("description", sa.String(length=512), nullable=False),
sa.Column("required_xp", sa.Integer(), nullable=False, server_default="0"),
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
),
)
op.create_table(
"competencies",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=120), nullable=False, unique=True),
sa.Column("description", sa.String(length=512), nullable=False),
sa.Column("category", competency_category, nullable=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
),
)
op.create_table(
"artifacts",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=160), nullable=False, unique=True),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("rarity", artifact_rarity, nullable=False),
sa.Column("image_url", 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
),
)
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("email", sa.String(length=255), nullable=False, unique=True),
sa.Column("hashed_password", sa.String(length=255), nullable=False),
sa.Column("full_name", sa.String(length=255), nullable=False),
sa.Column("role", user_role, nullable=False, server_default="pilot"),
sa.Column("xp", sa.Integer(), nullable=False, server_default="0"),
sa.Column("mana", sa.Integer(), nullable=False, server_default="0"),
sa.Column("current_rank_id", sa.Integer(), sa.ForeignKey("ranks.id")),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.sql.expression.true()),
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
),
)
op.create_table(
"user_competencies",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("competency_id", sa.Integer(), sa.ForeignKey("competencies.id", ondelete="CASCADE"), nullable=False),
sa.Column("level", sa.Integer(), nullable=False, server_default="0"),
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", "competency_id", name="uq_user_competency"),
)
op.create_table(
"user_artifacts",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("artifact_id", sa.Integer(), sa.ForeignKey("artifacts.id", ondelete="CASCADE"), nullable=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", "artifact_id", name="uq_user_artifact"),
)
op.create_table(
"branches",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("title", sa.String(length=120), nullable=False, unique=True),
sa.Column("description", sa.String(length=512), nullable=False),
sa.Column("category", sa.String(length=64), nullable=False, server_default="quest"),
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
),
)
op.create_table(
"missions",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("title", sa.String(length=160), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("xp_reward", sa.Integer(), nullable=False, server_default="0"),
sa.Column("mana_reward", sa.Integer(), nullable=False, server_default="0"),
sa.Column("difficulty", mission_difficulty, nullable=False, server_default="medium"),
sa.Column("minimum_rank_id", sa.Integer(), sa.ForeignKey("ranks.id")),
sa.Column("artifact_id", sa.Integer(), sa.ForeignKey("artifacts.id")),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.sql.expression.true()),
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
),
)
op.create_table(
"branch_missions",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="CASCADE"), nullable=False),
sa.Column("mission_id", sa.Integer(), sa.ForeignKey("missions.id", ondelete="CASCADE"), nullable=False),
sa.Column("order", sa.Integer(), nullable=False, server_default="1"),
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
),
)
op.create_table(
"mission_competency_rewards",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("mission_id", sa.Integer(), sa.ForeignKey("missions.id", ondelete="CASCADE"), nullable=False),
sa.Column("competency_id", sa.Integer(), sa.ForeignKey("competencies.id", ondelete="CASCADE"), nullable=False),
sa.Column("level_delta", sa.Integer(), nullable=False, server_default="1"),
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("mission_id", "competency_id", name="uq_mission_competency"),
)
op.create_table(
"mission_prerequisites",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("mission_id", sa.Integer(), sa.ForeignKey("missions.id", ondelete="CASCADE"), nullable=False),
sa.Column(
"required_mission_id",
sa.Integer(),
sa.ForeignKey("missions.id", ondelete="CASCADE"),
nullable=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("mission_id", "required_mission_id", name="uq_mission_prerequisite"),
)
op.create_table(
"rank_mission_requirements",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("rank_id", sa.Integer(), sa.ForeignKey("ranks.id", ondelete="CASCADE"), nullable=False),
sa.Column("mission_id", sa.Integer(), sa.ForeignKey("missions.id", ondelete="CASCADE"), nullable=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("rank_id", "mission_id", name="uq_rank_mission"),
)
op.create_table(
"rank_competency_requirements",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("rank_id", sa.Integer(), sa.ForeignKey("ranks.id", ondelete="CASCADE"), nullable=False),
sa.Column(
"competency_id", sa.Integer(), sa.ForeignKey("competencies.id", ondelete="CASCADE"), nullable=False
),
sa.Column("required_level", sa.Integer(), nullable=False, server_default="0"),
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("rank_id", "competency_id", name="uq_rank_competency"),
)
op.create_table(
"mission_submissions",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("mission_id", sa.Integer(), sa.ForeignKey("missions.id", ondelete="CASCADE"), nullable=False),
sa.Column("status", submission_status, nullable=False, server_default="pending"),
sa.Column("comment", sa.Text()),
sa.Column("proof_url", sa.String(length=512)),
sa.Column("awarded_xp", sa.Integer(), nullable=False, server_default="0"),
sa.Column("awarded_mana", sa.Integer(), nullable=False, server_default="0"),
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", "mission_id", name="uq_user_mission_submission"),
)
op.create_table(
"store_items",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("cost_mana", sa.Integer(), nullable=False),
sa.Column("stock", sa.Integer(), nullable=False, server_default="0"),
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
),
)
op.create_table(
"orders",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("item_id", sa.Integer(), sa.ForeignKey("store_items.id", ondelete="CASCADE"), nullable=False),
sa.Column("status", order_status, nullable=False, server_default="created"),
sa.Column("comment", sa.Text()),
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
),
)
op.create_table(
"journal_entries",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("event_type", journal_event, nullable=False),
sa.Column("title", sa.String(length=160), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("payload", sa.JSON()),
sa.Column("xp_delta", sa.Integer(), nullable=False, server_default="0"),
sa.Column("mana_delta", sa.Integer(), nullable=False, server_default="0"),
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
),
)
def downgrade() -> None:
op.drop_table("journal_entries")
op.drop_table("orders")
op.drop_table("store_items")
op.drop_table("mission_submissions")
op.drop_table("rank_competency_requirements")
op.drop_table("rank_mission_requirements")
op.drop_table("mission_prerequisites")
op.drop_table("mission_competency_rewards")
op.drop_table("branch_missions")
op.drop_table("missions")
op.drop_table("branches")
op.drop_table("user_artifacts")
op.drop_table("user_competencies")
op.drop_table("users")
op.drop_table("artifacts")
op.drop_table("competencies")
op.drop_table("ranks")
sa.Enum(name="journaleventtype").drop(op.get_bind(), checkfirst=False)
sa.Enum(name="orderstatus").drop(op.get_bind(), checkfirst=False)
sa.Enum(name="submissionstatus").drop(op.get_bind(), checkfirst=False)
sa.Enum(name="missiondifficulty").drop(op.get_bind(), checkfirst=False)
sa.Enum(name="artifactrarity").drop(op.get_bind(), checkfirst=False)
sa.Enum(name="competencycategory").drop(op.get_bind(), checkfirst=False)
sa.Enum(name="userrole").drop(op.get_bind(), checkfirst=False)

0
backend/app/__init__.py Normal file
View File

View File

49
backend/app/api/deps.py Normal file
View File

@ -0,0 +1,49 @@
"""Вспомогательные зависимости FastAPI."""
from __future__ import annotations
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from app.core.security import decode_access_token
from app.db.session import get_db
from app.models.user import User, UserRole
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
"""Находим пользователя по токену."""
try:
payload = decode_access_token(token)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
email: str | None = payload.get("sub")
if not email:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неизвестный токен")
user = db.query(User).filter(User.email == email).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Пользователь не найден")
return user
async def require_hr(current_user: User = Depends(get_current_user)) -> User:
"""Проверяем, что пользователь HR или администратор."""
if current_user.role not in {UserRole.HR, UserRole.ADMIN}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Недостаточно прав")
return current_user
async def require_admin(current_user: User = Depends(get_current_user)) -> User:
"""Проверяем права администратора."""
if current_user.role != UserRole.ADMIN:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Только для администраторов")
return current_user

View File

@ -0,0 +1,12 @@
"""Экспортируем роутеры для подключения в приложении."""
from . import admin, auth, journal, missions, store, users # noqa: F401
__all__ = [
"admin",
"auth",
"journal",
"missions",
"store",
"users",
]

View File

@ -0,0 +1,167 @@
"""HR-панель и административные действия."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import require_hr
from app.db.session import get_db
from app.models.branch import BranchMission
from app.models.mission import Mission, MissionCompetencyReward, MissionPrerequisite, MissionSubmission, SubmissionStatus
from app.models.rank import Rank
from app.schemas.mission import MissionBase, MissionCreate, MissionDetail, MissionSubmissionRead
from app.schemas.rank import RankBase
from app.services.mission import approve_submission, reject_submission
router = APIRouter(prefix="/api/admin", tags=["admin"])
@router.get("/missions", response_model=list[MissionBase], summary="Миссии (HR)")
def admin_missions(*, db: Session = Depends(get_db), current_user=Depends(require_hr)) -> list[MissionBase]:
"""Список всех миссий для HR."""
missions = db.query(Mission).order_by(Mission.title).all()
return [MissionBase.model_validate(mission) for mission in missions]
@router.post("/missions", response_model=MissionDetail, summary="Создать миссию")
def create_mission_endpoint(
mission_in: MissionCreate,
*,
db: Session = Depends(get_db),
current_user=Depends(require_hr),
) -> MissionDetail:
"""Создаём новую миссию."""
mission = Mission(
title=mission_in.title,
description=mission_in.description,
xp_reward=mission_in.xp_reward,
mana_reward=mission_in.mana_reward,
difficulty=mission_in.difficulty,
minimum_rank_id=mission_in.minimum_rank_id,
artifact_id=mission_in.artifact_id,
)
db.add(mission)
db.flush()
for reward in mission_in.competency_rewards:
db.add(
MissionCompetencyReward(
mission_id=mission.id,
competency_id=reward.competency_id,
level_delta=reward.level_delta,
)
)
for prerequisite_id in mission_in.prerequisite_ids:
db.add(
MissionPrerequisite(mission_id=mission.id, required_mission_id=prerequisite_id)
)
if mission_in.branch_id:
db.add(
BranchMission(
branch_id=mission_in.branch_id,
mission_id=mission.id,
order=mission_in.branch_order,
)
)
db.commit()
db.refresh(mission)
return MissionDetail(
id=mission.id,
title=mission.title,
description=mission.description,
xp_reward=mission.xp_reward,
mana_reward=mission.mana_reward,
difficulty=mission.difficulty,
is_active=mission.is_active,
minimum_rank_id=mission.minimum_rank_id,
artifact_id=mission.artifact_id,
prerequisites=[link.required_mission_id for link in mission.prerequisites],
competency_rewards=[
{
"competency_id": reward.competency_id,
"competency_name": reward.competency.name,
"level_delta": reward.level_delta,
}
for reward in mission.competency_rewards
],
created_at=mission.created_at,
updated_at=mission.updated_at,
)
@router.get("/ranks", response_model=list[RankBase], summary="Список рангов")
def admin_ranks(*, db: Session = Depends(get_db), current_user=Depends(require_hr)) -> list[RankBase]:
"""Перечень рангов."""
ranks = db.query(Rank).order_by(Rank.required_xp).all()
return [RankBase.model_validate(rank) for rank in ranks]
@router.get(
"/submissions",
response_model=list[MissionSubmissionRead],
summary="Очередь модерации",
)
def moderation_queue(
status_filter: SubmissionStatus = SubmissionStatus.PENDING,
*,
db: Session = Depends(get_db),
current_user=Depends(require_hr),
) -> list[MissionSubmissionRead]:
"""Возвращаем отправки со статусом по умолчанию pending."""
submissions = (
db.query(MissionSubmission)
.filter(MissionSubmission.status == status_filter)
.order_by(MissionSubmission.created_at)
.all()
)
return [MissionSubmissionRead.model_validate(submission) for submission in submissions]
@router.post(
"/submissions/{submission_id}/approve",
response_model=MissionSubmissionRead,
summary="Одобрить миссию",
)
def approve_submission_endpoint(
submission_id: int,
*,
db: Session = Depends(get_db),
current_user=Depends(require_hr),
) -> MissionSubmissionRead:
"""HR подтверждает выполнение."""
submission = db.query(MissionSubmission).filter(MissionSubmission.id == submission_id).first()
if not submission:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Отправка не найдена")
submission = approve_submission(db, submission)
return MissionSubmissionRead.model_validate(submission)
@router.post(
"/submissions/{submission_id}/reject",
response_model=MissionSubmissionRead,
summary="Отклонить миссию",
)
def reject_submission_endpoint(
submission_id: int,
comment: str | None = None,
*,
db: Session = Depends(get_db),
current_user=Depends(require_hr),
) -> MissionSubmissionRead:
"""HR отклоняет выполнение."""
submission = db.query(MissionSubmission).filter(MissionSubmission.id == submission_id).first()
if not submission:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Отправка не найдена")
submission = reject_submission(db, submission, comment)
return MissionSubmissionRead.model_validate(submission)

View File

@ -0,0 +1,37 @@
"""Маршруты аутентификации."""
from __future__ import annotations
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.core.config import settings
from app.core.security import create_access_token, verify_password
from app.db.session import get_db
from app.models.user import User
from app.schemas.auth import Token
from app.schemas.user import UserLogin, UserRead
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=Token, summary="Авторизация по email и паролю")
def login(user_in: UserLogin, db: Session = Depends(get_db)) -> Token:
"""Проверяем логин и выдаём JWT."""
user = db.query(User).filter(User.email == user_in.email).first()
if not user or not verify_password(user_in.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверные данные")
token = create_access_token(user.email, timedelta(minutes=settings.access_token_expire_minutes))
return Token(access_token=token)
@router.get("/me", response_model=UserRead, summary="Текущий пользователь")
def read_current_user(current_user: User = Depends(get_current_user)) -> UserRead:
"""Простая проверка токена."""
return current_user

View File

@ -0,0 +1,29 @@
"""Чтение журнала событий."""
from __future__ import annotations
from fastapi import APIRouter, Depends
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
router = APIRouter(prefix="/api/journal", tags=["journal"])
@router.get("/", response_model=list[JournalEntryRead], summary="Журнал пользователя")
def list_journal(
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
) -> list[JournalEntryRead]:
"""Возвращаем записи, отсортированные по времени."""
entries = (
db.query(JournalEntry)
.filter(JournalEntry.user_id == current_user.id)
.order_by(JournalEntry.created_at.desc())
.all()
)
return [JournalEntryRead.model_validate(entry) for entry in entries]

View File

@ -0,0 +1,122 @@
"""Маршруты для работы с миссиями."""
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.mission import Mission, MissionSubmission
from app.models.user import User
from app.schemas.mission import (
MissionBase,
MissionDetail,
MissionSubmissionCreate,
MissionSubmissionRead,
)
from app.services.mission import submit_mission
router = APIRouter(prefix="/api/missions", tags=["missions"])
@router.get("/", response_model=list[MissionBase], summary="Список миссий")
def list_missions(
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
) -> list[MissionBase]:
"""Возвращаем доступные миссии."""
query = db.query(Mission).filter(Mission.is_active.is_(True))
if current_user.current_rank_id:
query = query.filter(
(Mission.minimum_rank_id.is_(None)) | (Mission.minimum_rank_id <= current_user.current_rank_id)
)
missions = query.all()
return [MissionBase.model_validate(mission) for mission in missions]
@router.get("/{mission_id}", response_model=MissionDetail, summary="Карточка миссии")
def get_mission(
mission_id: int,
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> MissionDetail:
"""Возвращаем подробную информацию о миссии."""
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="Миссия не найдена")
prerequisites = [link.required_mission_id for link in mission.prerequisites]
rewards = [
{
"competency_id": reward.competency_id,
"competency_name": reward.competency.name,
"level_delta": reward.level_delta,
}
for reward in mission.competency_rewards
]
data = MissionDetail(
id=mission.id,
title=mission.title,
description=mission.description,
xp_reward=mission.xp_reward,
mana_reward=mission.mana_reward,
difficulty=mission.difficulty,
is_active=mission.is_active,
minimum_rank_id=mission.minimum_rank_id,
artifact_id=mission.artifact_id,
prerequisites=prerequisites,
competency_rewards=rewards,
created_at=mission.created_at,
updated_at=mission.updated_at,
)
return data
@router.post("/{mission_id}/submit", response_model=MissionSubmissionRead, summary="Отправляем отчёт")
def submit(
mission_id: int,
submission_in: MissionSubmissionCreate,
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> MissionSubmissionRead:
"""Пилот отправляет доказательство выполнения миссии."""
mission = db.query(Mission).filter(Mission.id == mission_id).first()
if not mission:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена")
submission = submit_mission(
db=db,
user=current_user,
mission=mission,
comment=submission_in.comment,
proof_url=submission_in.proof_url,
)
return MissionSubmissionRead.model_validate(submission)
@router.get(
"/{mission_id}/submission",
response_model=MissionSubmissionRead | None,
summary="Получаем текущую отправку",
)
def get_submission(
mission_id: int,
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> MissionSubmissionRead | None:
"""Возвращаем статус отправленной миссии."""
submission = (
db.query(MissionSubmission)
.filter(MissionSubmission.user_id == current_user.id, MissionSubmission.mission_id == mission_id)
.first()
)
if not submission:
return None
return MissionSubmissionRead.model_validate(submission)

View File

@ -0,0 +1,76 @@
"""Магазин и заказы."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, require_hr
from app.db.session import get_db
from app.models.store import Order, OrderStatus, StoreItem
from app.models.user import User
from app.schemas.store import OrderCreate, OrderRead, StoreItemRead
from app.services.store import create_order, update_order_status
router = APIRouter(prefix="/api/store", tags=["store"])
@router.get("/items", response_model=list[StoreItemRead], summary="Список товаров")
def list_items(*, db: Session = Depends(get_db)) -> list[StoreItemRead]:
"""Товары магазина."""
items = db.query(StoreItem).order_by(StoreItem.name).all()
return [StoreItemRead.model_validate(item) for item in items]
@router.post("/orders", response_model=OrderRead, summary="Создать заказ")
def create_order_endpoint(
order_in: OrderCreate,
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> OrderRead:
"""Оформляем заказ пользователя."""
item = db.query(StoreItem).filter(StoreItem.id == order_in.item_id).first()
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Товар не найден")
order = create_order(db, current_user, item, order_in.comment)
db.refresh(order)
return OrderRead.model_validate(order)
@router.get("/orders", response_model=list[OrderRead], summary="Заказы пользователя")
def list_orders(
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
) -> list[OrderRead]:
"""Возвращаем заказы пилота."""
orders = (
db.query(Order)
.filter(Order.user_id == current_user.id)
.order_by(Order.created_at.desc())
.all()
)
return [OrderRead.model_validate(order) for order in orders]
@router.patch(
"/orders/{order_id}",
response_model=OrderRead,
summary="Изменить статус заказа",
)
def patch_order(
order_id: int,
status_name: OrderStatus,
*,
db: Session = Depends(get_db),
current_user: User = Depends(require_hr),
) -> OrderRead:
"""HR может подтвердить заказ."""
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Заказ не найден")
order = update_order_status(db, order, status_name)
return OrderRead.model_validate(order)

View File

@ -0,0 +1,25 @@
"""Маршруты работы с профилем."""
from __future__ import annotations
from fastapi import APIRouter, Depends
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.user import UserProfile
router = APIRouter(prefix="/api", tags=["profile"])
@router.get("/me", response_model=UserProfile, summary="Профиль пилота")
def get_profile(
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
) -> UserProfile:
"""Возвращаем профиль и связанные сущности."""
db.refresh(current_user)
_ = current_user.competencies
_ = current_user.artifacts
return UserProfile.model_validate(current_user)

View File

View File

@ -0,0 +1,40 @@
"""Конфигурация приложения и загрузка окружения."""
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Глобальные настройки сервиса."""
model_config = SettingsConfigDict(env_file=".env", env_prefix="ALABUGA_", extra="ignore")
project_name: str = "Alabuga Gamification API"
environment: str = "local"
secret_key: str = "super-secret-key-change-me"
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 12
backend_cors_origins: list[str] = ["http://localhost:3000", "http://frontend:3000"]
sqlite_path: Path = Path("/data/app.db")
@property
def database_url(self) -> str:
"""Путь к базе данных SQLite."""
return f"sqlite:///{self.sqlite_path}"
@lru_cache()
def get_settings() -> Settings:
"""Кэшируем создание настроек, чтобы не читать файл каждый раз."""
settings = Settings()
settings.sqlite_path.parent.mkdir(parents=True, exist_ok=True)
return settings
settings = get_settings()

View File

@ -0,0 +1,42 @@
"""Утилиты безопасности: хеширование паролей и JWT."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Проверяем соответствие пароля и хеша."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Создаём безопасный хеш."""
return pwd_context.hash(password)
def create_access_token(subject: str, expires_delta: timedelta | None = None) -> str:
"""Формируем JWT для аутентификации."""
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.access_token_expire_minutes))
to_encode: dict[str, Any] = {"sub": subject, "exp": expire}
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> dict[str, Any]:
"""Расшифровываем и валидируем JWT."""
try:
return jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm])
except JWTError as exc: # pragma: no cover - FastAPI обработает ошибку
raise ValueError("Недействительный токен") from exc

View File

3
backend/app/db/base.py Normal file
View File

@ -0,0 +1,3 @@
"""Импорт моделей для Alembic."""
from app.models import * # noqa: F401,F403

21
backend/app/db/session.py Normal file
View File

@ -0,0 +1,21 @@
"""Настройка подключения к базе данных."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# echo=True полезно при отладке, но оставляем False по умолчанию.
engine = create_engine(settings.database_url, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
"""Зависимость FastAPI для получения сессии БД."""
db = SessionLocal()
try:
yield db
finally:
db.close()

43
backend/app/main.py Normal file
View File

@ -0,0 +1,43 @@
"""Точка входа FastAPI."""
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.core.config import settings
from app.db.session import engine
from app.models.base import Base
app = FastAPI(title=settings.project_name)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.backend_cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
def on_startup() -> None:
"""Создаём таблицы, если миграции ещё не применены."""
Base.metadata.create_all(bind=engine)
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(missions.router)
app.include_router(journal.router)
app.include_router(store.router)
app.include_router(admin.router)
@app.get("/", summary="Проверка работоспособности")
def healthcheck() -> dict[str, str]:
"""Простой ответ для Docker healthcheck."""
return {"status": "ok", "environment": settings.environment}

View File

@ -0,0 +1,29 @@
"""Инициализация моделей для удобных импортов."""
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 .rank import Rank, RankCompetencyRequirement, RankMissionRequirement # noqa: F401
from .store import Order, StoreItem # noqa: F401
from .user import Competency, User, UserArtifact, UserCompetency # noqa: F401
__all__ = [
"Artifact",
"Branch",
"BranchMission",
"JournalEntry",
"Mission",
"MissionCompetencyReward",
"MissionPrerequisite",
"MissionSubmission",
"Rank",
"RankCompetencyRequirement",
"RankMissionRequirement",
"Order",
"StoreItem",
"Competency",
"User",
"UserArtifact",
"UserCompetency",
]

View File

@ -0,0 +1,35 @@
"""Артефакты и магазинные предметы."""
from __future__ import annotations
from enum import Enum
from typing import List
from sqlalchemy import Enum as SQLEnum, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class ArtifactRarity(str, Enum):
"""Редкость артефакта."""
COMMON = "common"
RARE = "rare"
EPIC = "epic"
LEGENDARY = "legendary"
class Artifact(Base, TimestampMixin):
"""Артефакт, который можно получить за миссию."""
__tablename__ = "artifacts"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(160), unique=True, nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
rarity: Mapped[ArtifactRarity] = mapped_column(SQLEnum(ArtifactRarity), nullable=False)
image_url: Mapped[str] = mapped_column(String(512), nullable=True)
missions: Mapped[List["Mission"]] = relationship("Mission", back_populates="artifact")
pilots: Mapped[List["UserArtifact"]] = relationship("UserArtifact", back_populates="artifact")

View File

@ -0,0 +1,21 @@
"""Общий базовый класс для моделей."""
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
"""Базовый класс SQLAlchemy."""
class TimestampMixin:
"""Добавляем временные метки для всех таблиц."""
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)

View File

@ -0,0 +1,39 @@
"""Ветки миссий и их порядок."""
from __future__ import annotations
from typing import List
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class Branch(Base, TimestampMixin):
"""Объединение миссий в сюжетную ветку."""
__tablename__ = "branches"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
description: Mapped[str] = mapped_column(String(512), nullable=False)
category: Mapped[str] = mapped_column(String(64), nullable=False, default="quest")
missions: Mapped[List["BranchMission"]] = relationship(
"BranchMission", back_populates="branch", cascade="all, delete-orphan"
)
class BranchMission(Base, TimestampMixin):
"""Связка ветки и миссии с порядком."""
__tablename__ = "branch_missions"
id: Mapped[int] = mapped_column(primary_key=True)
branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False)
mission_id: Mapped[int] = mapped_column(ForeignKey("missions.id"), nullable=False)
order: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
branch = relationship("Branch", back_populates="missions")
mission = relationship("Mission", back_populates="branches")

View File

@ -0,0 +1,37 @@
"""Бортовой журнал событий."""
from __future__ import annotations
from enum import Enum
from sqlalchemy import Enum as SQLEnum, ForeignKey, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class JournalEventType(str, Enum):
"""Типы событий для журнала."""
RANK_UP = "rank_up"
MISSION_COMPLETED = "mission_completed"
ORDER_CREATED = "order_created"
ORDER_APPROVED = "order_approved"
SKILL_UP = "skill_up"
class JournalEntry(Base, TimestampMixin):
"""Запись о важном событии пользователя."""
__tablename__ = "journal_entries"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
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)
xp_delta: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
mana_delta: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
user = relationship("User", back_populates="journal_entries")

View File

@ -0,0 +1,124 @@
"""Миссии, требования и отправки отчётов."""
from __future__ import annotations
from enum import Enum
from typing import List, Optional
from sqlalchemy import Boolean, Enum as SQLEnum, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class MissionDifficulty(str, Enum):
"""Условные уровни сложности."""
EASY = "easy"
MEDIUM = "medium"
HARD = "hard"
class Mission(Base, TimestampMixin):
"""Игровая миссия."""
__tablename__ = "missions"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(160), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
xp_reward: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
mana_reward: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
difficulty: Mapped[MissionDifficulty] = mapped_column(
SQLEnum(MissionDifficulty), default=MissionDifficulty.MEDIUM, nullable=False
)
minimum_rank_id: Mapped[Optional[int]] = mapped_column(ForeignKey("ranks.id"))
artifact_id: Mapped[Optional[int]] = mapped_column(ForeignKey("artifacts.id"))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
minimum_rank = relationship("Rank")
artifact = relationship("Artifact", back_populates="missions")
branches: Mapped[List["BranchMission"]] = relationship("BranchMission", back_populates="mission")
competency_rewards: Mapped[List["MissionCompetencyReward"]] = relationship(
"MissionCompetencyReward", back_populates="mission", cascade="all, delete-orphan"
)
prerequisites: Mapped[List["MissionPrerequisite"]] = relationship(
"MissionPrerequisite", foreign_keys="MissionPrerequisite.mission_id", back_populates="mission"
)
required_for: Mapped[List["MissionPrerequisite"]] = relationship(
"MissionPrerequisite",
foreign_keys="MissionPrerequisite.required_mission_id",
back_populates="required_mission",
)
submissions: Mapped[List["MissionSubmission"]] = relationship(
"MissionSubmission", back_populates="mission", cascade="all, delete-orphan"
)
rank_requirements: Mapped[List["RankMissionRequirement"]] = relationship(
"RankMissionRequirement", back_populates="mission"
)
class MissionCompetencyReward(Base, TimestampMixin):
"""Какие компетенции повышаются за миссию."""
__tablename__ = "mission_competency_rewards"
__table_args__ = (UniqueConstraint("mission_id", "competency_id", name="uq_mission_competency"),)
id: Mapped[int] = mapped_column(primary_key=True)
mission_id: Mapped[int] = mapped_column(ForeignKey("missions.id"), nullable=False)
competency_id: Mapped[int] = mapped_column(ForeignKey("competencies.id"), nullable=False)
level_delta: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
mission = relationship("Mission", back_populates="competency_rewards")
competency = relationship("Competency")
class MissionPrerequisite(Base, TimestampMixin):
"""Связка миссий с зависимостями."""
__tablename__ = "mission_prerequisites"
__table_args__ = (
UniqueConstraint("mission_id", "required_mission_id", name="uq_mission_prerequisite"),
)
id: Mapped[int] = mapped_column(primary_key=True)
mission_id: Mapped[int] = mapped_column(ForeignKey("missions.id"), nullable=False)
required_mission_id: Mapped[int] = mapped_column(ForeignKey("missions.id"), nullable=False)
mission = relationship(
"Mission", foreign_keys=[mission_id], back_populates="prerequisites"
)
required_mission = relationship(
"Mission", foreign_keys=[required_mission_id], back_populates="required_for"
)
class SubmissionStatus(str, Enum):
"""Статусы проверки отправленных заданий."""
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class MissionSubmission(Base, TimestampMixin):
"""Отправка выполненной миссии пользователем."""
__tablename__ = "mission_submissions"
__table_args__ = (
UniqueConstraint("user_id", "mission_id", name="uq_user_mission_submission"),
)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
mission_id: Mapped[int] = mapped_column(ForeignKey("missions.id"), nullable=False)
status: Mapped[SubmissionStatus] = mapped_column(
SQLEnum(SubmissionStatus), default=SubmissionStatus.PENDING, nullable=False
)
comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
proof_url: Mapped[Optional[str]] = mapped_column(String(512))
awarded_xp: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
awarded_mana: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
mission = relationship("Mission", back_populates="submissions")
user = relationship("User", back_populates="submissions")

View File

@ -0,0 +1,58 @@
"""Модели рангов и условий повышения."""
from __future__ import annotations
from typing import List
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class Rank(Base, TimestampMixin):
"""Игровой ранг."""
__tablename__ = "ranks"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
description: Mapped[str] = mapped_column(String(512), nullable=False)
required_xp: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
pilots: Mapped[List["User"]] = relationship("User", back_populates="current_rank")
mission_requirements: Mapped[List["RankMissionRequirement"]] = relationship(
"RankMissionRequirement", back_populates="rank", cascade="all, delete-orphan"
)
competency_requirements: Mapped[List["RankCompetencyRequirement"]] = relationship(
"RankCompetencyRequirement", back_populates="rank", cascade="all, delete-orphan"
)
class RankMissionRequirement(Base, TimestampMixin):
"""Связка ранга и обязательных миссий."""
__tablename__ = "rank_mission_requirements"
__table_args__ = (UniqueConstraint("rank_id", "mission_id", name="uq_rank_mission"),)
id: Mapped[int] = mapped_column(primary_key=True)
rank_id: Mapped[int] = mapped_column(ForeignKey("ranks.id"), nullable=False)
mission_id: Mapped[int] = mapped_column(ForeignKey("missions.id"), nullable=False)
rank = relationship("Rank", back_populates="mission_requirements")
mission = relationship("Mission", back_populates="rank_requirements")
class RankCompetencyRequirement(Base, TimestampMixin):
"""Требования к прокачке компетенций."""
__tablename__ = "rank_competency_requirements"
__table_args__ = (UniqueConstraint("rank_id", "competency_id", name="uq_rank_competency"),)
id: Mapped[int] = mapped_column(primary_key=True)
rank_id: Mapped[int] = mapped_column(ForeignKey("ranks.id"), nullable=False)
competency_id: Mapped[int] = mapped_column(ForeignKey("competencies.id"), nullable=False)
required_level: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
rank = relationship("Rank", back_populates="competency_requirements")
competency = relationship("Competency")

View File

@ -0,0 +1,51 @@
"""Магазин и заказы."""
from __future__ import annotations
from enum import Enum
from typing import List
from sqlalchemy import Enum as SQLEnum, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class OrderStatus(str, Enum):
"""Статусы заказа в магазине."""
CREATED = "created"
APPROVED = "approved"
REJECTED = "rejected"
DELIVERED = "delivered"
class StoreItem(Base, TimestampMixin):
"""Товар, который можно приобрести за ману."""
__tablename__ = "store_items"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(160), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
cost_mana: Mapped[int] = mapped_column(Integer, nullable=False)
stock: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
orders: Mapped[List["Order"]] = relationship("Order", back_populates="item")
class Order(Base, TimestampMixin):
"""Заказ пользователя."""
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
item_id: Mapped[int] = mapped_column(ForeignKey("store_items.id"), nullable=False)
status: Mapped[OrderStatus] = mapped_column(
SQLEnum(OrderStatus), default=OrderStatus.CREATED, nullable=False
)
comment: Mapped[str | None] = mapped_column(Text)
user = relationship("User", back_populates="orders")
item = relationship("StoreItem", back_populates="orders")

105
backend/app/models/user.py Normal file
View File

@ -0,0 +1,105 @@
"""Модели пользователей и связанных сущностей."""
from __future__ import annotations
from enum import Enum
from typing import List, Optional
from sqlalchemy import Boolean, Enum as SQLEnum, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class UserRole(str, Enum):
"""Типы ролей в системе."""
PILOT = "pilot"
HR = "hr"
ADMIN = "admin"
class User(Base, TimestampMixin):
"""Пользователь платформы."""
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(SQLEnum(UserRole), default=UserRole.PILOT, index=True)
xp: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
mana: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
current_rank_id: Mapped[Optional[int]] = mapped_column(ForeignKey("ranks.id"), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
current_rank = relationship("Rank", back_populates="pilots")
competencies: Mapped[List["UserCompetency"]] = relationship(
"UserCompetency", back_populates="user", cascade="all, delete-orphan"
)
submissions: Mapped[List["MissionSubmission"]] = relationship(
"MissionSubmission", back_populates="user", cascade="all, delete-orphan"
)
orders: Mapped[List["Order"]] = relationship("Order", back_populates="user")
journal_entries: Mapped[List["JournalEntry"]] = relationship(
"JournalEntry", back_populates="user", cascade="all, delete-orphan"
)
artifacts: Mapped[List["UserArtifact"]] = relationship(
"UserArtifact", back_populates="user", cascade="all, delete-orphan"
)
class CompetencyCategory(str, Enum):
"""Категории компетенций."""
COMMUNICATION = "communication"
ANALYTICS = "analytics"
TEAMWORK = "teamwork"
LEADERSHIP = "leadership"
TECH = "technology"
CULTURE = "culture"
class Competency(Base, TimestampMixin):
"""Компетенция с уровнем прокачки."""
__tablename__ = "competencies"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
description: Mapped[str] = mapped_column(String(512), nullable=False)
category: Mapped[CompetencyCategory] = mapped_column(
SQLEnum(CompetencyCategory), nullable=False, index=True
)
users: Mapped[List["UserCompetency"]] = relationship("UserCompetency", back_populates="competency")
class UserCompetency(Base, TimestampMixin):
"""Уровень компетенции для конкретного пользователя."""
__tablename__ = "user_competencies"
__table_args__ = (UniqueConstraint("user_id", "competency_id", name="uq_user_competency"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
competency_id: Mapped[int] = mapped_column(ForeignKey("competencies.id"), nullable=False)
level: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
user = relationship("User", back_populates="competencies")
competency = relationship("Competency", back_populates="users")
class UserArtifact(Base, TimestampMixin):
"""Коллекция артефактов пользователя."""
__tablename__ = "user_artifacts"
__table_args__ = (UniqueConstraint("user_id", "artifact_id", name="uq_user_artifact"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
artifact_id: Mapped[int] = mapped_column(ForeignKey("artifacts.id"), nullable=False)
user = relationship("User", back_populates="artifacts")
artifact = relationship("Artifact", back_populates="pilots")

View File

View File

@ -0,0 +1,10 @@
"""Схемы авторизации."""
from pydantic import BaseModel
class Token(BaseModel):
"""Ответ с токеном."""
access_token: str
token_type: str = "bearer"

View File

@ -0,0 +1,26 @@
"""Схемы веток миссий."""
from __future__ import annotations
from pydantic import BaseModel
class BranchMissionRead(BaseModel):
"""Миссия внутри ветки."""
mission_id: int
mission_title: str
order: int
class BranchRead(BaseModel):
"""Описание ветки."""
id: int
title: str
description: str
category: str
missions: list[BranchMissionRead]
class Config:
from_attributes = True

View File

@ -0,0 +1,26 @@
"""Схемы бортового журнала."""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
from app.models.journal import JournalEventType
class JournalEntryRead(BaseModel):
"""Запись журнала."""
id: int
event_type: JournalEventType
title: str
description: str
payload: Optional[dict]
xp_delta: int
mana_delta: int
created_at: datetime
class Config:
from_attributes = True

View File

@ -0,0 +1,90 @@
"""Схемы миссий."""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
from app.models.mission import MissionDifficulty, SubmissionStatus
class MissionBase(BaseModel):
"""Минимальная информация о миссии."""
id: int
title: str
description: str
xp_reward: int
mana_reward: int
difficulty: MissionDifficulty
is_active: bool
class Config:
from_attributes = True
class MissionCompetencyRewardRead(BaseModel):
"""Прокачка компетенции за миссию."""
competency_id: int
competency_name: str
level_delta: int
class MissionDetail(MissionBase):
"""Полная карточка миссии."""
minimum_rank_id: Optional[int]
artifact_id: Optional[int]
prerequisites: list[int]
competency_rewards: list[MissionCompetencyRewardRead]
created_at: datetime
updated_at: datetime
class MissionCreateReward(BaseModel):
"""Описание награды компетенции при создании миссии."""
competency_id: int
level_delta: int = 1
class MissionCreate(BaseModel):
"""Схема создания миссии."""
title: str
description: str
xp_reward: int
mana_reward: int
difficulty: MissionDifficulty = MissionDifficulty.MEDIUM
minimum_rank_id: Optional[int] = None
artifact_id: Optional[int] = None
prerequisite_ids: list[int] = []
competency_rewards: list[MissionCreateReward] = []
branch_id: Optional[int] = None
branch_order: int = 1
class MissionSubmissionCreate(BaseModel):
"""Отправка отчёта по миссии."""
comment: Optional[str] = None
proof_url: Optional[str] = None
class MissionSubmissionRead(BaseModel):
"""Получение статуса отправки."""
mission_id: int
status: SubmissionStatus
comment: Optional[str]
proof_url: Optional[str]
awarded_xp: int
awarded_mana: int
updated_at: datetime
class Config:
from_attributes = True

View File

@ -0,0 +1,43 @@
"""Схемы рангов."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
class RankBase(BaseModel):
"""Базовая информация о ранге."""
id: int
title: str
description: str
required_xp: int
class Config:
from_attributes = True
class RankRequirementMission(BaseModel):
"""Обязательная миссия."""
mission_id: int
mission_title: str
class RankRequirementCompetency(BaseModel):
"""Требование к компетенции."""
competency_id: int
competency_name: str
required_level: int
class RankDetailed(RankBase):
"""Полный ранг со списком условий."""
mission_requirements: list[RankRequirementMission]
competency_requirements: list[RankRequirementCompetency]
created_at: datetime
updated_at: datetime

View File

@ -0,0 +1,44 @@
"""Схемы магазина."""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
from app.models.store import OrderStatus
class StoreItemRead(BaseModel):
"""Товар магазина."""
id: int
name: str
description: str
cost_mana: int
stock: int
class Config:
from_attributes = True
class OrderRead(BaseModel):
"""Информация о заказе."""
id: int
item: StoreItemRead
status: OrderStatus
comment: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class OrderCreate(BaseModel):
"""Запрос на создание заказа."""
item_id: int
comment: Optional[str] = None

View File

@ -0,0 +1,86 @@
"""Pydantic-схемы для пользователей и компетенций."""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr
from app.models.user import CompetencyCategory, UserRole
class CompetencyBase(BaseModel):
"""Базовое описание компетенции."""
id: int
name: str
description: str
category: CompetencyCategory
class Config:
from_attributes = True
class UserCompetencyRead(BaseModel):
"""Информация о компетенции пользователя."""
competency: CompetencyBase
level: int
class Config:
from_attributes = True
class UserArtifactRead(BaseModel):
"""Полученный артефакт."""
id: int
name: str
description: str
rarity: str
image_url: Optional[str]
class Config:
from_attributes = True
class UserRead(BaseModel):
"""Схема чтения пользователя."""
id: int
email: EmailStr
full_name: str
role: UserRole
xp: int
mana: int
current_rank_id: Optional[int]
is_active: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class UserProfile(UserRead):
"""Профиль с расширенной информацией."""
competencies: list[UserCompetencyRead]
artifacts: list[UserArtifactRead]
class UserCreate(BaseModel):
"""Создание пользователя (используется для сидов)."""
email: EmailStr
full_name: str
password: str
role: UserRole = UserRole.PILOT
class UserLogin(BaseModel):
"""Данные для входа."""
email: EmailStr
password: str

View File

View File

@ -0,0 +1,35 @@
"""Сервисные функции для журнала."""
from __future__ import annotations
from sqlalchemy.orm import Session
from app.models.journal import JournalEntry, JournalEventType
def log_event(
db: Session,
*,
user_id: int,
event_type: JournalEventType,
title: str,
description: str,
payload: dict | None = None,
xp_delta: int = 0,
mana_delta: int = 0,
) -> JournalEntry:
"""Создаём запись журнала и возвращаем её."""
entry = JournalEntry(
user_id=user_id,
event_type=event_type,
title=title,
description=description,
payload=payload,
xp_delta=xp_delta,
mana_delta=mana_delta,
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry

View File

@ -0,0 +1,124 @@
"""Бизнес-логика работы с миссиями."""
from __future__ import annotations
from fastapi import HTTPException, status
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.services.journal import log_event
from app.services.rank import apply_rank_upgrade
def submit_mission(
*, db: Session, user: User, mission: Mission, comment: str | None, proof_url: str | None
) -> MissionSubmission:
"""Создаём или обновляем отправку."""
submission = (
db.query(MissionSubmission)
.filter(MissionSubmission.user_id == user.id, MissionSubmission.mission_id == mission.id)
.first()
)
if submission and submission.status == SubmissionStatus.APPROVED:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Миссия уже зачтена")
if not submission:
submission = MissionSubmission(user_id=user.id, mission_id=mission.id)
submission.comment = comment
submission.proof_url = proof_url
submission.status = SubmissionStatus.PENDING
db.add(submission)
db.commit()
db.refresh(submission)
log_event(
db,
user_id=user.id,
event_type=JournalEventType.MISSION_COMPLETED,
title=f"Отправка миссии «{mission.title}»",
description="Отчёт отправлен и ожидает проверки.",
payload={"mission_id": mission.id},
)
return submission
def _increase_competencies(db: Session, user: User, mission: Mission) -> None:
"""Повышаем уровни компетенций за миссию."""
for reward in mission.competency_rewards:
user_competency = next(
(uc for uc in user.competencies if uc.competency_id == reward.competency_id),
None,
)
if not user_competency:
user_competency = UserCompetency(
user_id=user.id, competency_id=reward.competency_id, level=0
)
db.add(user_competency)
db.flush()
user_competency.level += reward.level_delta
db.add(user_competency)
def approve_submission(db: Session, submission: MissionSubmission) -> MissionSubmission:
"""Подтверждаем миссию, начисляем награды и проверяем ранг."""
if submission.status == SubmissionStatus.APPROVED:
return submission
submission.status = SubmissionStatus.APPROVED
submission.awarded_xp = submission.mission.xp_reward
submission.awarded_mana = submission.mission.mana_reward
user = submission.user
user.xp += submission.awarded_xp
user.mana += submission.awarded_mana
_increase_competencies(db, user, submission.mission)
db.add_all([submission, user])
db.commit()
db.refresh(submission)
log_event(
db,
user_id=user.id,
event_type=JournalEventType.MISSION_COMPLETED,
title=f"Миссия «{submission.mission.title}» подтверждена",
description="HR одобрил выполнение миссии.",
payload={"mission_id": submission.mission_id},
xp_delta=submission.awarded_xp,
mana_delta=submission.awarded_mana,
)
apply_rank_upgrade(user, db)
return submission
def reject_submission(db: Session, submission: MissionSubmission, comment: str | None = None) -> MissionSubmission:
"""Отклоняем миссию."""
submission.status = SubmissionStatus.REJECTED
if comment:
submission.comment = comment
db.add(submission)
db.commit()
db.refresh(submission)
log_event(
db,
user_id=submission.user_id,
event_type=JournalEventType.MISSION_COMPLETED,
title=f"Миссия «{submission.mission.title}» отклонена",
description=comment or "Проверьте отчёт и отправьте снова.",
payload={"mission_id": submission.mission_id},
)
return submission

View File

@ -0,0 +1,62 @@
"""Правила повышения ранга."""
from __future__ import annotations
from sqlalchemy.orm import Session
from app.models.journal import JournalEventType
from app.models.mission import SubmissionStatus
from app.models.rank import Rank
from app.models.user import User
from app.services.journal import log_event
def _eligible_rank(user: User, db: Session) -> Rank | None:
"""Определяем максимальный ранг, который доступен пользователю."""
ranks = db.query(Rank).order_by(Rank.required_xp).all()
approved_missions = {
submission.mission_id
for submission in user.submissions
if submission.status == SubmissionStatus.APPROVED
}
competencies = {c.competency_id: c.level for c in user.competencies}
candidate: Rank | None = None
for rank in ranks:
if user.xp < rank.required_xp:
break
missions_ok = all(req.mission_id in approved_missions for req in rank.mission_requirements)
competencies_ok = all(
competencies.get(req.competency_id, 0) >= req.required_level
for req in rank.competency_requirements
)
if missions_ok and competencies_ok:
candidate = rank
return candidate
def apply_rank_upgrade(user: User, db: Session) -> Rank | None:
"""Пытаемся повысить ранг и фиксируем событие."""
new_rank = _eligible_rank(user, db)
if not new_rank or user.current_rank_id == new_rank.id:
return None
previous_rank_id = user.current_rank_id
user.current_rank_id = new_rank.id
db.add(user)
db.commit()
db.refresh(user)
log_event(
db,
user_id=user.id,
event_type=JournalEventType.RANK_UP,
title="Повышение ранга",
description=f"Пилот достиг ранга «{new_rank.title}».",
payload={"previous_rank_id": previous_rank_id, "new_rank_id": new_rank.id},
)
return new_rank

View File

@ -0,0 +1,61 @@
"""Сервис магазина."""
from __future__ import annotations
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.models.journal import JournalEventType
from app.models.store import Order, OrderStatus, StoreItem
from app.models.user import User
from app.services.journal import log_event
def create_order(db: Session, user: User, item: StoreItem, comment: str | None) -> Order:
"""Пытаемся списать ману и создать заказ."""
if item.stock <= 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Товар закончился")
if user.mana < item.cost_mana:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Недостаточно маны")
user.mana -= item.cost_mana
item.stock -= 1
order = Order(user_id=user.id, item_id=item.id, comment=comment)
db.add_all([user, item, order])
db.commit()
db.refresh(order)
log_event(
db,
user_id=user.id,
event_type=JournalEventType.ORDER_CREATED,
title=f"Заказ «{item.name}» оформлен",
description="Команда HR подтвердит и передаст приз.",
payload={"order_id": order.id, "item_id": item.id},
mana_delta=-item.cost_mana,
)
return order
def update_order_status(db: Session, order: Order, status_: OrderStatus) -> Order:
"""Обновляем статус заказа."""
order.status = status_
db.add(order)
db.commit()
db.refresh(order)
if status_ == OrderStatus.APPROVED:
log_event(
db,
user_id=order.user_id,
event_type=JournalEventType.ORDER_APPROVED,
title=f"Заказ «{order.item.name}» одобрен",
description="Скоро мы свяжемся для вручения.",
payload={"order_id": order.id},
)
return order

View File

29
backend/pyproject.toml Normal file
View File

@ -0,0 +1,29 @@
[project]
name = "alabuga-backend"
version = "0.1.0"
description = "Геймифицированный модуль платформы 'Алабуга'"
requires-python = ">=3.13"
dependencies = []
[tool.black]
line-length = 100
target-version = ["py313"]
[tool.isort]
profile = "black"
line_length = 100
[tool.ruff]
line-length = 100
select = ["E", "F", "I", "N", "UP", "B", "A", "C4", "T20", "PT", "SIM", "ASYNC"]
ignore = ["B008", "SIM105", "SIM108"]
[tool.ruff.per-file-ignores]
"backend/app/alembic/*" = ["E501"]
[tool.mypy]
python_version = "3.13"
warn_unused_configs = true
ignore_missing_imports = true
pretty = true
show_error_codes = true

View File

@ -0,0 +1,9 @@
-r requirements.txt
pytest==8.2.2
pytest-asyncio==0.23.7
httpx==0.27.0
coverage==7.5.3
black==24.4.2
isort==5.13.2
ruff==0.4.7
mypy==1.10.0

16
backend/requirements.txt Normal file
View File

@ -0,0 +1,16 @@
fastapi==0.111.0
uvicorn[standard]==0.30.1
SQLAlchemy==2.0.30
alembic==1.13.1
pydantic==2.7.4
pydantic-settings==2.3.2
passlib[bcrypt]==1.7.4
python-jose[cryptography]==3.3.0
python-multipart==0.0.9
bcrypt==4.1.3
email-validator==2.1.1
pandas==2.2.2
openpyxl==3.1.3
fastapi-pagination==0.12.24
Jinja2==3.1.4

45
backend/tests/conftest.py Normal file
View File

@ -0,0 +1,45 @@
"""Фикстуры для тестов."""
from __future__ import annotations
import os
from pathlib import Path
import pytest
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
TEST_DB = Path("/tmp/alabuga_test.db")
os.environ["ALABUGA_SQLITE_PATH"] = str(TEST_DB)
from app.core.config import settings # noqa: E402
from app.db import base as db_base # noqa: E402
from app.db.session import SessionLocal, engine # noqa: E402
from app.models.base import Base # noqa: E402
@pytest.fixture(autouse=True)
def _prepare_database():
"""Очищаем БД перед тестом."""
if TEST_DB.exists():
TEST_DB.unlink()
engine.dispose()
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
yield
engine.dispose()
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def db_session():
"""Предоставляем сессию SQLAlchemy."""
session = SessionLocal()
try:
yield session
finally:
session.close()

View File

@ -0,0 +1,35 @@
"""Тестируем отправку миссии."""
from __future__ import annotations
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
from app.models.user import User, UserRole
from app.services.mission import approve_submission
def test_approve_submission_rewards(db_session):
"""После одобрения пилот получает награды."""
mission = Mission(title="Сборка спутника", description="Практика", xp_reward=150, mana_reward=90)
user = User(
email="pilot@alabuga.space",
full_name="Пилот",
role=UserRole.PILOT,
hashed_password="hash",
)
db_session.add_all([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)
db_session.refresh(user)
approve_submission(db_session, submission)
db_session.refresh(user)
assert user.xp == mission.xp_reward
assert user.mana == mission.mana_reward
assert submission.status == SubmissionStatus.APPROVED

View File

@ -0,0 +1,48 @@
"""Проверяем повышение ранга."""
from __future__ import annotations
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
from app.models.rank import Rank, RankMissionRequirement
from app.models.user import User, UserRole
from app.services.rank import apply_rank_upgrade
def test_rank_upgrade_after_requirements(db_session):
"""Пользователь получает ранг после выполнения условий."""
novice = Rank(title="Новичок", description="Старт", required_xp=0)
pilot = Rank(title="Пилот", description="Готов к полёту", required_xp=100)
mission = Mission(title="Тренировка", description="Базовое обучение", xp_reward=100, mana_reward=0)
db_session.add_all([novice, pilot, mission])
db_session.flush()
user = User(
email="test@alabuga.space",
full_name="Тестовый Пилот",
role=UserRole.PILOT,
hashed_password="hash",
xp=120,
current_rank_id=novice.id,
)
db_session.add(user)
db_session.flush()
submission = MissionSubmission(
user_id=user.id,
mission_id=mission.id,
status=SubmissionStatus.APPROVED,
awarded_xp=mission.xp_reward,
awarded_mana=mission.mana_reward,
)
requirement = RankMissionRequirement(rank_id=pilot.id, mission_id=mission.id)
db_session.add_all([user, submission, requirement])
db_session.commit()
db_session.refresh(user)
new_rank = apply_rank_upgrade(user, db_session)
assert new_rank is not None
assert user.current_rank_id == pilot.id

View File

@ -1,89 +1,36 @@
version: '3.9'
services:
postgres:
image: postgres:16.0-bookworm
user: 0:0
environment:
POSTGRES_USER: collabry
POSTGRES_DB: collabry
POSTGRES_PASSWORD: Passw0rd
backend:
build:
context: ./backend
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
ports:
- '8000:8000'
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- 5432:5432
healthcheck:
test: ["CMD-SHELL", "pg_isready", "-d", "app"]
redis:
image: redis:7.2.1-bookworm
ports:
- 6379:6379
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
zookeeper:
image: confluentinc/cp-zookeeper:7.5.0
user: 0:0
- backend-data:/data
- ./backend:/app
env_file:
- backend/.env.example
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
ports:
- 2181:2181
healthcheck:
test: nc -z localhost 2181 || exit -1
ALABUGA_ENVIRONMENT=docker
depends_on: []
kafka:
image: confluentinc/cp-kafka:7.5.0
user: 0:0
frontend:
build:
context: ./frontend
command: npm run dev -- --hostname 0.0.0.0 --port 3000
ports:
- '3000:3000'
environment:
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9091
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
NEXT_PUBLIC_API_URL=http://backend:8000
NEXT_PUBLIC_DEMO_EMAIL=candidate@alabuga.space
NEXT_PUBLIC_DEMO_PASSWORD=orbita123
volumes:
- ./frontend:/app
- /app/node_modules
depends_on:
- zookeeper
ports:
- 9091:9091
healthcheck:
test: nc -z localhost 9091 || exit -1
elasticsearch:
image: elasticsearch:8.6.2
volumes:
- es-data:/usr/share/elasticsearch/data
environment:
- discovery.type=single-node
- ES_JAVA_OPTS=-Xms512m -Xmx512m
ports:
- "9300:9300"
- "9200:9200"
healthcheck:
test: curl -f http://elasticsearch:9200/_cat/health
interval: 5s
timeout: 10s
# pgsync:
# build:
# context: pgsync
# environment:
# PG_USER: collabry
# PG_PASSWORD: Passw0rd
# PG_HOST: postgres
# PG_PORT: 5432
# REDIS_PORT: 6379
# REDIS_HOST: redis
# ELASTICSEARCH_HOST: elasticsearch
# ELASTICSEARCH_PORT: 9200
# depends_on:
# postgres:
# condition: service_healthy
# elasticsearch:
# condition: service_healthy
# redis:
# condition: service_healthy
# kafka:
# condition: service_healthy
- backend
volumes:
postgres-data:
es-data:
backend-data:

17
docs/demo-scenario.md Normal file
View File

@ -0,0 +1,17 @@
# Демо-сценарий «Получение оффера»
1. Авторизуйтесь под `candidate@alabuga.space` / `orbita123`.
2. Пройдите онбординг (на фронтенде после реализации).
3. Откройте раздел «Миссии» и выполните последовательность из ветки «Получение оффера»:
- «Загрузка документов» — загрузите PDF с документами.
- «Резюме астронавта» — обновите профиль.
- «Собеседование с капитаном» — назначьте встречу.
- «Онбординг экипажа» — посетите экскурсию.
4. После каждой отправки дождитесь одобрения HR (можно авторизоваться под `hr@alabuga.space` и подтвердить выполнение).
5. Наблюдайте за прогрессом:
- XP и мана увеличиваются, прокачиваются компетенции «Коммуникация» и «Командная работа».
- При накоплении условий пилот получает ранг «Член экипажа».
- В журнале событий фиксируются отправки и повышение ранга.
6. Потратьте ману в магазине на «Мерч экипажа» и посмотрите, как заказ появляется в очереди HR.
Файл будет актуализирован по мере развития фронтенда и добавления новых веток.

6
frontend/.eslintrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"extends": ["next", "next/core-web-vitals"],
"rules": {
"react/no-unescaped-entities": "off"
}
}

12
frontend/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

5
frontend/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

13
frontend/next.config.mjs Normal file
View File

@ -0,0 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
reactStrictMode: true,
compiler: {
styledComponents: true
},
experimental: {
serverComponentsExternalPackages: ['@reduxjs/toolkit']
}
};
export default nextConfig;

5564
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "alabuga-gamification-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@reduxjs/toolkit": "2.2.3",
"next": "14.2.4",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-redux": "9.1.2",
"styled-components": "6.1.11"
},
"devDependencies": {
"@types/node": "20.12.7",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"@types/styled-components": "5.1.34",
"eslint": "8.57.0",
"eslint-config-next": "14.2.4",
"typescript": "5.4.5"
}
}

View File

@ -0,0 +1,53 @@
import { apiFetch } from '../../lib/api';
import { getDemoToken } from '../../lib/demo-auth';
interface Submission {
mission_id: number;
status: string;
comment: string | null;
proof_url: string | null;
awarded_xp: number;
awarded_mana: number;
updated_at: string;
}
async function fetchModerationQueue() {
const token = await getDemoToken();
const submissions = await apiFetch<Submission[]>('/api/admin/submissions', { authToken: token });
return submissions;
}
export default async function AdminPage() {
const submissions = await fetchModerationQueue();
return (
<section>
<h2>HR-панель: очередь модерации</h2>
<p style={{ color: 'var(--text-muted)' }}>
Демонстрационная выборка отправленных миссий. В реальном приложении добавим карточки с деталями пилота и
кнопки approve/reject непосредственно из UI.
</p>
<div className="grid">
{submissions.map((submission) => (
<div key={submission.mission_id} className="card">
<h3>Миссия #{submission.mission_id}</h3>
<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>
))}
{submissions.length === 0 && <p>Очередь пуста все миссии проверены.</p>}
</div>
</section>
);
}

View File

@ -0,0 +1,33 @@
import { apiFetch } from '../../lib/api';
import { getDemoToken } from '../../lib/demo-auth';
import { JournalTimeline } from '../../components/JournalTimeline';
interface JournalEntry {
id: number;
title: string;
description: string;
event_type: string;
xp_delta: number;
mana_delta: number;
created_at: string;
}
async function fetchJournal() {
const token = await getDemoToken();
const entries = await apiFetch<JournalEntry[]>('/api/journal/', { authToken: token });
return entries;
}
export default async function JournalPage() {
const entries = await fetchJournal();
return (
<section>
<h2>Бортовой журнал</h2>
<p style={{ color: 'var(--text-muted)' }}>
Здесь фиксируется каждый шаг: отправка миссий, повышение ранга, получение артефактов и покупки в магазине.
</p>
<JournalTimeline entries={entries} />
</section>
);
}

View File

@ -0,0 +1,35 @@
import type { Metadata } from 'next';
import StyledComponentsRegistry from '../lib/styled-registry';
import '../styles/globals.css';
export const metadata: Metadata = {
title: 'Alabuga Mission Control',
description: 'Космический модуль геймификации для пилотов Алабуги'
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<body>
<StyledComponentsRegistry>
<header style={{ padding: '1.5rem', display: 'flex', justifyContent: 'space-between' }}>
<div>
<h1 style={{ margin: 0 }}>Mission Control</h1>
<p style={{ margin: 0, color: 'var(--text-muted)' }}>
Путь пилота от искателя до члена экипажа
</p>
</div>
<nav style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<a href="/">Дашборд</a>
<a href="/missions">Миссии</a>
<a href="/journal">Журнал</a>
<a href="/store">Магазин</a>
<a href="/admin">HR Панель</a>
</nav>
</header>
<main>{children}</main>
</StyledComponentsRegistry>
</body>
</html>
);
}

View File

@ -0,0 +1,58 @@
import { apiFetch } from '../../../lib/api';
import { getDemoToken } from '../../../lib/demo-auth';
import { MissionSubmissionForm } from '../../../components/MissionSubmissionForm';
interface MissionDetail {
id: number;
title: string;
description: string;
xp_reward: number;
mana_reward: number;
difficulty: string;
minimum_rank_id: number | null;
artifact_id: number | null;
prerequisites: number[];
competency_rewards: Array<{
competency_id: number;
competency_name: string;
level_delta: number;
}>;
}
async function fetchMission(id: number) {
const token = await getDemoToken();
const mission = await apiFetch<MissionDetail>(`/api/missions/${id}`, { authToken: token });
return { mission, token };
}
interface MissionPageProps {
params: { id: string };
}
export default async function MissionPage({ params }: MissionPageProps) {
const missionId = Number(params.id);
const { mission, token } = await fetchMission(missionId);
return (
<section>
<h2>{mission.title}</h2>
<span className="badge">{mission.difficulty}</span>
<p style={{ marginTop: '1rem', color: 'var(--text-muted)' }}>{mission.description}</p>
<p style={{ marginTop: '1rem' }}>
Награда: {mission.xp_reward} XP · {mission.mana_reward}
</p>
<div className="card">
<h3>Компетенции</h3>
<ul style={{ listStyle: 'none', padding: 0 }}>
{mission.competency_rewards.map((reward) => (
<li key={reward.competency_id}>
{reward.competency_name} +{reward.level_delta}
</li>
))}
{mission.competency_rewards.length === 0 && <li>Нет прокачки компетенций.</li>}
</ul>
</div>
<MissionSubmissionForm missionId={mission.id} token={token} />
</section>
);
}

View File

@ -0,0 +1,24 @@
import { apiFetch } from '../../lib/api';
import { getDemoToken } from '../../lib/demo-auth';
import { MissionList, MissionSummary } from '../../components/MissionList';
async function fetchMissions() {
const token = await getDemoToken();
const missions = await apiFetch<MissionSummary[]>('/api/missions/', { authToken: token });
return missions;
}
export default async function MissionsPage() {
const missions = await fetchMissions();
return (
<section>
<h2>Активные миссии</h2>
<p style={{ color: 'var(--text-muted)' }}>
Список обновляется в реальном времени и зависит от вашего ранга и прогресса. HR может добавлять новые
задания в админ-панели.
</p>
<MissionList missions={missions} />
</section>
);
}

63
frontend/src/app/page.tsx Normal file
View File

@ -0,0 +1,63 @@
import { apiFetch } from '../lib/api';
import { getDemoToken } from '../lib/demo-auth';
import { ProgressOverview } from '../components/ProgressOverview';
interface ProfileResponse {
full_name: string;
xp: number;
mana: number;
current_rank_id: number | null;
competencies: Array<{
competency: { id: number; name: string };
level: number;
}>;
artifacts: Array<{
id: number;
name: string;
rarity: string;
}>;
}
interface RankResponse {
id: number;
title: string;
description: string;
}
async function fetchProfile() {
const token = await getDemoToken();
const profile = await apiFetch<ProfileResponse>('/api/me', { authToken: token });
const ranks = await apiFetch<RankResponse[]>('/api/admin/ranks', { authToken: token });
const currentRank = ranks.find((rank) => rank.id === profile.current_rank_id);
return { token, profile, currentRank };
}
export default async function DashboardPage() {
const { token, profile, currentRank } = await fetchProfile();
return (
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr' }}>
<div>
<ProgressOverview
fullName={profile.full_name}
xp={profile.xp}
mana={profile.mana}
rank={currentRank}
competencies={profile.competencies}
artifacts={profile.artifacts}
/>
</div>
<aside className="card">
<h3>Ближайшая цель</h3>
<p style={{ color: 'var(--text-muted)' }}>
Выполните миссии ветки «Получение оффера», чтобы закрепиться в экипаже и открыть доступ к
сложным задачам.
</p>
<p style={{ marginTop: '1rem' }}>Доступ к HR-панели: {token ? 'есть (демо-режим)' : 'нет'}</p>
<a className="primary" style={{ marginTop: '1rem', display: 'inline-block' }} href="/missions">
Посмотреть миссии
</a>
</aside>
</section>
);
}

View File

@ -0,0 +1,31 @@
import { apiFetch } from '../../lib/api';
import { getDemoToken } from '../../lib/demo-auth';
import { StoreItems } from '../../components/StoreItems';
interface StoreItem {
id: number;
name: string;
description: string;
cost_mana: number;
stock: number;
}
async function fetchStore() {
const token = await getDemoToken();
const items = await apiFetch<StoreItem[]>('/api/store/items', { authToken: token });
return { items, token };
}
export default async function StorePage() {
const { items, token } = await fetchStore();
return (
<section>
<h2>Магазин артефактов</h2>
<p style={{ color: 'var(--text-muted)' }}>
Обменивайте ману на уникальные впечатления и мерч. Доступно только для активных членов экипажа.
</p>
<StoreItems items={items} token={token} />
</section>
);
}

View File

@ -0,0 +1,59 @@
'use client';
import styled from 'styled-components';
type JournalEntry = {
id: number;
title: string;
description: string;
event_type: string;
xp_delta: number;
mana_delta: number;
created_at: string;
};
const Timeline = styled.ul`
list-style: none;
padding: 0;
margin: 0;
`;
const Item = styled.li`
position: relative;
padding-left: 1.5rem;
margin-bottom: 1.25rem;
&::before {
content: '';
position: absolute;
left: 0;
top: 0.4rem;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 8px rgba(108, 92, 231, 0.5);
}
`;
export function JournalTimeline({ entries }: { entries: JournalEntry[] }) {
if (entries.length === 0) {
return <p>Журнал пуст выполните миссии, чтобы увидеть прогресс.</p>;
}
return (
<Timeline>
{entries.map((entry) => (
<Item key={entry.id}>
<h4 style={{ margin: 0 }}>{entry.title}</h4>
<small style={{ color: 'var(--text-muted)' }}>{new Date(entry.created_at).toLocaleString('ru-RU')}</small>
<p style={{ marginTop: '0.5rem' }}>{entry.description}</p>
<div style={{ color: 'var(--text-muted)' }}>
{entry.xp_delta !== 0 && <span style={{ marginRight: '1rem' }}>{entry.xp_delta} XP</span>}
{entry.mana_delta !== 0 && <span>{entry.mana_delta} </span>}
</div>
</Item>
))}
</Timeline>
);
}

View File

@ -0,0 +1,44 @@
'use client';
import styled from 'styled-components';
export interface MissionSummary {
id: number;
title: string;
description: string;
xp_reward: number;
mana_reward: number;
difficulty: string;
is_active: boolean;
}
const Card = styled.div`
background: rgba(17, 22, 51, 0.85);
border-radius: 14px;
padding: 1.25rem;
border: 1px solid rgba(108, 92, 231, 0.25);
`;
export function MissionList({ missions }: { missions: MissionSummary[] }) {
if (missions.length === 0) {
return <p>Нет активных миссий скоро появятся новые испытания.</p>;
}
return (
<div className="grid">
{missions.map((mission) => (
<Card key={mission.id}>
<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>
);
}

View File

@ -0,0 +1,73 @@
'use client';
import { useState } from 'react';
import { apiFetch } from '../lib/api';
interface MissionSubmissionFormProps {
missionId: number;
token?: string;
}
export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFormProps) {
const [comment, setComment] = useState('');
const [proofUrl, setProofUrl] = useState('');
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (!token) {
setStatus('Не удалось получить токен демо-пользователя.');
return;
}
try {
setLoading(true);
setStatus(null);
await apiFetch(`/api/missions/${missionId}/submit`, {
method: 'POST',
body: JSON.stringify({ comment, proof_url: proofUrl }),
authToken: token
});
setStatus('Отчёт отправлен! HR проверит миссию в панели модерации.');
setComment('');
setProofUrl('');
} catch (error) {
if (error instanceof Error) {
setStatus(error.message);
}
} finally {
setLoading(false);
}
}
return (
<form className="card" onSubmit={handleSubmit} style={{ marginTop: '2rem' }}>
<h3>Отправить отчёт</h3>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
Комментарий
<textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
rows={4}
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
placeholder="Опишите, что сделали."
/>
</label>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
Ссылка на доказательство
<input
type="url"
value={proofUrl}
onChange={(event) => setProofUrl(event.target.value)}
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
placeholder="https://..."
/>
</label>
<button className="primary" type="submit" disabled={loading}>
{loading ? 'Отправляем...' : 'Отправить HR'}
</button>
{status && <p style={{ marginTop: '1rem', color: 'var(--accent-light)' }}>{status}</p>}
</form>
);
}

View File

@ -0,0 +1,97 @@
'use client';
import styled from 'styled-components';
type Rank = {
id: number | null;
title?: string;
};
type Competency = {
competency: {
id: number;
name: string;
};
level: number;
};
type Artifact = {
id: number;
name: string;
rarity: string;
};
export interface ProfileProps {
fullName: string;
xp: number;
mana: number;
rank?: Rank;
competencies: Competency[];
artifacts: Artifact[];
}
const Card = styled.div`
background: rgba(17, 22, 51, 0.85);
border-radius: 16px;
padding: 1.5rem;
border: 1px solid rgba(108, 92, 231, 0.4);
`;
const ProgressBar = styled.div<{ value: number }>`
position: relative;
height: 12px;
border-radius: 999px;
background: rgba(162, 155, 254, 0.2);
overflow: hidden;
margin-top: 0.5rem;
&::after {
content: '';
position: absolute;
inset: 0;
width: ${({ value }) => Math.min(100, value)}%;
background: linear-gradient(90deg, #6c5ce7, #00b894);
}
`;
export function ProgressOverview({ fullName, xp, mana, rank, competencies, artifacts }: ProfileProps) {
const xpPercent = Math.min(100, (xp / 500) * 100);
return (
<Card>
<h2 style={{ marginTop: 0 }}>{fullName}</h2>
<p style={{ color: 'var(--text-muted)' }}>Текущий ранг: {rank?.title ?? 'не назначен'}</p>
<div style={{ marginTop: '1rem' }}>
<strong>Опыт:</strong>
<ProgressBar value={xpPercent} />
<small style={{ color: 'var(--text-muted)' }}>{xp} / 500 XP до следующей цели</small>
</div>
<div style={{ marginTop: '1rem' }}>
<strong>Мана:</strong>
<p style={{ margin: '0.5rem 0' }}>{mana} </p>
</div>
<div style={{ marginTop: '1.5rem' }}>
<strong>Компетенции</strong>
<ul style={{ listStyle: 'none', padding: 0, margin: '0.5rem 0 0' }}>
{competencies.map((item) => (
<li key={item.competency.id} style={{ marginBottom: '0.25rem' }}>
<span className="badge">{item.competency.name}</span> уровень {item.level}
</li>
))}
</ul>
</div>
<div style={{ marginTop: '1.5rem' }}>
<strong>Артефакты</strong>
<div className="grid" style={{ marginTop: '0.75rem' }}>
{artifacts.length === 0 && <p>Ещё нет трофеев выполните миссии!</p>}
{artifacts.map((artifact) => (
<div key={artifact.id} className="card">
<span className="badge">{artifact.rarity}</span>
<h4 style={{ marginBottom: '0.5rem' }}>{artifact.name}</h4>
</div>
))}
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,67 @@
'use client';
import { useState } from 'react';
import styled from 'styled-components';
import { apiFetch } from '../lib/api';
type StoreItem = {
id: number;
name: string;
description: string;
cost_mana: number;
stock: number;
};
const Card = styled.div`
background: rgba(17, 22, 51, 0.85);
border-radius: 14px;
padding: 1.25rem;
border: 1px solid rgba(108, 92, 231, 0.25);
`;
export function StoreItems({ items, token }: { items: StoreItem[]; token?: string }) {
const [loadingId, setLoadingId] = useState<number | null>(null);
const [message, setMessage] = useState<string | null>(null);
async function handlePurchase(id: number) {
try {
setLoadingId(id);
setMessage(null);
await apiFetch(`/api/store/orders`, {
method: 'POST',
body: JSON.stringify({ item_id: id }),
authToken: token
});
setMessage('Заказ оформлен — проверьте журнал событий.');
} catch (error) {
if (error instanceof Error) {
setMessage(error.message);
}
} finally {
setLoadingId(null);
}
}
return (
<div>
{message && <p style={{ color: 'var(--accent-light)' }}>{message}</p>}
<div className="grid">
{items.map((item) => (
<Card key={item.id}>
<h3 style={{ marginBottom: '0.5rem' }}>{item.name}</h3>
<p style={{ color: 'var(--text-muted)' }}>{item.description}</p>
<p style={{ marginTop: '1rem' }}>{item.cost_mana} · остаток {item.stock}</p>
<button
className="primary"
style={{ marginTop: '1rem' }}
onClick={() => handlePurchase(item.id)}
disabled={item.stock === 0 || !token || loadingId === item.id}
>
{loadingId === item.id ? 'Оформляем...' : 'Получить приз'}
</button>
</Card>
))}
</div>
</div>
);
}

26
frontend/src/lib/api.ts Normal file
View File

@ -0,0 +1,26 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
export interface RequestOptions extends RequestInit {
authToken?: string;
}
export async function apiFetch<T>(path: string, options: RequestOptions = {}): Promise<T> {
const headers = new Headers(options.headers);
headers.set('Content-Type', 'application/json');
if (options.authToken) {
headers.set('Authorization', `Bearer ${options.authToken}`);
}
const response = await fetch(`${API_URL}${path}`, {
...options,
headers,
cache: 'no-store'
});
if (!response.ok) {
const text = await response.text();
throw new Error(`API error ${response.status}: ${text}`);
}
return response.json() as Promise<T>;
}

View File

@ -0,0 +1,20 @@
import { apiFetch } from './api';
let cachedToken: string | null = null;
export async function getDemoToken() {
if (cachedToken) {
return cachedToken;
}
const email = process.env.NEXT_PUBLIC_DEMO_EMAIL ?? 'candidate@alabuga.space';
const password = process.env.NEXT_PUBLIC_DEMO_PASSWORD ?? 'orbita123';
const data = await apiFetch<{ access_token: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password })
});
cachedToken = data.access_token;
return cachedToken;
}

View File

@ -0,0 +1,16 @@
'use client';
import React, { useState } from 'react';
import { ServerStyleSheet, StyleSheetManager } from 'styled-components';
export default function StyledComponentsRegistry({ children }: { children: React.ReactNode }) {
const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());
if (typeof window !== 'undefined') {
return <>{children}</>;
}
return (
<StyleSheetManager sheet={styledComponentsStyleSheet.instance}>{children}</StyleSheetManager>
);
}

View File

@ -0,0 +1,73 @@
:root {
color-scheme: dark;
--bg: #080b1a;
--bg-panel: #111633;
--accent: #6c5ce7;
--accent-light: #a29bfe;
--text: #f5f6fa;
--text-muted: #b2bec3;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body {
margin: 0;
padding: 0;
background: radial-gradient(circle at 20% 20%, rgba(108, 92, 231, 0.2), transparent),
radial-gradient(circle at 80% 0%, rgba(0, 184, 148, 0.2), transparent),
var(--bg);
color: var(--text);
min-height: 100vh;
}
a {
color: inherit;
text-decoration: none;
}
* {
box-sizing: border-box;
}
main {
padding: 1.5rem;
}
.card {
background: rgba(17, 22, 51, 0.85);
border: 1px solid rgba(162, 155, 254, 0.2);
border-radius: 16px;
padding: 1.5rem;
backdrop-filter: blur(10px);
margin-bottom: 1.5rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
}
.badge {
background: rgba(162, 155, 254, 0.15);
border-radius: 9999px;
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
color: var(--accent-light);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.primary {
padding: 0.75rem 1.5rem;
border-radius: 12px;
border: none;
background: linear-gradient(135deg, var(--accent), #00b894);
color: white;
font-weight: 600;
cursor: pointer;
}
.primary:disabled {
opacity: 0.4;
cursor: not-allowed;
}

39
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,39 @@
{
"compilerOptions": {
"target": "es2020",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"types": [
"node"
],
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

260
scripts/seed_data.py Normal file
View File

@ -0,0 +1,260 @@
"""Наполнение демонстрационными данными."""
from __future__ import annotations
from pathlib import Path
import sys
from sqlalchemy.orm import Session
ROOT = Path(__file__).resolve().parents[1]
sys.path.append(str(ROOT / 'backend'))
from app.core.security import get_password_hash
from app.db.session import SessionLocal, engine
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.store import StoreItem
from app.models.user import Competency, CompetencyCategory, User, UserCompetency, UserRole
DATA_SENTINEL = Path("/data/.seeded")
def ensure_database() -> None:
"""Создаём таблицы, если их ещё нет."""
from app.models.base import Base
Base.metadata.create_all(bind=engine)
def seed() -> None:
if DATA_SENTINEL.exists():
print("Database already seeded, skipping")
return
ensure_database()
session: Session = SessionLocal()
try:
# Компетенции
competencies = [
Competency(
name="Навигация",
description="Умение ориентироваться в процессах Алабуги",
category=CompetencyCategory.ANALYTICS,
),
Competency(
name="Коммуникация",
description="Чётко объяснять свои идеи",
category=CompetencyCategory.COMMUNICATION,
),
Competency(
name="Инженерия",
description="Работа с технологиями и оборудованием",
category=CompetencyCategory.TECH,
),
Competency(
name="Командная работа",
description="Поддержка экипажа",
category=CompetencyCategory.TEAMWORK,
),
Competency(
name="Лидерство",
description="Вести за собой",
category=CompetencyCategory.LEADERSHIP,
),
Competency(
name="Культура",
description="Следование лору Алабуги",
category=CompetencyCategory.CULTURE,
),
]
session.add_all(competencies)
session.flush()
# Ранги
ranks = [
Rank(title="Искатель", description="Первое знакомство с космофлотом", required_xp=0),
Rank(title="Пилот-кандидат", description="Готовится к старту", required_xp=200),
Rank(title="Член экипажа", description="Активно выполняет миссии", required_xp=500),
]
session.add_all(ranks)
session.flush()
# Артефакты
artifacts = [
Artifact(
name="Значок Буран",
description="Памятный знак о легендарном корабле",
rarity=ArtifactRarity.RARE,
),
Artifact(
name="Патч экипажа",
description="Показывает принадлежность к команде",
rarity=ArtifactRarity.COMMON,
),
]
session.add_all(artifacts)
session.flush()
# Ветка миссий
branch = Branch(
title="Получение оффера",
description="Путь кандидата от знакомства до выхода на орбиту",
category="quest",
)
session.add(branch)
session.flush()
# Миссии
mission_documents = Mission(
title="Загрузка документов",
description="Соберите полный пакет документов для HR",
xp_reward=100,
mana_reward=50,
difficulty=MissionDifficulty.EASY,
minimum_rank_id=ranks[0].id,
artifact_id=artifacts[1].id,
)
mission_resume = Mission(
title="Резюме астронавта",
description="Обновите резюме с акцентом на космический опыт",
xp_reward=120,
mana_reward=60,
difficulty=MissionDifficulty.MEDIUM,
minimum_rank_id=ranks[0].id,
)
mission_interview = Mission(
title="Собеседование с капитаном",
description="Пройдите собеседование и докажите готовность",
xp_reward=180,
mana_reward=80,
difficulty=MissionDifficulty.MEDIUM,
minimum_rank_id=ranks[1].id,
artifact_id=artifacts[0].id,
)
mission_onboarding = Mission(
title="Онбординг экипажа",
description="Познакомьтесь с кораблём и командой",
xp_reward=200,
mana_reward=100,
difficulty=MissionDifficulty.HARD,
minimum_rank_id=ranks[1].id,
)
session.add_all([mission_documents, mission_resume, mission_interview, mission_onboarding])
session.flush()
session.add_all(
[
MissionCompetencyReward(
mission_id=mission_documents.id,
competency_id=competencies[1].id,
level_delta=1,
),
MissionCompetencyReward(
mission_id=mission_resume.id,
competency_id=competencies[0].id,
level_delta=1,
),
MissionCompetencyReward(
mission_id=mission_interview.id,
competency_id=competencies[1].id,
level_delta=1,
),
MissionCompetencyReward(
mission_id=mission_onboarding.id,
competency_id=competencies[3].id,
level_delta=1,
),
]
)
session.add_all(
[
BranchMission(branch_id=branch.id, mission_id=mission_documents.id, order=1),
BranchMission(branch_id=branch.id, mission_id=mission_resume.id, order=2),
BranchMission(branch_id=branch.id, mission_id=mission_interview.id, order=3),
BranchMission(branch_id=branch.id, mission_id=mission_onboarding.id, order=4),
]
)
session.add_all(
[
RankMissionRequirement(rank_id=ranks[1].id, mission_id=mission_documents.id),
RankMissionRequirement(rank_id=ranks[1].id, mission_id=mission_resume.id),
RankMissionRequirement(rank_id=ranks[2].id, mission_id=mission_interview.id),
RankMissionRequirement(rank_id=ranks[2].id, mission_id=mission_onboarding.id),
]
)
session.add_all(
[
RankCompetencyRequirement(
rank_id=ranks[1].id,
competency_id=competencies[1].id,
required_level=1,
),
RankCompetencyRequirement(
rank_id=ranks[2].id,
competency_id=competencies[3].id,
required_level=1,
),
]
)
# Магазин
session.add_all(
[
StoreItem(
name="Экскурсия по космодрому",
description="Личный тур по цехам Алабуги",
cost_mana=200,
stock=5,
),
StoreItem(
name="Мерч экипажа",
description="Футболка с эмблемой миссии",
cost_mana=150,
stock=10,
),
]
)
# Пользователи
pilot = User(
email="candidate@alabuga.space",
full_name="Алексей Пилотов",
role=UserRole.PILOT,
hashed_password=get_password_hash("orbita123"),
current_rank_id=ranks[0].id,
)
hr = User(
email="hr@alabuga.space",
full_name="Мария HR",
role=UserRole.HR,
hashed_password=get_password_hash("orbita123"),
current_rank_id=ranks[2].id,
)
session.add_all([pilot, hr])
session.flush()
session.add_all(
[
UserCompetency(user_id=pilot.id, competency_id=competencies[1].id, level=1),
UserCompetency(user_id=pilot.id, competency_id=competencies[0].id, level=1),
]
)
session.commit()
DATA_SENTINEL.write_text("seeded")
print("Seed data created")
finally:
session.close()
if __name__ == "__main__":
seed()