1 vers
This commit is contained in:
parent
ce3d1e70f5
commit
e050bd46ef
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal 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
18
.pre-commit-config.yaml
Normal 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/
|
||||
78
README.md
78
README.md
|
|
@ -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
23
backend/Dockerfile
Normal 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
35
backend/alembic.ini
Normal 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
53
backend/alembic/env.py
Normal 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()
|
||||
13
backend/alembic/script.py.mako
Normal file
13
backend/alembic/script.py.mako
Normal 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
|
||||
287
backend/alembic/versions/20240609_0001_init.py
Normal file
287
backend/alembic/versions/20240609_0001_init.py
Normal 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
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
49
backend/app/api/deps.py
Normal file
49
backend/app/api/deps.py
Normal 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
|
||||
12
backend/app/api/routes/__init__.py
Normal file
12
backend/app/api/routes/__init__.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"""Экспортируем роутеры для подключения в приложении."""
|
||||
|
||||
from . import admin, auth, journal, missions, store, users # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"admin",
|
||||
"auth",
|
||||
"journal",
|
||||
"missions",
|
||||
"store",
|
||||
"users",
|
||||
]
|
||||
167
backend/app/api/routes/admin.py
Normal file
167
backend/app/api/routes/admin.py
Normal 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)
|
||||
37
backend/app/api/routes/auth.py
Normal file
37
backend/app/api/routes/auth.py
Normal 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
|
||||
29
backend/app/api/routes/journal.py
Normal file
29
backend/app/api/routes/journal.py
Normal 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]
|
||||
122
backend/app/api/routes/missions.py
Normal file
122
backend/app/api/routes/missions.py
Normal 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)
|
||||
76
backend/app/api/routes/store.py
Normal file
76
backend/app/api/routes/store.py
Normal 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)
|
||||
25
backend/app/api/routes/users.py
Normal file
25
backend/app/api/routes/users.py
Normal 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)
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
40
backend/app/core/config.py
Normal file
40
backend/app/core/config.py
Normal 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()
|
||||
42
backend/app/core/security.py
Normal file
42
backend/app/core/security.py
Normal 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
|
||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
3
backend/app/db/base.py
Normal file
3
backend/app/db/base.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""Импорт моделей для Alembic."""
|
||||
|
||||
from app.models import * # noqa: F401,F403
|
||||
21
backend/app/db/session.py
Normal file
21
backend/app/db/session.py
Normal 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
43
backend/app/main.py
Normal 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}
|
||||
29
backend/app/models/__init__.py
Normal file
29
backend/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
35
backend/app/models/artifact.py
Normal file
35
backend/app/models/artifact.py
Normal 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")
|
||||
21
backend/app/models/base.py
Normal file
21
backend/app/models/base.py
Normal 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
|
||||
)
|
||||
39
backend/app/models/branch.py
Normal file
39
backend/app/models/branch.py
Normal 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")
|
||||
37
backend/app/models/journal.py
Normal file
37
backend/app/models/journal.py
Normal 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")
|
||||
124
backend/app/models/mission.py
Normal file
124
backend/app/models/mission.py
Normal 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")
|
||||
58
backend/app/models/rank.py
Normal file
58
backend/app/models/rank.py
Normal 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")
|
||||
51
backend/app/models/store.py
Normal file
51
backend/app/models/store.py
Normal 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
105
backend/app/models/user.py
Normal 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")
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
10
backend/app/schemas/auth.py
Normal file
10
backend/app/schemas/auth.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
"""Схемы авторизации."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Ответ с токеном."""
|
||||
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
26
backend/app/schemas/branch.py
Normal file
26
backend/app/schemas/branch.py
Normal 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
|
||||
26
backend/app/schemas/journal.py
Normal file
26
backend/app/schemas/journal.py
Normal 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
|
||||
90
backend/app/schemas/mission.py
Normal file
90
backend/app/schemas/mission.py
Normal 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
|
||||
43
backend/app/schemas/rank.py
Normal file
43
backend/app/schemas/rank.py
Normal 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
|
||||
44
backend/app/schemas/store.py
Normal file
44
backend/app/schemas/store.py
Normal 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
|
||||
86
backend/app/schemas/user.py
Normal file
86
backend/app/schemas/user.py
Normal 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
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
35
backend/app/services/journal.py
Normal file
35
backend/app/services/journal.py
Normal 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
|
||||
124
backend/app/services/mission.py
Normal file
124
backend/app/services/mission.py
Normal 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
|
||||
62
backend/app/services/rank.py
Normal file
62
backend/app/services/rank.py
Normal 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
|
||||
61
backend/app/services/store.py
Normal file
61
backend/app/services/store.py
Normal 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
|
||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
29
backend/pyproject.toml
Normal file
29
backend/pyproject.toml
Normal 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
|
||||
9
backend/requirements-dev.txt
Normal file
9
backend/requirements-dev.txt
Normal 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
16
backend/requirements.txt
Normal 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
45
backend/tests/conftest.py
Normal 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()
|
||||
35
backend/tests/test_mission_submission.py
Normal file
35
backend/tests/test_mission_submission.py
Normal 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
|
||||
48
backend/tests/test_rank_engine.py
Normal file
48
backend/tests/test_rank_engine.py
Normal 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
|
||||
|
|
@ -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
17
docs/demo-scenario.md
Normal 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
6
frontend/.eslintrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": ["next", "next/core-web-vitals"],
|
||||
"rules": {
|
||||
"react/no-unescaped-entities": "off"
|
||||
}
|
||||
}
|
||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal 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
5
frontend/next-env.d.ts
vendored
Normal 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
13
frontend/next.config.mjs
Normal 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
5564
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
53
frontend/src/app/admin/page.tsx
Normal file
53
frontend/src/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
frontend/src/app/journal/page.tsx
Normal file
33
frontend/src/app/journal/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
frontend/src/app/layout.tsx
Normal file
35
frontend/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
frontend/src/app/missions/[id]/page.tsx
Normal file
58
frontend/src/app/missions/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
frontend/src/app/missions/page.tsx
Normal file
24
frontend/src/app/missions/page.tsx
Normal 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
63
frontend/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
frontend/src/app/store/page.tsx
Normal file
31
frontend/src/app/store/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
frontend/src/components/JournalTimeline.tsx
Normal file
59
frontend/src/components/JournalTimeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
frontend/src/components/MissionList.tsx
Normal file
44
frontend/src/components/MissionList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
frontend/src/components/MissionSubmissionForm.tsx
Normal file
73
frontend/src/components/MissionSubmissionForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
frontend/src/components/ProgressOverview.tsx
Normal file
97
frontend/src/components/ProgressOverview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
frontend/src/components/StoreItems.tsx
Normal file
67
frontend/src/components/StoreItems.tsx
Normal 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
26
frontend/src/lib/api.ts
Normal 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>;
|
||||
}
|
||||
20
frontend/src/lib/demo-auth.ts
Normal file
20
frontend/src/lib/demo-auth.ts
Normal 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;
|
||||
}
|
||||
16
frontend/src/lib/styled-registry.tsx
Normal file
16
frontend/src/lib/styled-registry.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
frontend/src/styles/globals.css
Normal file
73
frontend/src/styles/globals.css
Normal 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
39
frontend/tsconfig.json
Normal 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
260
scripts/seed_data.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user