From fc684e7c7d5d8129e2ff9b25ef29f4f784526252 Mon Sep 17 00:00:00 2001 From: Aleksey Razorvin <> Date: Sat, 21 Mar 2026 15:26:40 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BF=D1=80=D0=B8=D0=BD=D1=82=205:=20?= =?UTF-8?q?=D0=A2=D1=80=D0=B5=D0=BA=D0=B5=D1=80=20=D1=80=D0=B5=D0=B7=D1=83?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Миграция 005: user_id в test_attempts (дефолт 1 = Гость) - GET /api/attempts с фильтрами по тесту, дате и пагинацией - Страница /tracker: таблица попыток, фильтры, пагинация - Ссылка «Трекер» в шапке приложения Co-Authored-By: Claude Sonnet 4.6 --- DOC/ИТОГИ_2026-03-21.md | 118 +++++++++++++++ DOC/СПРИНТЫ.md | 17 ++- DOC/ШАГИ/ШАГ_2026-03-21_013.md | 72 +++++++++ README.md | 2 +- backend/alembic/versions/005_attempt_user.py | 31 ++++ backend/app/api/attempts.py | 69 ++++++++- backend/app/models/attempt.py | 1 + backend/app/schemas/attempt.py | 26 ++++ frontend/src/App.tsx | 30 ++-- frontend/src/api/attempts.ts | 33 +++++ frontend/src/pages/Tracker/index.tsx | 145 +++++++++++++++++++ 11 files changed, 522 insertions(+), 22 deletions(-) create mode 100644 DOC/ИТОГИ_2026-03-21.md create mode 100644 DOC/ШАГИ/ШАГ_2026-03-21_013.md create mode 100644 backend/alembic/versions/005_attempt_user.py create mode 100644 frontend/src/pages/Tracker/index.tsx diff --git a/DOC/ИТОГИ_2026-03-21.md b/DOC/ИТОГИ_2026-03-21.md new file mode 100644 index 0000000..77604f7 --- /dev/null +++ b/DOC/ИТОГИ_2026-03-21.md @@ -0,0 +1,118 @@ +# Итоги разработки — 21 марта 2026 + +## Общая информация + +| Параметр | Значение | +|----------|---------| +| Дата | 21 марта 2026 | +| Начало работы | 10:56 | +| Текущее состояние | Спринты 1–5 завершены | +| Затрачено времени | ~5 часов | +| Коммитов | 26 | + +--- + +## Хронология по коммитам + +| Время | Этап | +|-------|------| +| 10:56–11:41 | Подготовка: структура проекта, ТЗ v1.1, стек, план спринтов, README | +| 12:05–12:46 | **Спринт 1** — инфраструктура + создание тестов | +| 12:53–13:18 | **Спринт 2** — прохождение теста + результаты + UX | +| 13:22–14:08 | **Спринт 3** — редактирование + версионирование | +| 14:08–15:11 | **Спринт 4** — AI-помощник (DeepSeek) | +| 15:11–16:00 | **Спринт 5** — трекер результатов | + +--- + +## Что реализовано + +### Спринт 1 — Инфраструктура + создание тестов (~40 мин) +- Docker Compose: PostgreSQL 16, FastAPI, React + Vite, Nginx +- Alembic: миграция `001_init` (таблицы tests, questions, answers) +- API: `POST /api/tests`, `GET /api/tests`, `GET /api/tests/{id}` +- Фронт: создание теста, список тестов, просмотр теста +- Исправлено 4 бага при тестировании (entrypoint, PYTHONPATH, nginx DNS, FastAPI docs URL) + +### Спринт 2 — Прохождение теста + результаты (~25 мин) +- Миграция `002_attempts` (таблицы test_attempts, attempt_answers) +- API: `POST /api/attempts`, `POST /api/attempts/{id}/submit`, `GET /api/attempts/{id}/result` +- Прохождение: случайный порядок вопросов, таймер с автосабмитом, навигация назад +- Страница результатов: балл, зачёт/незачёт, разбор ошибок по каждому вопросу +- UX: разделение вида сотрудника (`/tests/:id`) и автора (`/tests/:id/edit`), выпадающее меню «⋯» в списке тестов + +### Спринт 3 — Редактирование + версионирование (~46 мин) +- Миграция `003_test_versioning` (поле `parent_id` в tests) +- API: `PUT /api/tests/{id}`, `GET /api/tests/{id}/versions`, `POST /api/tests/{id}/activate` +- Логика: нет попыток → редактировать на месте; есть попытки → создать новую версию (`version+1`, `parent_id=old.id`) +- Фронт: общий компонент `TestForm`, страница редактирования с историей версий и активацией +- Исправлено 2 бага (FK cascade при bulk DELETE, отображение статуса «Активная») + +### Спринт 4 — AI-помощник (DeepSeek) (~63 мин) +- Миграция `004_settings` (таблица settings, key-value) +- Страница `/settings`: ввод API ключа DeepSeek + кнопка «Проверить подключение» +- 6 AI-эндпоинтов: + - `POST /api/llm/check` — проверка подключения + - `POST /api/llm/generate` — генерация вопросов по теме (= название теста) + - `POST /api/llm/improve` — улучшение формулировки вопроса + ответов + - `POST /api/llm/distractors` — генерация дистракторов + - `POST /api/llm/review` — рецензия всего теста + - `POST /api/llm/improve_all` — улучшение всего теста целиком +- В форме теста: 4 AI-кнопки с модалами и постатейным сравнением (галочки для применения изменений) +- Шапка приложения с навигацией + +### Спринт 5 — Трекер результатов (~49 мин) +- Миграция `005_attempt_user` (поле `user_id` в test_attempts, дефолт 1 = «Гость») +- API: `GET /api/attempts` с фильтрами (test_id, date_from, date_to) и пагинацией +- Страница `/tracker`: таблица всех попыток, фильтр по тесту и диапазону дат, пагинация +- Колонки: Сотрудник / Тест + версия / Начало / Завершение / Результат / Зачёт + +--- + +## Объём кода + +| Слой | Файлы | +|------|-------| +| Backend — модели | `test.py`, `attempt.py`, `setting.py` | +| Backend — схемы | `test.py`, `attempt.py`, `setting.py` | +| Backend — API роутеры | `tests.py`, `attempts.py`, `llm.py`, `settings.py` | +| Backend — сервисы | `llm.py` | +| Backend — миграции | `001` → `005` | +| Frontend — страницы | TestList, TestCreate, TestDetail, TestEdit, TestTake, AttemptResult, Settings, Tracker | +| Frontend — компоненты | `TestForm` (с AI-функциями) | +| Frontend — API клиенты | `tests.ts`, `attempts.ts`, `llm.ts`, `settings.ts`, `client.ts` | +| Документация | ТЗ v1.2, СТЕК, СПРИНТЫ, README, 12 файлов ШАГИ | +| **Итого** | **~46 файлов** | + +--- + +## Текущий статус спринтов + +| Спринт | Содержание | Статус | +|--------|-----------|--------| +| 1 | Инфраструктура + создание тестов | ✅ Завершён | +| 2 | Прохождение теста + результаты | ✅ Завершён | +| 3 | Редактирование + версионирование | ✅ Завершён | +| 4 | AI-помощник (DeepSeek) | ✅ Завершён | +| 5 | Трекер результатов | ✅ Завершён | +| 6 | Авторизация, роли, подразделения | ⬜ Следующий | +| 7 | Уведомления в MAX | ⬜ Запланирован | + +--- + +## Технический долг перед Спринтом 6 + +- `user_id = 1` (Гость) в `test_attempts` — заменить на ID авторизованного пользователя +- `GUEST_USER_NAME = "Гость"` в `api/attempts.py` — заменить на JOIN с таблицей `users` +- Все эндпоинты открыты без авторизации — добавить JWT Middleware + +--- + +## Запуск проекта + +```bash +docker compose up --build +``` + +Приложение: `http://localhost` +API документация: `http://localhost/api/docs` diff --git a/DOC/СПРИНТЫ.md b/DOC/СПРИНТЫ.md index 68adb5d..220c636 100644 --- a/DOC/СПРИНТЫ.md +++ b/DOC/СПРИНТЫ.md @@ -140,15 +140,20 @@ --- -## Спринт 5 — Трекер результатов +## Спринт 5 — Трекер результатов ✅ **Результат:** Таблица всех попыток прохождения тестов. +**Статус:** Завершён и протестирован вручную в браузере. + +- [x] Миграция `005`: поле `user_id` в `test_attempts` (дефолт 1 = «Гость») +- [x] API: `GET /api/attempts` — все попытки (с фильтрами по тесту, дате, пагинацией) +- [x] Фронт: страница `/tracker` + - Таблица: сотрудник, тест + версия, дата начала, дата завершения, результат, зачёт + - Фильтрация по тесту и диапазону дат + - Пагинация (20 записей на страницу) +- [x] Ссылка «Трекер» в шапке приложения -- [ ] API: `GET /api/attempts` — все попытки (с фильтрами по тесту, дате) -- [ ] Фронт: страница трекера - - Таблица: тест, версия, дата начала, дата завершения, результат, зачёт - - Фильтрация по тесту и дате - - Пагинация +**Примечание:** `user_id = 1` («Гость») — временно до Спринта 6 (авторизация). --- diff --git a/DOC/ШАГИ/ШАГ_2026-03-21_013.md b/DOC/ШАГИ/ШАГ_2026-03-21_013.md new file mode 100644 index 0000000..7aa2657 --- /dev/null +++ b/DOC/ШАГИ/ШАГ_2026-03-21_013.md @@ -0,0 +1,72 @@ +# ШАГ 013 — Спринт 5: Трекер результатов + +**Дата:** 2026-03-21 +**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники. + +--- + +## Запрос + +> Оставляем текущий спринт. Введём user_id здесь вручную — пусть это будет гость. + +--- + +## Реализовано + +Страница трекера всех попыток с фильтрацией и пагинацией. Все попытки пока принадлежат одному пользователю — «Гость» (user_id = 1). + +--- + +## Новые файлы + +``` +backend/alembic/versions/005_attempt_user.py ← добавляет user_id в test_attempts +frontend/src/pages/Tracker/index.tsx ← страница /tracker +``` + +## Изменённые файлы + +``` +backend/app/models/attempt.py ← поле user_id (default=1, server_default='1') +backend/app/schemas/attempt.py ← AttemptListItem, AttemptListResponse +backend/app/api/attempts.py ← GET /api/attempts + GUEST_USER_ID/NAME константы +frontend/src/api/attempts.ts ← AttemptListItem, AttemptListResponse, list() +frontend/src/App.tsx ← роут /tracker, кнопка «Трекер» в шапке +``` + +--- + +## API эндпоинты (новые) + +| Метод | URL | Параметры | Описание | +|-------|-----|-----------|----------| +| GET | `/api/attempts` | test_id, date_from, date_to, page, page_size | Список завершённых попыток | + +--- + +## Схема БД (изменено) + +``` +test_attempts + + user_id INTEGER NOT NULL DEFAULT 1 +``` + +--- + +## Технический долг (Sprint 6) + +- `user_id = 1` → заменить на ID из JWT токена +- `GUEST_USER_NAME` → JOIN с таблицей users +- Все эндпоинты открыты → добавить JWT Middleware + +--- + +## Следующие шаги + +- [x] Спринт 1: Инфраструктура + Создание тестов +- [x] Спринт 2: Прохождение теста + результат +- [x] Спринт 3: Редактирование + версионность +- [x] Спринт 4: AI-помощник (DeepSeek) +- [x] Спринт 5: Трекер результатов +- [ ] Спринт 6: Авторизация и роли +- [ ] Спринт 7: Уведомления в MAX diff --git a/README.md b/README.md index c263261..3d21833 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ | **2** | Прохождение теста + результаты и разбор ошибок | ✅ | | **3** | Редактирование тестов + версионность | ✅ | | **4** | AI-помощник при создании/редактировании тестов (DeepSeek) | ✅ | -| **5** | Трекер результатов | ⬜ | +| **5** | Трекер результатов | ✅ | | **6** | Авторизация, роли, подразделения, управление пользователями | ⬜ | | **7** | Уведомления в MAX | ⬜ | diff --git a/backend/alembic/versions/005_attempt_user.py b/backend/alembic/versions/005_attempt_user.py new file mode 100644 index 0000000..21db857 --- /dev/null +++ b/backend/alembic/versions/005_attempt_user.py @@ -0,0 +1,31 @@ +"""005_attempt_user + +Revision ID: 005 +Revises: 004 +Create Date: 2026-03-21 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "005" +down_revision: Union[str, None] = "004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "test_attempts", + sa.Column( + "user_id", + sa.Integer(), + nullable=False, + server_default="1", + ), + ) + + +def downgrade() -> None: + op.drop_column("test_attempts", "user_id") diff --git a/backend/app/api/attempts.py b/backend/app/api/attempts.py index ee5ea54..ffb3465 100644 --- a/backend/app/api/attempts.py +++ b/backend/app/api/attempts.py @@ -1,8 +1,9 @@ import random from datetime import datetime, timezone +from typing import Optional -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import select +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -10,16 +11,22 @@ from app.database import get_db from app.models.attempt import AttemptAnswer, TestAttempt from app.models.test import Question, Test from app.schemas.attempt import ( + AnswerForTest, + AnswerResult, + AttemptListItem, + AttemptListResponse, AttemptResult, AttemptStart, AttemptStarted, AttemptSubmitDto, - AnswerForTest, - AnswerResult, QuestionForTest, QuestionResult, ) +# Временно: до Sprint 6 (авторизация) все попытки принадлежат гостю +GUEST_USER_ID = 1 +GUEST_USER_NAME = "Гость" + router = APIRouter(prefix="/api/attempts", tags=["attempts"]) @@ -36,7 +43,7 @@ async def start_attempt(data: AttemptStart, db: AsyncSession = Depends(get_db)): if not test: raise HTTPException(status_code=404, detail="Тест не найден") - attempt = TestAttempt(test_id=test.id, status="in_progress") + attempt = TestAttempt(test_id=test.id, status="in_progress", user_id=GUEST_USER_ID) db.add(attempt) await db.commit() await db.refresh(attempt) @@ -162,6 +169,58 @@ async def submit_attempt( ) +@router.get("", response_model=AttemptListResponse) +async def list_attempts( + test_id: Optional[int] = Query(None), + date_from: Optional[datetime] = Query(None), + date_to: Optional[datetime] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), +): + """Трекер попыток: все завершённые попытки с фильтрацией и пагинацией.""" + base = ( + select(TestAttempt) + .options(selectinload(TestAttempt.test)) + .where(TestAttempt.status == "finished") + ) + if test_id is not None: + base = base.where(TestAttempt.test_id == test_id) + if date_from is not None: + base = base.where(TestAttempt.started_at >= date_from) + if date_to is not None: + base = base.where(TestAttempt.started_at <= date_to) + + total = (await db.execute(select(func.count()).select_from(base.subquery()))).scalar_one() + + rows_result = await db.execute( + base.order_by(TestAttempt.started_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + attempts_list = rows_result.scalars().all() + + items = [ + AttemptListItem( + id=a.id, + test_id=a.test_id, + test_title=a.test.title, + test_version=a.test.version, + user_id=a.user_id, + user_name=GUEST_USER_NAME, # TODO Sprint 6: заменить на JOIN с таблицей users + started_at=a.started_at, + finished_at=a.finished_at, + score=a.score, + correct_count=a.correct_count, + total_count=a.total_count, + passed=a.passed, + ) + for a in attempts_list + ] + + return AttemptListResponse(items=items, total=total, page=page, page_size=page_size) + + @router.get("/{attempt_id}/result", response_model=AttemptResult) async def get_result(attempt_id: int, db: AsyncSession = Depends(get_db)): """Получить результат завершённой попытки.""" diff --git a/backend/app/models/attempt.py b/backend/app/models/attempt.py index 7222203..4df0ab4 100644 --- a/backend/app/models/attempt.py +++ b/backend/app/models/attempt.py @@ -12,6 +12,7 @@ class TestAttempt(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True) test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False) + user_id: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1") started_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) diff --git a/backend/app/schemas/attempt.py b/backend/app/schemas/attempt.py index cc1cf88..e6b069d 100644 --- a/backend/app/schemas/attempt.py +++ b/backend/app/schemas/attempt.py @@ -87,3 +87,29 @@ class AttemptResult(BaseModel): questions: list[QuestionResult] model_config = {"from_attributes": True} + + +# ── Трекер результатов ──────────────────────────────────────── + +class AttemptListItem(BaseModel): + id: int + test_id: int + test_title: str + test_version: int + user_id: int + user_name: str + started_at: datetime + finished_at: Optional[datetime] + score: Optional[float] + correct_count: Optional[int] + total_count: Optional[int] + passed: Optional[bool] + + model_config = {"from_attributes": True} + + +class AttemptListResponse(BaseModel): + items: list[AttemptListItem] + total: int + page: int + page_size: int diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c8642c1..333537a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,12 @@ -import { SettingOutlined } from '@ant-design/icons' +import { BarChartOutlined, SettingOutlined } from '@ant-design/icons' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { Button, ConfigProvider, Layout } from 'antd' +import { Button, ConfigProvider, Layout, Space } from 'antd' import ruRU from 'antd/locale/ru_RU' import { BrowserRouter, Route, Routes, useNavigate } from 'react-router-dom' import AttemptResult from './pages/AttemptResult' import Settings from './pages/Settings' +import Tracker from './pages/Tracker' import TestCreate from './pages/TestCreate' import TestDetail from './pages/TestDetail' import TestEdit from './pages/TestEdit' @@ -35,14 +36,22 @@ function AppHeader() { > QA Test App - + + + + ) } @@ -62,6 +71,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api/attempts.ts b/frontend/src/api/attempts.ts index ff69e32..450485f 100644 --- a/frontend/src/api/attempts.ts +++ b/frontend/src/api/attempts.ts @@ -56,6 +56,36 @@ export interface AttemptResult { questions: QuestionResult[] } +export interface AttemptListItem { + id: number + test_id: number + test_title: string + test_version: number + user_id: number + user_name: string + started_at: string + finished_at: string | null + score: number | null + correct_count: number | null + total_count: number | null + passed: boolean | null +} + +export interface AttemptListResponse { + items: AttemptListItem[] + total: number + page: number + page_size: number +} + +export interface AttemptListParams { + test_id?: number + date_from?: string + date_to?: string + page?: number + page_size?: number +} + export const attemptsApi = { start: (test_id: number) => client.post('/attempts', { test_id }), @@ -65,4 +95,7 @@ export const attemptsApi = { getResult: (attempt_id: number) => client.get(`/attempts/${attempt_id}/result`), + + list: (params: AttemptListParams = {}) => + client.get('/attempts', { params }), } diff --git a/frontend/src/pages/Tracker/index.tsx b/frontend/src/pages/Tracker/index.tsx new file mode 100644 index 0000000..578db13 --- /dev/null +++ b/frontend/src/pages/Tracker/index.tsx @@ -0,0 +1,145 @@ +import { CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons' +import { useQuery } from '@tanstack/react-query' +import { Button, DatePicker, Select, Space, Table, Tag, Typography } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import dayjs, { Dayjs } from 'dayjs' +import { useState } from 'react' + +import { AttemptListItem, attemptsApi } from '../../api/attempts' +import { testsApi } from '../../api/tests' + +const { Title } = Typography +const { RangePicker } = DatePicker + +export default function Tracker() { + const [testId, setTestId] = useState() + const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null) + const [page, setPage] = useState(1) + const pageSize = 20 + + const params = { + test_id: testId, + date_from: dateRange?.[0].startOf('day').toISOString(), + date_to: dateRange?.[1].endOf('day').toISOString(), + page, + page_size: pageSize, + } + + const { data, isLoading } = useQuery({ + queryKey: ['attempts', params], + queryFn: () => attemptsApi.list(params).then((r) => r.data), + }) + + const { data: testsData } = useQuery({ + queryKey: ['tests'], + queryFn: () => testsApi.list().then((r) => r.data), + }) + + const handleReset = () => { + setTestId(undefined) + setDateRange(null) + setPage(1) + } + + const columns: ColumnsType = [ + { + title: 'Сотрудник', + dataIndex: 'user_name', + width: 120, + }, + { + title: 'Тест', + key: 'test', + render: (_, r) => ( + + {r.test_title}{' '} + + v{r.test_version} + + + ), + }, + { + title: 'Начало', + dataIndex: 'started_at', + width: 160, + render: (v: string) => dayjs(v).format('DD.MM.YYYY HH:mm'), + }, + { + title: 'Завершение', + dataIndex: 'finished_at', + width: 160, + render: (v: string | null) => (v ? dayjs(v).format('DD.MM.YYYY HH:mm') : '—'), + }, + { + title: 'Результат', + key: 'result', + width: 140, + render: (_, r) => + r.correct_count != null && r.total_count != null + ? `${r.correct_count} / ${r.total_count} (${r.score?.toFixed(1)}%)` + : '—', + }, + { + title: 'Зачёт', + dataIndex: 'passed', + width: 90, + render: (passed: boolean | null) => { + if (passed == null) return '—' + return passed ? ( + + + Сдал + + ) : ( + + + Не сдал + + ) + }, + }, + ] + + return ( +
+ Трекер результатов + + {/* Фильтры */} + +