Browse Source

feat: Sprint 1 — infrastructure + test creation

Backend:
- FastAPI + SQLAlchemy 2.0 async + Alembic
- Models: Test, Question, Answer
- API: GET /api/tests, GET /api/tests/{id}, POST /api/tests
- Pydantic validation: min 7 questions, min 3 answers, ≥1 correct

Frontend:
- React 18 + TypeScript + Vite + Ant Design + TanStack Query
- Pages: TestList, TestCreate (nested Form.List), TestDetail

Infrastructure:
- Docker Compose: db (postgres:16), backend, frontend, nginx
- Nginx: /api/ → FastAPI, / → Vite dev server with HMR
- Alembic migration 001_init: tests, questions, answers tables
- entrypoint.sh: wait for db, migrate, start uvicorn

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
Aleksey Razorvin 1 week ago
parent
commit
8b17c5d3c4
  1. 2
      .env.example
  2. 145
      DOC/ШАГИ/ШАГ_2026-03-21_006.md
  3. 16
      backend/Dockerfile
  4. 39
      backend/alembic.ini
  5. 57
      backend/alembic/env.py
  6. 25
      backend/alembic/script.py.mako
  7. 62
      backend/alembic/versions/001_init.py
  8. 0
      backend/app/__init__.py
  9. 0
      backend/app/api/__init__.py
  10. 77
      backend/app/api/tests.py
  11. 11
      backend/app/config.py
  12. 17
      backend/app/database.py
  13. 20
      backend/app/main.py
  14. 0
      backend/app/models/__init__.py
  15. 54
      backend/app/models/test.py
  16. 0
      backend/app/schemas/__init__.py
  17. 85
      backend/app/schemas/test.py
  18. 13
      backend/entrypoint.sh
  19. 7
      backend/requirements.txt
  20. 46
      docker-compose.yml
  21. 12
      frontend/Dockerfile
  22. 12
      frontend/index.html
  23. 26
      frontend/package.json
  24. 26
      frontend/src/App.tsx
  25. 7
      frontend/src/api/client.ts
  26. 64
      frontend/src/api/tests.ts
  27. 8
      frontend/src/index.css
  28. 10
      frontend/src/main.tsx
  29. 281
      frontend/src/pages/TestCreate/index.tsx
  30. 89
      frontend/src/pages/TestDetail/index.tsx
  31. 98
      frontend/src/pages/TestList/index.tsx
  32. 21
      frontend/tsconfig.json
  33. 10
      frontend/tsconfig.node.json
  34. 10
      frontend/vite.config.ts
  35. 20
      nginx/nginx.conf

2
.env.example

@ -0,0 +1,2 @@
# База данных
DATABASE_URL=postgresql+asyncpg://qa_user:qa_password@db:5432/qa_test

145
DOC/ШАГИ/ШАГ_2026-03-21_006.md

