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 @@ + + +
+ + +