Files
api-copp/route/radar_crud.py
2026-04-01 14:18:52 +05:00

259 lines
9.6 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.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))
],
)