Browse Source
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
35 changed files with 1370 additions and 0 deletions
@ -0,0 +1,2 @@
|
||||
# База данных |
||||
DATABASE_URL=postgresql+asyncpg://qa_user:qa_password@db:5432/qa_test |
||||
@ -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 |
||||
@ -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"] |
||||
@ -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 |
||||
@ -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()) |
||||
@ -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"} |
||||
@ -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,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() |
||||
@ -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() |
||||
@ -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 |
||||
@ -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,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,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} |
||||
@ -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 |
||||
@ -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 |
||||
@ -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: |
||||
@ -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"] |
||||
@ -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> |
||||
@ -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" |
||||
} |
||||
} |
||||
@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,7 @@
|
||||
import axios from 'axios' |
||||
|
||||
const client = axios.create({ |
||||
baseURL: '/api', |
||||
}) |
||||
|
||||
export default client |
||||
@ -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), |
||||
} |
||||
@ -0,0 +1,8 @@
|
||||
* { |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
body { |
||||
margin: 0; |
||||
background: #f5f5f5; |
||||
} |
||||
@ -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>, |
||||
) |
||||
@ -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> |
||||
) |
||||
} |
||||
@ -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> |
||||
) |
||||
} |
||||
@ -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> |
||||
) |
||||
} |
||||
@ -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" }] |
||||
} |
||||
@ -0,0 +1,10 @@
|
||||
{ |
||||
"compilerOptions": { |
||||
"composite": true, |
||||
"skipLibCheck": true, |
||||
"module": "ESNext", |
||||
"moduleResolution": "bundler", |
||||
"allowSyntheticDefaultImports": true |
||||
}, |
||||
"include": ["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, |
||||
}, |
||||
}) |
||||
@ -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…
Reference in new issue