First working version

This commit is contained in:
danilgryaznev 2025-09-22 20:55:55 +02:00
parent 1b46293ce2
commit 746e62d617
8 changed files with 126 additions and 15 deletions

View File

@ -0,0 +1,93 @@
# 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. Скопируйте пример конфигурации и при необходимости измените значения:
```bash
cp backend/.env.example backend/.env
cp frontend/.env.example frontend/.env
```
3. Запустите окружение:
```bash
docker compose up --build
```
4. После запуска будут доступны сервисы:
- API: http://localhost:8000 (документация Swagger — `/docs`).
- Фронтенд: http://localhost:3000.
Docker Compose автоматически переопределяет `ALABUGA_SQLITE_PATH=/data/app.db`, чтобы база сохранялась во внешнем volume. Для локального запуска вне Docker оставьте путь `./data/app.db` из примера.
## Локальная разработка backend
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
export PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1
pip install -r requirements-dev.txt
# подготовьте переменные окружения (однократно)
cp .env.example .env
# применяем миграции
alembic upgrade head
# создаём демо-данные (команда выполняется из корня репозитория)
cd ..
python -m scripts.seed_data
cd backend
# Запуск API
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
## Локальная разработка фронтенда
```bash
cd frontend
npm install
cp .env.example .env
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).

View File

@ -2,7 +2,8 @@ FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
POETRY_VIRTUALENVS_CREATE=false POETRY_VIRTUALENVS_CREATE=false \
PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1
WORKDIR /app WORKDIR /app

View File

@ -7,7 +7,9 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.db.session import get_db from app.db.session import get_db
from app.models.rank import Rank
from app.models.user import User from app.models.user import User
from app.schemas.rank import RankBase
from app.schemas.user import UserProfile from app.schemas.user import UserProfile
router = APIRouter(prefix="/api", tags=["profile"]) router = APIRouter(prefix="/api", tags=["profile"])
@ -23,3 +25,13 @@ def get_profile(
_ = current_user.competencies _ = current_user.competencies
_ = current_user.artifacts _ = current_user.artifacts
return UserProfile.model_validate(current_user) return UserProfile.model_validate(current_user)
@router.get("/ranks", response_model=list[RankBase], summary="Перечень рангов")
def list_ranks(
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
) -> list[RankBase]:
"""Возвращаем ранги по возрастанию требований."""
ranks = db.query(Rank).order_by(Rank.required_xp).all()
return [RankBase.model_validate(rank) for rank in ranks]

View File

@ -6,10 +6,13 @@ from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
BASE_DIR = Path(__file__).resolve().parents[2]
class Settings(BaseSettings): class Settings(BaseSettings):
"""Глобальные настройки сервиса.""" """Глобальные настройки сервиса."""
model_config = SettingsConfigDict(env_file=".env", env_prefix="ALABUGA_", extra="ignore") model_config = SettingsConfigDict(env_file=BASE_DIR / ".env", env_prefix="ALABUGA_", extra="ignore")
project_name: str = "Alabuga Gamification API" project_name: str = "Alabuga Gamification API"
environment: str = "local" environment: str = "local"
@ -33,6 +36,10 @@ def get_settings() -> Settings:
"""Кэшируем создание настроек, чтобы не читать файл каждый раз.""" """Кэшируем создание настроек, чтобы не читать файл каждый раз."""
settings = Settings() settings = Settings()
if not settings.sqlite_path.is_absolute():
settings.sqlite_path = (BASE_DIR / settings.sqlite_path).resolve()
settings.sqlite_path.parent.mkdir(parents=True, exist_ok=True) settings.sqlite_path.parent.mkdir(parents=True, exist_ok=True)
return settings return settings

View File

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

View File

@ -13,7 +13,7 @@ services:
env_file: env_file:
- backend/.env.example - backend/.env.example
environment: environment:
ALABUGA_ENVIRONMENT=docker ALABUGA_ENVIRONMENT: docker
depends_on: [] depends_on: []
frontend: frontend:
@ -23,9 +23,9 @@ services:
ports: ports:
- '3000:3000' - '3000:3000'
environment: environment:
NEXT_PUBLIC_API_URL=http://backend:8000 NEXT_PUBLIC_API_URL: http://backend:8000
NEXT_PUBLIC_DEMO_EMAIL=candidate@alabuga.space NEXT_PUBLIC_DEMO_EMAIL: candidate@alabuga.space
NEXT_PUBLIC_DEMO_PASSWORD=orbita123 NEXT_PUBLIC_DEMO_PASSWORD: orbita123
volumes: volumes:
- ./frontend:/app - ./frontend:/app
- /app/node_modules - /app/node_modules

View File

@ -27,7 +27,7 @@ interface RankResponse {
async function fetchProfile() { async function fetchProfile() {
const token = await getDemoToken(); const token = await getDemoToken();
const profile = await apiFetch<ProfileResponse>('/api/me', { authToken: token }); const profile = await apiFetch<ProfileResponse>('/api/me', { authToken: token });
const ranks = await apiFetch<RankResponse[]>('/api/admin/ranks', { authToken: token }); const ranks = await apiFetch<RankResponse[]>('/api/ranks', { authToken: token });
const currentRank = ranks.find((rank) => rank.id === profile.current_rank_id); const currentRank = ranks.find((rank) => rank.id === profile.current_rank_id);
return { token, profile, currentRank }; return { token, profile, currentRank };
} }

View File

@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.append(str(ROOT / 'backend')) sys.path.append(str(ROOT / 'backend'))
from app.core.config import settings
from app.core.security import get_password_hash from app.core.security import get_password_hash
from app.db.session import SessionLocal, engine from app.db.session import SessionLocal, engine
from app.models.artifact import Artifact, ArtifactRarity from app.models.artifact import Artifact, ArtifactRarity
@ -20,7 +21,7 @@ from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirem
from app.models.store import StoreItem from app.models.store import StoreItem
from app.models.user import Competency, CompetencyCategory, User, UserCompetency, UserRole from app.models.user import Competency, CompetencyCategory, User, UserCompetency, UserRole
DATA_SENTINEL = Path("/data/.seeded") DATA_SENTINEL = settings.sqlite_path.parent / ".seeded"
def ensure_database() -> None: def ensure_database() -> None: