- bd/__init__.py: добавлены get_engine() и get_session() — один SQLAlchemy engine на весь процесс - make_engine(): pool_size=5, max_overflow=5, pool_timeout=30, pool_recycle=1800 - Все 17 route-файлов: убраны локальные get_session()/make_engine(), импорт из bd - transfer_crud.py: _get_session() → get_session() из bd - init_data_base.py: get_engine() вместо make_engine() - Результат: 78 idle соединений → 1
268 lines
11 KiB
Python
268 lines
11 KiB
Python
"""
|
||
Эндпоинты для работы с радарными результатами тестов.
|
||
|
||
POST /responses/{response_id}/radar/compute
|
||
— (пере)считать и сохранить результат как многогранную диаграмму.
|
||
Вызывается автоматически из response_crud при сдаче теста,
|
||
а также может быть вызван вручную (пересчёт).
|
||
|
||
GET /responses/{response_id}/radar
|
||
— получить сохранённый результат (данные для отрисовки диаграммы).
|
||
|
||
GET /users/{user_id}/radar-results
|
||
— история всех результатов пользователя (для всех тестов).
|
||
"""
|
||
|
||
import os
|
||
import uuid
|
||
from typing import List, Optional
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
from fastapi.responses import FileResponse
|
||
from pydantic import BaseModel
|
||
from sqlalchemy.orm import Session as SASession
|
||
|
||
from bd import get_session
|
||
from bd.tables.response import Response, Answer
|
||
from bd.tables.poll import Poll
|
||
from bd.tables.scale import ScaleDimension, ChoiceScore
|
||
from bd.tables.radar_result import RadarResult, RadarResultItem
|
||
from route.radar_svg_gen import save_radar_svg
|
||
|
||
router = APIRouter(tags=["radar"])
|
||
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Схемы ответов
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class RadarItemOut(BaseModel):
|
||
dimension_id: Optional[str]
|
||
dimension_name: str
|
||
dimension_color: Optional[str]
|
||
dimension_position: Optional[float]
|
||
value: float
|
||
|
||
|
||
class RadarResultOut(BaseModel):
|
||
id: str
|
||
response_id: str
|
||
computed_at: str
|
||
image_url: Optional[str] # URL для скачивания SVG с сервера
|
||
items: List[RadarItemOut]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Вспомогательная функция расчёта — используется и внутри пакета
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def compute_radar(response_id: uuid.UUID, session: SASession) -> RadarResult:
|
||
"""
|
||
Считает сумму баллов по каждой оси теста для данного ответа.
|
||
Если результат уже существует — пересоздаёт его.
|
||
Возвращает сохранённый объект RadarResult.
|
||
"""
|
||
resp: Response = session.get(Response, response_id)
|
||
if not resp:
|
||
raise HTTPException(status_code=404, detail="Response not found")
|
||
|
||
# Получаем все оси теста
|
||
dimensions = (
|
||
session.query(ScaleDimension)
|
||
.filter(ScaleDimension.poll_id == resp.poll_id)
|
||
.all()
|
||
)
|
||
if not dimensions:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="Poll has no scale dimensions defined. Add dimensions and choice scores first.",
|
||
)
|
||
|
||
# Собираем set выбранных вариантов
|
||
chosen_choice_ids = {
|
||
a.choice_id for a in resp.answers if a.choice_id is not None
|
||
}
|
||
|
||
# Считаем баллы по каждой оси
|
||
scores_map: dict[uuid.UUID, float] = {d.id: 0.0 for d in dimensions}
|
||
if chosen_choice_ids:
|
||
choice_scores = (
|
||
session.query(ChoiceScore)
|
||
.filter(ChoiceScore.choice_id.in_(chosen_choice_ids))
|
||
.all()
|
||
)
|
||
for cs in choice_scores:
|
||
if cs.dimension_id in scores_map:
|
||
scores_map[cs.dimension_id] += cs.score
|
||
|
||
# Удаляем старый результат, если есть
|
||
old = session.query(RadarResult).filter(RadarResult.response_id == response_id).first()
|
||
if old:
|
||
session.delete(old)
|
||
session.flush()
|
||
|
||
# Создаём новый
|
||
result = RadarResult(response_id=response_id)
|
||
session.add(result)
|
||
session.flush() # чтобы получить result.id до добавления items
|
||
|
||
dims_sorted = sorted(dimensions, key=lambda d: (d.position is None, d.position))
|
||
for dim in dims_sorted:
|
||
item = RadarResultItem(
|
||
result_id=result.id,
|
||
dimension_id=dim.id,
|
||
dimension_name=dim.name,
|
||
dimension_color=dim.color,
|
||
dimension_position=float(dim.position) if dim.position is not None else None,
|
||
value=scores_map[dim.id],
|
||
)
|
||
session.add(item)
|
||
|
||
# Генерируем SVG до коммита — result.id уже известен после flush(),
|
||
# items_data строим из scores_map/dimensions (они в памяти, не из БД).
|
||
# Так result + items + image_path фиксируются в одной транзакции.
|
||
items_data = [
|
||
{
|
||
"dimension_name": dim.name,
|
||
"dimension_color": dim.color,
|
||
"value": scores_map[dim.id],
|
||
}
|
||
for dim in dims_sorted
|
||
]
|
||
try:
|
||
poll = session.get(Poll, resp.poll_id)
|
||
poll_title = poll.title if poll else "unknown"
|
||
user_id_str = str(resp.user_id) if resp.user_id else "anonymous"
|
||
rel_path = save_radar_svg(
|
||
str(result.id), items_data,
|
||
max_val=0.0, # автомасштаб: шкала строится от данных
|
||
poll_title=poll_title,
|
||
user_id=user_id_str,
|
||
scale_padding=0.2, # +20% запаса над максимумом
|
||
min_scale=5.0, # шкала не менее 5 даже для малых значений
|
||
)
|
||
result.image_path = rel_path
|
||
except Exception:
|
||
pass # не ломаем сдачу, если генерация файла не удалась
|
||
|
||
session.commit() # единственный коммит: result + items + image_path
|
||
session.refresh(result)
|
||
return result
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Эндпоинты
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@router.post("/responses/{response_id}/radar/compute", response_model=RadarResultOut,
|
||
status_code=200)
|
||
def compute_radar_endpoint(response_id: str):
|
||
"""Вычисляет (или пересчитывает) многогранный результат по прохождению теста."""
|
||
Session = get_session()
|
||
try:
|
||
rid = uuid.UUID(response_id)
|
||
except Exception:
|
||
raise HTTPException(status_code=400, detail="Invalid UUID")
|
||
with Session() as session:
|
||
result = compute_radar(rid, session)
|
||
return _result_to_out(result)
|
||
|
||
|
||
@router.get("/responses/{response_id}/radar", response_model=RadarResultOut)
|
||
def get_radar_result(response_id: str):
|
||
"""Возвращает сохранённый радарный результат для отображения диаграммы."""
|
||
Session = get_session()
|
||
try:
|
||
rid = uuid.UUID(response_id)
|
||
except Exception:
|
||
raise HTTPException(status_code=400, detail="Invalid UUID")
|
||
with Session() as session:
|
||
result = (
|
||
session.query(RadarResult)
|
||
.filter(RadarResult.response_id == rid)
|
||
.first()
|
||
)
|
||
if not result:
|
||
raise HTTPException(status_code=404, detail="Radar result not found. Submit the response first.")
|
||
return _result_to_out(result)
|
||
|
||
|
||
@router.get("/responses/{response_id}/radar/image")
|
||
def get_radar_image(response_id: str):
|
||
"""Отдаёт SVG-файл диаграммы. Если файл отсутствует — пересчитывает автоматически."""
|
||
Session = get_session()
|
||
try:
|
||
rid = uuid.UUID(response_id)
|
||
except Exception:
|
||
raise HTTPException(status_code=400, detail="Invalid UUID")
|
||
with Session() as session:
|
||
result = (
|
||
session.query(RadarResult)
|
||
.filter(RadarResult.response_id == rid)
|
||
.first()
|
||
)
|
||
if not result:
|
||
raise HTTPException(status_code=404, detail="Radar result not found. Submit the response first.")
|
||
app_path = os.getenv("APP_PATH", "/app")
|
||
full_path = os.path.join(app_path, result.image_path) if result.image_path else None
|
||
# Файл удалён или отсутствует — пересчитываем с актуальным алгоритмом
|
||
if not full_path or not os.path.isfile(full_path):
|
||
result = compute_radar(rid, session)
|
||
full_path = os.path.join(app_path, result.image_path) if result.image_path else None
|
||
if not full_path or not os.path.isfile(full_path):
|
||
raise HTTPException(status_code=500, detail="Failed to generate radar image")
|
||
return FileResponse(
|
||
full_path,
|
||
media_type="image/svg+xml",
|
||
filename=f"radar_{response_id}.svg",
|
||
headers={"Content-Disposition": f'attachment; filename="radar_{response_id}.svg"'},
|
||
)
|
||
|
||
|
||
@router.get("/users/{user_id}/radar-results", response_model=List[RadarResultOut])
|
||
def get_user_radar_history(user_id: str):
|
||
"""История радарных результатов пользователя по всем тестам."""
|
||
Session = get_session()
|
||
try:
|
||
uid = uuid.UUID(user_id)
|
||
except Exception:
|
||
raise HTTPException(status_code=400, detail="Invalid user UUID")
|
||
with Session() as session:
|
||
responses = (
|
||
session.query(Response)
|
||
.filter(Response.user_id == uid)
|
||
.all()
|
||
)
|
||
response_ids = [r.id for r in responses]
|
||
if not response_ids:
|
||
return []
|
||
results = (
|
||
session.query(RadarResult)
|
||
.filter(RadarResult.response_id.in_(response_ids))
|
||
.all()
|
||
)
|
||
return [_result_to_out(r) for r in results]
|
||
|
||
|
||
def _result_to_out(result: RadarResult) -> RadarResultOut:
|
||
# image_url возвращаем всегда — endpoint пересоздаст SVG если файл отсутствует
|
||
image_url = f"/responses/{result.response_id}/radar/image"
|
||
return RadarResultOut(
|
||
id=str(result.id),
|
||
response_id=str(result.response_id),
|
||
computed_at=result.computed_at.isoformat(),
|
||
image_url=image_url,
|
||
items=[
|
||
RadarItemOut(
|
||
dimension_id=str(item.dimension_id) if item.dimension_id else None,
|
||
dimension_name=item.dimension_name,
|
||
dimension_color=item.dimension_color,
|
||
dimension_position=item.dimension_position,
|
||
value=item.value,
|
||
)
|
||
for item in sorted(result.items, key=lambda i: (i.dimension_position is None, i.dimension_position))
|
||
],
|
||
)
|