@ -0,0 +1,145 @@
# ШАГ 006 — Спринт 1: Инфраструктура + Создание тестов
**Дата:** 2026-03-21
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
---
## Запрос
> готов. поехали (начинаем Спринт 1)
---
## Ответ
Реализован Спринт 1 в полном объёме: инфраструктура + создание тестов без авторизации.
---
## Структура проекта после спринта
```
qa_test_app/
├── docker-compose.yml ← поднимает весь стек одной командой
├── .env.example
├── nginx/
│ └── nginx.conf ← / → frontend, /api/ → backend
├── backend/
│ ├── Dockerfile
│ ├── entrypoint.sh ← ждёт БД, запускает миграции, стартует сервер
│ ├── requirements.txt
│ ├── alembic.ini
│ ├── alembic/
│ │ ├── env.py ← async alembic, берёт URL из settings
│ │ ├── script.py.mako
│ │ └── versions/
│ │ └── 001_init.py ← создаёт таблицы tests, questions, answers
│ └── app/
│ ├── config.py ← настройки через pydantic-settings
│ ├── database.py ← async SQLAlchemy engine + session
│ ├── main.py ← FastAPI app, /api/health
│ ├── models/test.py ← ORM модели: Test, Question, Answer
│ ├── schemas/test.py ← Pydantic схемы с валидацией
│ └── api/tests.py ← REST эндпоинты
└── frontend/
├── Dockerfile
├── package.json
├── vite.config.ts
├── index.html
└── src/
├── App.tsx ← роутер + провайдеры
├── api/
│ ├── client.ts ← axios с baseURL=/api
│ └── tests.ts ← типы + функции запросов
└── pages/
├── TestList/ ← список тестов + кнопка создать
├── TestCreate/ ← форма создания теста
└── TestDetail/ ← просмотр теста с вопросами
```
---
## API эндпоинты
| Метод | URL | Описание |
|-------|-----|----------|
| GET | `/api/health` | Проверка работы сервера |
| GET | `/api/tests` | Список тестов |
| GET | `/api/tests/{id}` | Детали теста с вопросами и ответами |
| POST | `/api/tests` | Создать тест |
---
## Схема БД
```
tests
id, title, description, passing_score, time_limit,
allow_navigation_back, is_active, version, created_at
questions
id, test_id → tests.id, text, order
answers
id, question_id → questions.id, text, is_correct
```
---
## Валидация
**Backend (Pydantic):**
- Тест: минимум 7 вопросов, passing_score 0–100
- Вопрос: минимум 3 варианта ответа, хотя бы 1 правильный
**Frontend (Ant Design Form):**
- Те же правила воспроизведены на клиенте
- Nested Form.List для динамических вопросов и ответов
- Таймер показывается только при включённом переключателе (shouldUpdate)
---
## Как запустить
```bash
docker compose up --build
```
Открыть браузер: `http://localhost`
- Список тестов → кнопка «Создать тест»
- Заполнить форму → нажать «Создать тест»
- Перейти к созданному тесту и увидеть все вопросы и ответы
- `http://localhost/api/health``{"status": "ok"}`
- `http://localhost/api/docs` → Swagger UI FastAPI
---
## Ключевые решения для джуниора
**Async SQLAlchemy 2.0:**
- `async_sessionmaker` + `AsyncSession` — не блокируем event loop при запросах к БД
- `selectinload` для жадной загрузки связей (вместо N+1 запросов)
**Alembic async:**
- `async_engine_from_config` + `connection.run_sync(do_run_migrations)`
- URL берётся из `app.config.settings` — одно место для конфига
**Docker Compose healthcheck:**
- `db` объявляет `healthcheck`, `backend` ждёт `condition: service_healthy`
- Дополнительно `entrypoint.sh` вызывает `pg_isready` для надёжности
**TanStack Query:**
- `useQuery` для чтения данных — кэш, loading state, error state из коробки
- `useMutation` для создания — `invalidateQueries` обновляет список после успеха
---
## Следующие шаги
- [x] Спринт 1: Инфраструктура + Создание тестов
- [ ] Спринт 2: Прохождение теста + результаты
- [ ] Спринт 3: Трекер результатов
- [ ] Спринт 4: Авторизация и роли
- [ ] Спринт 5: Уведомления в MAX

16
backend/Dockerfile

@ -0,0 +1,16 @@
FROM python:3.12-slim
# pg_isready нужен для проверки готовности БД в entrypoint
RUN apt-get update && apt-get install -y --no-install-recommends postgresql-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chmod +x entrypoint.sh
CMD ["./entrypoint.sh"]

39
backend/alembic.ini

@ -0,0 +1,39 @@
[alembic]
script_location = alembic
# URL переопределяется в alembic/env.py из переменной окружения DATABASE_URL
sqlalchemy.url = driver://user:pass@localhost/dbname
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

57
backend/alembic/env.py

