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