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:
|
services:
|
||||||
postgres:
|
backend:
|
||||||
image: postgres:16.0-bookworm
|
build:
|
||||||
user: 0:0
|
context: ./backend
|
||||||
environment:
|
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
POSTGRES_USER: collabry
|
ports:
|
||||||
POSTGRES_DB: collabry
|
- '8000:8000'
|
||||||
POSTGRES_PASSWORD: Passw0rd
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- backend-data:/data
|
||||||
ports:
|
- ./backend:/app
|
||||||
- 5432:5432
|
env_file:
|
||||||
healthcheck:
|
- backend/.env.example
|
||||||
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
|
|
||||||
environment:
|
environment:
|
||||||
ZOOKEEPER_CLIENT_PORT: 2181
|
ALABUGA_ENVIRONMENT=docker
|
||||||
ZOOKEEPER_TICK_TIME: 2000
|
depends_on: []
|
||||||
ports:
|
|
||||||
- 2181:2181
|
|
||||||
healthcheck:
|
|
||||||
test: nc -z localhost 2181 || exit -1
|
|
||||||
|
|
||||||
kafka:
|
frontend:
|
||||||
image: confluentinc/cp-kafka:7.5.0
|
build:
|
||||||
user: 0:0
|
context: ./frontend
|
||||||
|
command: npm run dev -- --hostname 0.0.0.0 --port 3000
|
||||||
|
ports:
|
||||||
|
- '3000:3000'
|
||||||
environment:
|
environment:
|
||||||
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
|
NEXT_PUBLIC_API_URL=http://backend:8000
|
||||||
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9091
|
NEXT_PUBLIC_DEMO_EMAIL=candidate@alabuga.space
|
||||||
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT
|
NEXT_PUBLIC_DEMO_PASSWORD=orbita123
|
||||||
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
|
volumes:
|
||||||
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
- ./frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
depends_on:
|
depends_on:
|
||||||
- zookeeper
|
- backend
|
||||||
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
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
backend-data:
|
||||||
es-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