From 8b17c5d3c4ef807edee4faa37bd3a33e0bfd3659 Mon Sep 17 00:00:00 2001 From: Aleksey Razorvin <> Date: Sat, 21 Mar 2026 12:05:04 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=201=20=E2=80=94=20infrastructure?= =?UTF-8?q?=20+=20test=20creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 2 + DOC/ШАГИ/ШАГ_2026-03-21_006.md | 145 ++++++++++++ backend/Dockerfile | 16 ++ backend/alembic.ini | 39 ++++ backend/alembic/env.py | 57 +++++ backend/alembic/script.py.mako | 25 +++ backend/alembic/versions/001_init.py | 62 ++++++ backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/tests.py | 77 +++++++ backend/app/config.py | 11 + backend/app/database.py | 17 ++ backend/app/main.py | 20 ++ backend/app/models/__init__.py | 0 backend/app/models/test.py | 54 +++++ backend/app/schemas/__init__.py | 0 backend/app/schemas/test.py | 85 +++++++ backend/entrypoint.sh | 13 ++ backend/requirements.txt | 7 + docker-compose.yml | 46 ++++ frontend/Dockerfile | 12 + frontend/index.html | 12 + frontend/package.json | 26 +++ frontend/src/App.tsx | 26 +++ frontend/src/api/client.ts | 7 + frontend/src/api/tests.ts | 64 ++++++ frontend/src/index.css | 8 + frontend/src/main.tsx | 10 + frontend/src/pages/TestCreate/index.tsx | 281 ++++++++++++++++++++++++ frontend/src/pages/TestDetail/index.tsx | 89 ++++++++ frontend/src/pages/TestList/index.tsx | 98 +++++++++ frontend/tsconfig.json | 21 ++ frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 10 + nginx/nginx.conf | 20 ++ 35 files changed, 1370 insertions(+) create mode 100644 .env.example create mode 100644 DOC/ШАГИ/ШАГ_2026-03-21_006.md create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/001_init.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/tests.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/test.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/test.py create mode 100644 backend/entrypoint.sh create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/tests.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/TestCreate/index.tsx create mode 100644 frontend/src/pages/TestDetail/index.tsx create mode 100644 frontend/src/pages/TestList/index.tsx create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 nginx/nginx.conf diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9a9c9f7 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# База данных +DATABASE_URL=postgresql+asyncpg://qa_user:qa_password@db:5432/qa_test diff --git a/DOC/ШАГИ/ШАГ_2026-03-21_006.md b/DOC/ШАГИ/ШАГ_2026-03-21_006.md new file mode 100644 index 0000000..b94a64a --- /dev/null +++ b/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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..468f854 --- /dev/null +++ b/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"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..307c10b --- /dev/null +++ b/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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..3cfaac7 --- /dev/null +++ b/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()) diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..17dcba0 --- /dev/null +++ b/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"} diff --git a/backend/alembic/versions/001_init.py b/backend/alembic/versions/001_init.py new file mode 100644 index 0000000..0a44f28 --- /dev/null +++ b/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") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/tests.py b/backend/app/api/tests.py new file mode 100644 index 0000000..7ae2358 --- /dev/null +++ b/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() diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..b9da9e1 --- /dev/null +++ b/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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..5a317ff --- /dev/null +++ b/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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..3431823 --- /dev/null +++ b/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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/test.py b/backend/app/models/test.py new file mode 100644 index 0000000..259fec8 --- /dev/null +++ b/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") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/test.py b/backend/app/schemas/test.py new file mode 100644 index 0000000..fef4ed8 --- /dev/null +++ b/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} diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..bcd81e2 --- /dev/null +++ b/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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..c68a4aa --- /dev/null +++ b/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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0fbb417 --- /dev/null +++ b/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: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..546a9de --- /dev/null +++ b/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"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..670fdad --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + QA Test App + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f84360c --- /dev/null +++ b/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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..93c0af4 --- /dev/null +++ b/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 ( + + + + + } /> + } /> + } /> + + + + + ) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..b0eac4f --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,7 @@ +import axios from 'axios' + +const client = axios.create({ + baseURL: '/api', +}) + +export default client diff --git a/frontend/src/api/tests.ts b/frontend/src/api/tests.ts new file mode 100644 index 0000000..d32399e --- /dev/null +++ b/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('/tests'), + get: (id: number) => client.get(`/tests/${id}`), + create: (data: CreateTestDto) => client.post('/tests', data), +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..8fea030 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,8 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: #f5f5f5; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..964aeb4 --- /dev/null +++ b/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( + + + , +) diff --git a/frontend/src/pages/TestCreate/index.tsx b/frontend/src/pages/TestCreate/index.tsx new file mode 100644 index 0000000..1bb11c2 --- /dev/null +++ b/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 ( +
+ Создание теста + +
+ {/* ── Основные настройки ── */} + + + + + + + + + + + + + + {/* Таймер: переключатель + поле минут */} + + + + + + prev.has_timer !== cur.has_timer} + > + {({ getFieldValue }) => + getFieldValue('has_timer') ? ( + + + + ) : ( + без ограничения + ) + } + + + + + + + + + + {/* ── Вопросы ── */} + { + if (!questions || questions.length < 7) { + return Promise.reject(new Error('Минимум 7 вопросов')) + } + }, + }, + ]} + > + {(questionFields, { add: addQuestion, remove: removeQuestion }, { errors }) => ( + <> + {questionFields.map(({ key, name: qName }, index) => ( + 7 ? ( + removeQuestion(qName)} + /> + ) : null + } + style={{ marginBottom: 16 }} + > + + + + + {/* ── Варианты ответов ── */} + { + 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 }) => ( + + {/* Чекбокс «правильный» */} + + + + + {/* Текст ответа */} + + + + + {answerFields.length > 3 && ( + removeAnswer(aName)} + /> + )} + + ))} + + + + + + )} + + + ))} + + + + + + )} + + + + + + + + +
+
+ ) +} diff --git a/frontend/src/pages/TestDetail/index.tsx b/frontend/src/pages/TestDetail/index.tsx new file mode 100644 index 0000000..e69aae6 --- /dev/null +++ b/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 + } + + if (!test) return null + + return ( +
+ + + + + {test.title} + + {test.description && ( + + {test.description} + + )} + + + + {test.questions.length} + {test.passing_score}% + + {test.time_limit ? `${test.time_limit} мин` : 'Без ограничений'} + + + {test.allow_navigation_back ? ( + Разрешён + ) : ( + Запрещён + )} + + {test.version} + + {new Date(test.created_at).toLocaleDateString('ru-RU')} + + + + + Вопросы ({test.questions.length}) + + {test.questions.map((question, index) => ( + + + {index + 1}. {question.text} + + ( + + + {answer.is_correct ? ( + + ) : ( + + )} + {answer.text} + + + )} + /> + + ))} +
+ ) +} diff --git a/frontend/src/pages/TestList/index.tsx b/frontend/src/pages/TestList/index.tsx new file mode 100644 index 0000000..086e189 --- /dev/null +++ b/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 = [ + { + title: 'Название', + dataIndex: 'title', + key: 'title', + render: (text: string, record: TestListItem) => ( + navigate(`/tests/${record.id}`)}>{text} + ), + }, + { + 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) => ( + + ), + }, + ] + + if (isLoading) { + return + } + + return ( +
+ + + Тесты + + + + + + + ) +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..3934b8f --- /dev/null +++ b/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" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..e5197df --- /dev/null +++ b/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, + }, +}) diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..eb50e14 --- /dev/null +++ b/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; + } +}