""" Эндпоинты для работы с радарными результатами тестов. 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)) ], )