Files
api-copp/route/radar_crud.py
2026-04-02 12:00:11 +05:00

271 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Эндпоинты для работы с радарными результатами тестов.
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.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"])
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
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))
],
)