@ -0,0 +1,57 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Alembic config
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Берём DATABASE_URL из настроек приложения
from app.config import settings
from app.database import Base
from app.models import test # noqa: F401 — импортируем модели, чтобы Alembic их видел
config.set_main_option("sqlalchemy.url", settings.database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
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 do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

25
backend/alembic/script.py.mako

@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

62
backend/alembic/versions/001_init.py

@ -0,0 +1,62 @@
"""init
Revision ID: 001
Revises:
Create Date: 2026-03-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"tests",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("passing_score", sa.Integer(), nullable=False),
sa.Column("time_limit", sa.Integer(), nullable=True),
sa.Column("allow_navigation_back", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"questions",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("test_id", sa.Integer(), sa.ForeignKey("tests.id"), nullable=False),
sa.Column("text", sa.Text(), nullable=False),
sa.Column("order", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"answers",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column(
"question_id", sa.Integer(), sa.ForeignKey("questions.id"), nullable=False
),
sa.Column("text", sa.Text(), nullable=False),
sa.Column("is_correct", sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("answers")
op.drop_table("questions")
op.drop_table("tests")

0
backend/app/__init__.py

0
backend/app/api/__init__.py

77
backend/app/api/tests.py

@ -0,0 +1,77 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.test import Answer, Question, Test
from app.schemas.test import TestCreate, TestListItem, TestOut
router = APIRouter(prefix="/api/tests", tags=["tests"])
@router.get("", response_model=list[TestListItem])
async def list_tests(db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Test)
.options(selectinload(Test.questions))
.where(Test.is_active == True)
.order_by(Test.created_at.desc())
)
tests = result.scalars().all()
items = []
for test in tests:
item = TestListItem.model_validate(test)
item.questions_count = len(test.questions)
items.append(item)
return items
@router.get("/{test_id}", response_model=TestOut)
async def get_test(test_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Test)
.options(selectinload(Test.questions).selectinload(Question.answers))
.where(Test.id == test_id, Test.is_active == True)
)
test = result.scalar_one_or_none()
if not test:
raise HTTPException(status_code=404, detail="Тест не найден")
return test
@router.post("", response_model=TestOut, status_code=201)
async def create_test(data: TestCreate, db: AsyncSession = Depends(get_db)):
test = Test(
title=data.title,
description=data.description,
passing_score=data.passing_score,
time_limit=data.time_limit,
allow_navigation_back=data.allow_navigation_back,
)
db.add(test)
await db.flush()
for order, q_data in enumerate(data.questions):
question = Question(test_id=test.id, text=q_data.text, order=order)
db.add(question)
await db.flush()
for a_data in q_data.answers:
db.add(Answer(
question_id=question.id,
text=a_data.text,
is_correct=a_data.is_correct,
))
await db.commit()
# Перезагружаем с вложенными связями
result = await db.execute(
select(Test)
.options(selectinload(Test.questions).selectinload(Question.answers))
.where(Test.id == test.id)
)
return result.scalar_one()

11
backend/app/config.py

@ -0,0 +1,11 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://qa_user:qa_password@db:5432/qa_test"
class Config:
env_file = ".env"
settings = Settings()

17
backend/app/database.py

@ -0,0 +1,17 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.database_url, echo=True)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
yield session

20
backend/app/main.py

@ -0,0 +1,20 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import tests
app = FastAPI(title="QA Test App API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(tests.router)
@app.get("/api/health")
async def health():
return {"status": "ok"}

0
backend/app/models/__init__.py

54
backend/app/models/test.py

@ -0,0 +1,54 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
class Test(Base):
__tablename__ = "tests"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
passing_score: Mapped[int] = mapped_column(Integer, nullable=False)
time_limit: Mapped[int | None] = mapped_column(Integer, nullable=True) # минуты
allow_navigation_back: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
questions: Mapped[list["Question"]] = relationship(
"Question", back_populates="test", cascade="all, delete-orphan"
)
class Question(Base):
__tablename__ = "questions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False)
text: Mapped[str] = mapped_column(Text, nullable=False)
order: Mapped[int] = mapped_column(Integer, nullable=False)
test: Mapped["Test"] = relationship("Test", back_populates="questions")
answers: Mapped[list["Answer"]] = relationship(
"Answer", back_populates="question", cascade="all, delete-orphan"
)
class Answer(Base):
__tablename__ = "answers"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
question_id: Mapped[int] = mapped_column(
Integer, ForeignKey("questions.id"), nullable=False
)
text: Mapped[str] = mapped_column(Text, nullable=False)
is_correct: Mapped[bool] = mapped_column(Boolean, nullable=False)
question: Mapped["Question"] = relationship("Question", back_populates="answers")

0
backend/app/schemas/__init__.py

85
backend/app/schemas/test.py

@ -0,0 +1,85 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, field_validator
class AnswerCreate(BaseModel):
text: str = Field(min_length=1)
is_correct: bool
class AnswerOut(BaseModel):
id: int
text: str
is_correct: bool
model_config = {"from_attributes": True}
class QuestionCreate(BaseModel):
text: str = Field(min_length=1)
answers: list[AnswerCreate]
@field_validator("answers")
@classmethod
def validate_answers(cls, v: list[AnswerCreate]) -> list[AnswerCreate]:
if len(v) < 3:
raise ValueError("Минимум 3 варианта ответа на вопрос")
if not any(a.is_correct for a in v):
raise ValueError("Хотя бы один ответ должен быть правильным")
return v
class QuestionOut(BaseModel):
id: int
text: str
order: int
answers: list[AnswerOut]
model_config = {"from_attributes": True}
class TestCreate(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: Optional[str] = None
passing_score: int = Field(ge=0, le=100)
time_limit: Optional[int] = Field(None, ge=1)
allow_navigation_back: bool = True
questions: list[QuestionCreate]
@field_validator("questions")
@classmethod
def validate_questions(cls, v: list[QuestionCreate]) -> list[QuestionCreate]:
if len(v) < 7:
raise ValueError("Минимум 7 вопросов в тесте")
return v
class TestOut(BaseModel):
id: int
title: str
description: Optional[str]
passing_score: int
time_limit: Optional[int]
allow_navigation_back: bool
is_active: bool
version: int
created_at: datetime
questions: list[QuestionOut] = []
model_config = {"from_attributes": True}
class TestListItem(BaseModel):
id: int
title: str
description: Optional[str]
passing_score: int
time_limit: Optional[int]
is_active: bool
version: int
created_at: datetime
questions_count: int = 0
model_config = {"from_attributes": True}

13
backend/entrypoint.sh

@ -0,0 +1,13 @@
#!/bin/bash
set -e
echo "Waiting for PostgreSQL..."
until pg_isready -h db -p 5432 -U qa_user; do
sleep 1
done
echo "Running migrations..."
alembic upgrade head
echo "Starting server..."
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

7
backend/requirements.txt

@ -0,0 +1,7 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy==2.0.35
asyncpg==0.29.0
alembic==1.13.3
pydantic==2.9.2
pydantic-settings==2.5.2

46
docker-compose.yml

@ -0,0 +1,46 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: qa_test
POSTGRES_USER: qa_user
POSTGRES_PASSWORD: qa_password
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U qa_user -d qa_test"]
interval: 5s
timeout: 5s
retries: 10
backend:
build: ./backend
environment:
DATABASE_URL: postgresql+asyncpg://qa_user:qa_password@db:5432/qa_test
depends_on:
db:
condition: service_healthy
volumes:
- ./backend:/app
frontend:
build: ./frontend
volumes:
- ./frontend/src:/app/src
- ./frontend/index.html:/app/index.html
depends_on:
- backend
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- backend
- frontend
volumes:
postgres_data:

12
frontend/Dockerfile

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

12
frontend/index.html

@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>QA Test App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

26
frontend/package.json

@ -0,0 +1,26 @@
{
"name": "qa-test-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.4.0",
"@tanstack/react-query": "^5.59.0",
"antd": "^5.21.0",
"axios": "^1.7.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0"
},
"devDependencies": {
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.6.3",
"vite": "^5.4.8"
}
}

26
frontend/src/App.tsx

@ -0,0 +1,26 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ConfigProvider } from 'antd'
import ruRU from 'antd/locale/ru_RU'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import TestCreate from './pages/TestCreate'
import TestDetail from './pages/TestDetail'
import TestList from './pages/TestList'
const queryClient = new QueryClient()
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<ConfigProvider locale={ruRU}>
<BrowserRouter>
<Routes>
<Route path="/" element={<TestList />} />
<Route path="/tests/create" element={<TestCreate />} />
<Route path="/tests/:id" element={<TestDetail />} />
</Routes>
</BrowserRouter>
</ConfigProvider>
</QueryClientProvider>
)
}

7
frontend/src/api/client.ts

@ -0,0 +1,7 @@
import axios from 'axios'
const client = axios.create({
baseURL: '/api',
})
export default client

64
frontend/src/api/tests.ts

@ -0,0 +1,64 @@
import client from './client'
export interface Answer {
id: number
text: string
is_correct: boolean
}
export interface Question {
id: number
text: string
order: number
answers: Answer[]
}
export interface Test {
id: number
title: string
description: string | null
passing_score: number
time_limit: number | null
allow_navigation_back: boolean
is_active: boolean
version: number
created_at: string
questions: Question[]
}
export interface TestListItem {
id: number
title: string
description: string | null
passing_score: number
time_limit: number | null
is_active: boolean
version: number
created_at: string
questions_count: number
}
export interface CreateAnswerDto {
text: string
is_correct: boolean
}
export interface CreateQuestionDto {
text: string
answers: CreateAnswerDto[]
}
export interface CreateTestDto {
title: string
description?: string
passing_score: number
time_limit?: number
allow_navigation_back: boolean
questions: CreateQuestionDto[]
}
export const testsApi = {
list: () => client.get<TestListItem[]>('/tests'),
get: (id: number) => client.get<Test>(`/tests/${id}`),
create: (data: CreateTestDto) => client.post<Test>('/tests', data),
}

8
frontend/src/index.css

@ -0,0 +1,8 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f5f5f5;
}

10
frontend/src/main.tsx

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

281
frontend/src/pages/TestCreate/index.tsx

@ -0,0 +1,281 @@
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import {
Button,
Card,
Checkbox,
Form,
Input,
InputNumber,
Space,
Switch,
Typography,
message,
} from 'antd'
import { useNavigate } from 'react-router-dom'
import { CreateTestDto, testsApi } from '../../api/tests'
const { Title } = Typography
// Начальные данные: 7 пустых вопросов с 3 вариантами ответов каждый
const EMPTY_ANSWER = { text: '', is_correct: false }
const EMPTY_QUESTION = {
text: '',
answers: [EMPTY_ANSWER, EMPTY_ANSWER, EMPTY_ANSWER],
}
const INITIAL_QUESTIONS = Array(7).fill(null).map(() => ({ ...EMPTY_QUESTION }))
export default function TestCreate() {
const [form] = Form.useForm()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { mutate: createTest, isPending } = useMutation({
mutationFn: (data: CreateTestDto) => testsApi.create(data).then((r) => r.data),
onSuccess: (test) => {
queryClient.invalidateQueries({ queryKey: ['tests'] })
message.success('Тест успешно создан')
navigate(`/tests/${test.id}`)
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { detail?: string } } }
message.error(err.response?.data?.detail || 'Ошибка при создании теста')
},
})
const onFinish = (values: {
title: string
description?: string
passing_score: number
has_timer: boolean
time_limit?: number
allow_navigation_back: boolean
questions: { text: string; answers: { text: string; is_correct: boolean }[] }[]
}) => {
createTest({
title: values.title,
description: values.description,
passing_score: values.passing_score,
time_limit: values.has_timer ? values.time_limit : undefined,
allow_navigation_back: values.allow_navigation_back ?? true,
questions: values.questions,
})
}
return (
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}>
<Title level={2}>Создание теста</Title>
<Form
form={form}
layout="vertical"
onFinish={onFinish}
initialValues={{
allow_navigation_back: true,
has_timer: false,
passing_score: 70,
questions: INITIAL_QUESTIONS,
}}
>
{/* ── Основные настройки ── */}
<Card title="Основные настройки" style={{ marginBottom: 16 }}>
<Form.Item
name="title"
label="Название теста"
rules={[{ required: true, message: 'Введите название теста' }]}
>
<Input placeholder="Например: Пожарная безопасность 2026" />
</Form.Item>
<Form.Item name="description" label="Описание (необязательно)">
<Input.TextArea rows={2} placeholder="Краткое описание теста" />
</Form.Item>
<Form.Item
name="passing_score"
label="Порог зачёта"
rules={[{ required: true, message: 'Укажите порог' }]}
>
<InputNumber min={0} max={100} addonAfter="%" style={{ width: 140 }} />
</Form.Item>
{/* Таймер: переключатель + поле минут */}
<Form.Item label="Ограничение по времени">
<Space align="center">
<Form.Item name="has_timer" valuePropName="checked" noStyle>
<Switch />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, cur) => prev.has_timer !== cur.has_timer}
>
{({ getFieldValue }) =>
getFieldValue('has_timer') ? (
<Form.Item
name="time_limit"
noStyle
rules={[{ required: true, message: 'Укажите время' }]}
>
<InputNumber min={1} addonAfter="мин" style={{ width: 150 }} />
</Form.Item>
) : (
<span style={{ color: '#999' }}>без ограничения</span>
)
}
</Form.Item>
</Space>
</Form.Item>
<Form.Item
name="allow_navigation_back"
label="Разрешить возврат к предыдущему вопросу"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Card>
{/* ── Вопросы ── */}
<Form.List
name="questions"
rules={[
{
validator: async (_, questions) => {
if (!questions || questions.length < 7) {
return Promise.reject(new Error('Минимум 7 вопросов'))
}
},
},
]}
>
{(questionFields, { add: addQuestion, remove: removeQuestion }, { errors }) => (
<>
{questionFields.map(({ key, name: qName }, index) => (
<Card
key={key}
title={`Вопрос ${index + 1}`}
extra={
questionFields.length > 7 ? (
<MinusCircleOutlined
style={{ color: '#ff4d4f', fontSize: 16 }}
onClick={() => removeQuestion(qName)}
/>
) : null
}
style={{ marginBottom: 16 }}
>
<Form.Item
name={[qName, 'text']}
rules={[{ required: true, message: 'Введите текст вопроса' }]}
>
<Input.TextArea rows={2} placeholder="Текст вопроса" />
</Form.Item>
{/* ── Варианты ответов ── */}
<Form.List
name={[qName, 'answers']}
rules={[
{
validator: async (_, answers) => {
if (!answers || answers.length < 3) {
return Promise.reject(new Error('Минимум 3 варианта ответа'))
}
if (!answers.some((a: { is_correct: boolean }) => a?.is_correct)) {
return Promise.reject(
new Error('Отметьте хотя бы один правильный ответ'),
)
}
},
},
]}
>
{(
answerFields,
{ add: addAnswer, remove: removeAnswer },
{ errors: answerErrors },
) => (
<>
{answerFields.map(({ key: ak, name: aName }) => (
<Space
key={ak}
style={{ display: 'flex', marginBottom: 8 }}
align="start"
>
{/* Чекбокс «правильный» */}
<Form.Item
name={[aName, 'is_correct']}
valuePropName="checked"
initialValue={false}
style={{ marginBottom: 0 }}
>
<Checkbox />
</Form.Item>
{/* Текст ответа */}
<Form.Item
name={[aName, 'text']}
rules={[{ required: true, message: 'Введите вариант ответа' }]}
style={{ marginBottom: 0, flex: 1 }}
>
<Input placeholder="Вариант ответа" style={{ width: 440 }} />
</Form.Item>
{answerFields.length > 3 && (
<MinusCircleOutlined
style={{ color: '#ff4d4f' }}
onClick={() => removeAnswer(aName)}
/>
)}
</Space>
))}
<Form.ErrorList errors={answerErrors} />
<Button
type="dashed"
size="small"
icon={<PlusOutlined />}
onClick={() => addAnswer({ text: '', is_correct: false })}
style={{ marginTop: 4 }}
>
Добавить вариант
</Button>
</>
)}
</Form.List>
</Card>
))}
<Form.ErrorList errors={errors} />
<Button
type="dashed"
block
icon={<PlusOutlined />}
style={{ marginBottom: 24 }}
onClick={() =>
addQuestion({
text: '',
answers: [EMPTY_ANSWER, EMPTY_ANSWER, EMPTY_ANSWER],
})
}
>
Добавить вопрос
</Button>
</>
)}
</Form.List>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={isPending}>
Создать тест
</Button>
<Button onClick={() => navigate('/')}>Отмена</Button>
</Space>
</Form.Item>
</Form>
</div>
)
}

89
frontend/src/pages/TestDetail/index.tsx

@ -0,0 +1,89 @@
import { ArrowLeftOutlined, CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import { Button, Card, Descriptions, List, Space, Spin, Tag, Typography } from 'antd'
import { useNavigate, useParams } from 'react-router-dom'
import { Answer, testsApi } from '../../api/tests'
const { Title, Text } = Typography
export default function TestDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { data: test, isLoading } = useQuery({
queryKey: ['tests', id],
queryFn: () => testsApi.get(Number(id)).then((r) => r.data),
})
if (isLoading) {
return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} />
}
if (!test) return null
return (
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}>
<Space style={{ marginBottom: 16 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/')}>
К списку тестов
</Button>
</Space>
<Title level={2}>{test.title}</Title>
{test.description && (
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
{test.description}
</Text>
)}
<Card style={{ marginBottom: 24 }}>
<Descriptions column={3}>
<Descriptions.Item label="Вопросов">{test.questions.length}</Descriptions.Item>
<Descriptions.Item label="Порог зачёта">{test.passing_score}%</Descriptions.Item>
<Descriptions.Item label="Таймер">
{test.time_limit ? `${test.time_limit} мин` : 'Без ограничений'}
</Descriptions.Item>
<Descriptions.Item label="Возврат к вопросу">
{test.allow_navigation_back ? (
<Tag color="green">Разрешён</Tag>
) : (
<Tag color="red">Запрещён</Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="Версия">{test.version}</Descriptions.Item>
<Descriptions.Item label="Создан">
{new Date(test.created_at).toLocaleDateString('ru-RU')}
</Descriptions.Item>
</Descriptions>
</Card>
<Title level={3}>Вопросы ({test.questions.length})</Title>
{test.questions.map((question, index) => (
<Card key={question.id} style={{ marginBottom: 12 }}>
<Text strong>
{index + 1}. {question.text}
</Text>
<List
style={{ marginTop: 10 }}
dataSource={question.answers}
renderItem={(answer: Answer) => (
<List.Item style={{ padding: '4px 0' }}>
<Space>
{answer.is_correct ? (
<CheckCircleTwoTone twoToneColor="#52c41a" />
) : (
<CloseCircleTwoTone twoToneColor="#d9d9d9" />
)}
<Text>{answer.text}</Text>
</Space>
</List.Item>
)}
/>
</Card>
))}
</div>
)
}

98
frontend/src/pages/TestList/index.tsx

@ -0,0 +1,98 @@
import { PlusOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import { Button, Space, Spin, Table, Tag, Typography } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useNavigate } from 'react-router-dom'
import { TestListItem, testsApi } from '../../api/tests'
const { Title } = Typography
export default function TestList() {
const navigate = useNavigate()
const { data: tests = [], isLoading } = useQuery({
queryKey: ['tests'],
queryFn: () => testsApi.list().then((r) => r.data),
})
const columns: ColumnsType<TestListItem> = [
{
title: 'Название',
dataIndex: 'title',
key: 'title',
render: (text: string, record: TestListItem) => (
<a onClick={() => navigate(`/tests/${record.id}`)}>{text}</a>
),
},
{
title: 'Вопросов',
dataIndex: 'questions_count',
key: 'questions_count',
width: 100,
align: 'center',
},
{
title: 'Порог зачёта',
dataIndex: 'passing_score',
key: 'passing_score',
width: 130,
align: 'center',
render: (score: number) => `${score}%`,
},
{
title: 'Таймер',
dataIndex: 'time_limit',
key: 'time_limit',
width: 110,
align: 'center',
render: (limit: number | null) => (limit ? `${limit} мин` : '—'),
},
{
title: 'Создан',
dataIndex: 'created_at',
key: 'created_at',
width: 130,
render: (date: string) => new Date(date).toLocaleDateString('ru-RU'),
},
{
title: '',
key: 'actions',
width: 90,
render: (_: unknown, record: TestListItem) => (
<Button size="small" onClick={() => navigate(`/tests/${record.id}`)}>
Открыть
</Button>
),
},
]
if (isLoading) {
return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} />
}
return (
<div style={{ maxWidth: 1000, margin: '0 auto', padding: 24 }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={2} style={{ margin: 0 }}>
Тесты
</Title>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/tests/create')}
>
Создать тест
</Button>
</Space>
<Table
dataSource={tests}
columns={columns}
rowKey="id"
locale={{ emptyText: 'Тестов пока нет. Создайте первый!' }}
pagination={{ pageSize: 20 }}
/>
</div>
)
}

21
frontend/tsconfig.json

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
frontend/tsconfig.node.json

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

10
frontend/vite.config.ts

@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
},
})

20
nginx/nginx.conf

@ -0,0 +1,20 @@
server {
listen 80;
# API запросы FastAPI backend
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Всё остальное Vite dev server поддержкой WebSocket для HMR)
location / {
proxy_pass http://frontend:5173;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
Loading…
Cancel
Save