259 lines
9.6 KiB
Python
259 lines
9.6 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 make_engine
|
||
from bd.tables.response import Response, Answer
|
||
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"])
|
||
|
||
|
||
def get_session():
|
||
from sqlalchemy.orm import sessionmaker
|
||
return sessionmaker(bind=make_engine(), future=True)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Схемы ответов
|
||
# ---------------------------------------------------------------------------
|
||
|
||
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
|
||
|
||
for dim in sorted(dimensions, key=lambda d: (d.position is None, d.position)):
|
||
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)
|
||
|
||
session.commit()
|
||
session.refresh(result)
|
||
|
||
# Сохраняем SVG-файл на сервере
|
||
items_data = [
|
||
{
|
||
"dimension_name": item.dimension_name,
|
||
"dimension_color": item.dimension_color,
|
||
"value": item.value,
|
||
}
|
||
for item in sorted(result.items, key=lambda i: (i.dimension_position is None, i.dimension_position))
|
||
]
|
||
max_val = max((it["value"] for it in items_data), default=1.0)
|
||
max_val = max(max_val, 14.0)
|
||
try:
|
||
rel_path = save_radar_svg(str(result.id), items_data, max_val)
|
||
with session.begin_nested():
|
||
result.image_path = rel_path
|
||
session.commit()
|
||
session.refresh(result)
|
||
except Exception:
|
||
pass # не ломаем сдачу, если запись файла не удалась
|
||
|
||
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 or not result.image_path:
|
||
raise HTTPException(status_code=404, detail="Image not found")
|
||
app_path = os.getenv("APP_PATH", "/app")
|
||
full_path = os.path.join(app_path, result.image_path)
|
||
if not os.path.isfile(full_path):
|
||
raise HTTPException(status_code=404, detail="Image file missing on server")
|
||
return FileResponse(
|
||
full_path,
|
||
media_type="image/svg+xml",
|
||
filename=f"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: Optional[str] = None
|
||
if result.image_path:
|
||
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))
|
||
],
|
||
)
|