new shems
This commit is contained in:
258
route/radar_crud.py
Normal file
258
route/radar_crud.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Эндпоинты для работы с радарными результатами тестов.
|
||||
|
||||
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))
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user