new shems

This commit is contained in:
2026-04-01 14:18:52 +05:00
parent 4e9b89fbe5
commit 22bbc3a0a6
15 changed files with 1793 additions and 133 deletions

View File

@@ -7,53 +7,137 @@ from bd import make_engine
from bd.tables.poll import Poll
from bd.tables.question import Question
from bd.tables.choice import Choice
from bd.tables.scale import ScaleDimension, ChoiceScore
router = APIRouter(tags=["polls"], prefix="/polls")
# ---------------------------------------------------------------------------
# Вопросы теста — модификация Г.В. Резапкиной
# Пары профессий: (вариант А, вариант В)
# ---------------------------------------------------------------------------
PAIRS = [
("инженер-техник", "инженер-контролер"),
("вязальщик", "санитарный врач"),
("повар", "наборщик"),
("фотограф", "зав. магазином"),
("чертежник", "дизайнер"),
("философ", "психиатр"),
("ученый-химик", "бухгалтер"),
("редактор научного журнала", "адвокат"),
("лингвист", "переводчик художественной литературы"),
("педиатр", "статистик"),
("организатор воспитательной работы", "председатель профсоюза"),
("спортивный врач", "фельетонист"),
("нотариус", "снабженец"),
("перфоратор", "карикатурист"),
("политический деятель", "писатель"),
("садовник", "метеоролог"),
("водитель", "медсестра"),
("инженер-электрик", "секретарь-машинистка"),
("маляр", "художник по металлу"),
("биолог", "главный врач"),
("телеоператор", "режиссер"),
("гидролог", "ревизор"),
("зоолог", "зоотехник"),
("математик", "архитектор"),
("работник ИДН", "счетовод"),
("учитель", "милиционер"),
("воспитатель", "художник по керамике"),
("экономист", "заведующий отделом"),
("корректор", "критик"),
("завхоз", "директор"),
("радиоинженер", "специалист по ядерной физике"),
("водопроводчик", "наборщик"),
("агроном", "председатель сельхозкооператива"),
("закройщик-модельер", "декоратор"),
("археолог", "эксперт"),
("работник музея", "консультант"),
("ученый", "актер"),
("логопед", "стенографист"),
("врач", "дипломат"),
("главный бухгалтер", "директор"),
("поэт", "психолог"),
("архивариус", "скульптор"),
("инженер-технолог", "инженер-конструктор"), # 1
("электрорадиотехник", "врач-терапевт"), # 2
("оператор станков с числовым программным управлением", "кодировщик (обработка информации)"), # 3
("фотограф", "коммерсант"), # 4
("спасатель МЧС", "дизайнер"), # 5
("политолог", "психиатр"), # 6
("ученый химик", "бухгалтер"), # 7
("философ", "частный предприниматель"), # 8
("лингвист", "модельер"), # 9
("инспектор службы занятости населения", "статист"), # 10
("социальный педагог", "биржевой маклер"), # 11
("тренер", "искусствовед"), # 12
("нотариус", "менеджер"), # 13
("перфораторщик", "художник"), # 14
("лидер политической партии, общего движения", "писатель"), # 15
("закройщик", "метеоролог"), # 16
("водитель", "работник пресс-службы"), # 17
("чертежник", "риэлтер"), # 18
("специалист по ремонту компьютеров и оргтехники", "секретарь-референт"), # 19
("микробиолог", "психолог"), # 20
("видеооператор", "режиссер"), # 21
("экономист", "провизор"), # 22
("зоолог", "главный инженер"), # 23
("программист", "архитектор"), # 24
("работник инспекции по делам несовершеннолетних", "коммивояжер (сетевой маркетинг)"), # 25
("преподаватель", "биржевой маклер"), # 26
("воспитатель", "декоратор"), # 27
("реставратор", "зав. отделом предприятия"), # 28
("корректор", "литератор и кинокритик"), # 29
("фермер", "визажист"), # 30
("парикмахер", "социолог"), # 31
("экспедитор", "редактор"), # 32
("ветеринар", "директор (финансовый)"), # 33
("автомеханик", "стилист"), # 34
("археолог", "эксперт"), # 35
("библиограф", "корреспондент"), # 36
("эколог", "актер"), # 37
("логопед", "контролер"), # 38
("адвокат", "директор (глава АО)"), # 39
("кассир", "продюсер"), # 40
("поэт, писатель", "продавец"), # 41
("криминалист (баллистик)", "композитор"), # 42
]
# ---------------------------------------------------------------------------
# Ключ расшифровки: номер пары → (тип выбора А, тип выбора В)
# Типы: Р=Реалистический, И=Интеллектуальный, С=Социальный,
# К=Конвенциональный, П=Предприимчивый, А=Художественный
# Источник: Тест Дж. Голланда, модификация Г.В. Резапкиной
# ---------------------------------------------------------------------------
CHOICE_KEY: dict[int, tuple[str, str]] = {
1: ("Р", "И"), 2: ("Р", "С"), 3: ("Р", "К"), 4: ("Р", "П"), 5: ("Р", "А"),
6: ("И", "С"), 7: ("И", "К"), 8: ("И", "П"), 9: ("И", "А"), 10: ("С", "К"),
11: ("С", "П"), 12: ("С", "А"), 13: ("К", "П"), 14: ("К", "А"), 15: ("П", "А"),
16: ("Р", "И"), 17: ("Р", "С"), 18: ("К", "П"), 19: ("Р", "К"), 20: ("И", "С"),
21: ("Р", "А"), 22: ("И", "К"), 23: ("И", "П"), 24: ("И", "А"), 25: ("С", "П"),
26: ("С", "П"), 27: ("С", "А"), 28: ("Р", "П"), 29: ("К", "А"), 30: ("П", "А"),
31: ("Р", "И"), 32: ("Р", "К"), 33: ("Р", "П"), 34: ("Р", "А"), 35: ("И", "К"),
36: ("И", "С"), 37: ("И", "А"), 38: ("С", "К"), 39: ("С", "П"), 40: ("К", "П"),
41: ("А", "С"), 42: ("К", "А"),
}
# ---------------------------------------------------------------------------
# Описания шести типов личности по Голланду
# ---------------------------------------------------------------------------
DIMENSIONS = [
{
"code": "Р", "name": "Реалистический", "position": 1, "color": "#6B8E5E",
"description": (
"Предпочитает работать с вещами, а не с людьми. Несоциальный, эмоционально-стабильный тип. "
"Ориентирован на настоящее, определённое. Занимается конкретными объектами и их использованием "
"(вещи, инструменты, техника). Трудолюбив, пластичен. Преобладают невербальные и математические "
"способности. Охотнее делает, чем говорит; настойчив, уверен в себе. "
"Рекомендуемые профессии: оператор ПК, техник, шофёр, ювелир, автомеханик, фермер, инженер."
),
},
{
"code": "И", "name": "Интеллектуальный", "position": 2, "color": "#4A7FA5",
"description": (
"Ориентирован на труд с идеями и объектами. Любознательный, методичный, любит работать в одиночку. "
"Аналитические способности, рационализм, независимость и оригинальность мышления. "
"Нередко выбирает научную и исследовательскую работу. Мир идей для него важнее, чем общение с людьми. "
"Рекомендуемые профессии: метеоролог, научный работник, физик, химик, хирург, биолог."
),
},
{
"code": "С", "name": "Социальный", "position": 3, "color": "#D4A84B",
"description": (
"Ориентирован на общение и взаимодействие с людьми. Ответственен, терпелив, эмпатичен. "
"Развитые вербальные способности. Предпочитает деятельность, связанную с обучением, воспитанием, "
"лечением, консультированием. Гуманен, чувствителен, активен. "
"Рекомендуемые профессии: учитель, психолог, логопед, врач, продавец."
),
},
{
"code": "К", "name": "Конвенциональный", "position": 4, "color": "#8B7355",
"description": (
"Отдаёт предпочтение чётко структурированной деятельности. Консервативен, аккуратен, пунктуален. "
"Хорошо работает с информацией в виде цифр, формул, текстов. Прекрасный исполнитель. "
"Склонен к канцелярской и конторской работе. "
"Рекомендуемые профессии: экономист, бухгалтер, нотариус, кассир, архивариус, налоговый инспектор."
),
},
{
"code": "П", "name": "Предприимчивый", "position": 5, "color": "#C0392B",
"description": (
"Выбирает цели и задачи, которые позволяют проявить энергию и энтузиазм. Самоуверен, настойчив. "
"Находчив, практичен, быстро ориентируется в сложной обстановке. Социально активен, готов рисковать. "
"Предпочитает деятельность, связанную с руководством и влиянием на людей. "
"Рекомендуемые профессии: руководитель, директор, судья, адвокат, брокер, предприниматель, риэлтор."
),
},
{
"code": "А", "name": "Художественный", "position": 6, "color": "#8E44AD",
"description": (
"Сложный взгляд на жизнь, гибкость и независимость в принятии решений. Богатое воображение, "
"склонность к творческой деятельности. Оригинален, эмоционален, не терпит жёсткой регламентации. "
"Преобладают вербальные способности. "
"Рекомендуемые профессии: писатель, фотограф, музыкант, художник, журналист, архитектор, дизайнер."
),
},
]
@@ -66,15 +150,19 @@ def get_session():
def create_holland_poll(author_id: Optional[str] = None):
Session = get_session()
with Session() as session:
poll = Poll(title="Тест Д. Голланда — определение типа личности",
description="Тест Д. Голланда: выберите из каждой пары предпочитаемую профессию.")
# polls are anonymous; ignore author_id
poll = Poll(
title="Тест Дж. Голланда — определение типа личности",
description=(
"Тест Дж. Голланда (модификация Г.В. Резапкиной): выберите из каждой пары "
"предпочитаемую профессию. Из 42 пар профессий отметьте ту, которая вам больше нравится."
),
)
session.add(poll)
for idx, (a, b) in enumerate(PAIRS, start=1):
q = Question(text=f"Вариант {idx}", position=idx, type="single", poll=poll)
session.add(q)
ch1 = Choice(question=q, text=f"а) {a}", position=1)
ch2 = Choice(question=q, text=f"б) {b}", position=2)
ch1 = Choice(question=q, text=f"А) {a}", position=1)
ch2 = Choice(question=q, text=f"В) {b}", position=2)
session.add(ch1)
session.add(ch2)
session.commit()
@@ -88,3 +176,88 @@ def list_holland_polls():
with Session() as session:
rows = session.query(Poll).filter(Poll.title.like("%Голланда%")).all()
return [{"id": str(p.id), "title": p.title, "description": p.description} for p in rows]
@router.post("/holland/{poll_id}/seed-scores", status_code=201)
def seed_holland_scores(poll_id: str):
"""
Загружает расшифровку теста Голланда (модификация Резапкиной) в базу данных:
- создаёт 6 осей диаграммы (ScaleDimension) для указанного теста
- для каждого из 42 вопросов привязывает оба варианта ответа к своей оси (ChoiceScore)
При повторном вызове старые оси и расшифровки пересоздаются.
"""
Session = get_session()
try:
pid = uuid.UUID(poll_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid poll UUID")
with Session() as session:
poll = session.get(Poll, pid)
if not poll:
raise HTTPException(status_code=404, detail="Poll not found")
# Получаем вопросы упорядоченно по позиции
questions = (
session.query(Question)
.filter(Question.poll_id == pid)
.order_by(Question.position.asc())
.all()
)
if len(questions) != 42:
raise HTTPException(
status_code=422,
detail=f"Ожидается 42 вопроса, найдено {len(questions)}. Убедитесь, что тест создан через POST /polls/holland.",
)
# Удаляем старые оси (и ChoiceScore через cascade)
old_dims = session.query(ScaleDimension).filter(ScaleDimension.poll_id == pid).all()
for d in old_dims:
session.delete(d)
session.flush()
# Создаём 6 осей: код → объект
dim_map: dict[str, ScaleDimension] = {}
for d_data in DIMENSIONS:
dim = ScaleDimension(
poll_id=pid,
name=d_data["name"],
description=d_data["description"],
color=d_data["color"],
position=d_data["position"],
)
session.add(dim)
session.flush()
dim_map[d_data["code"]] = dim
# Для каждого вопроса привязываем варианты к осям
scores_created = 0
for q in questions:
pair_num = q.position # 1..42
if pair_num not in CHOICE_KEY:
continue
type_a, type_b = CHOICE_KEY[pair_num]
choices = (
session.query(Choice)
.filter(Choice.question_id == q.id)
.order_by(Choice.position.asc())
.all()
)
if len(choices) < 2:
continue
choice_a, choice_b = choices[0], choices[1]
session.add(ChoiceScore(choice_id=choice_a.id, dimension_id=dim_map[type_a].id, score=1.0))
session.add(ChoiceScore(choice_id=choice_b.id, dimension_id=dim_map[type_b].id, score=1.0))
scores_created += 2
session.commit()
return {
"status": "ok",
"dimensions": len(DIMENSIONS),
"choice_scores": scores_created,
"message": "Расшифровка теста Голланда успешно загружена в базу данных.",
}

View File

@@ -103,6 +103,8 @@ async def migrate_tables():
# Добавляем новые колонки в users
'ALTER TABLE users ADD COLUMN IF NOT EXISTS group_id UUID REFERENCES "group"(id) ON DELETE SET NULL',
"ALTER TABLE users ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization(id) ON DELETE SET NULL",
# Добавляем путь к SVG-файлу диаграммы в radar_results
"ALTER TABLE radar_results ADD COLUMN IF NOT EXISTS image_path VARCHAR(512)",
]
applied = []
try:

258
route/radar_crud.py Normal file
View 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))
],
)

118
route/radar_svg_gen.py Normal file
View File

@@ -0,0 +1,118 @@
"""
radar_svg_gen.py — серверная генерация SVG паутинной диаграммы.
Используется при сохранении результата: файл пишется в APP_PATH/data/radar/.
Не требует сторонних зависимостей.
"""
import math
import os
import base64
def generate_radar_svg(items: list[dict], max_val: float = 14.0, size: int = 520) -> str:
"""
Возвращает строку SVG паутинной диаграммы.
items — список осей: [{dimension_name, dimension_color, value}, ...]
max_val — максимум шкалы (14 для теста Голланда)
size — размер холста в пикселях
"""
n = len(items)
if n < 3:
return (
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">'
f'<rect width="{size}" height="{size}" fill="#2F184B" rx="16"/>'
f'<text x="{size//2}" y="{size//2}" text-anchor="middle" fill="white" '
f'font-family="Arial,sans-serif" font-size="16">Нет данных</text></svg>'
)
cx = cy = size / 2
R = size * 0.27
lbl_r = R + size * 0.13
safe_max = max_val if max_val > 0 else 1.0
def axis_xy(i: int, frac: float = 1.0) -> tuple[float, float]:
a = math.radians(-90.0 + 360.0 * i / n)
return cx + R * frac * math.cos(a), cy + R * frac * math.sin(a)
def poly_pts(pts: list[tuple]) -> str:
return " ".join(f"{x:.2f},{y:.2f}" for x, y in pts)
lines: list[str] = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">',
f'<rect width="{size}" height="{size}" fill="#2F184B" rx="16"/>',
]
# Концентрические сетки
for lvl in range(1, 6):
pts = [axis_xy(i, lvl / 5) for i in range(n)]
lines.append(
f'<polygon points="{poly_pts(pts)}" '
f'fill="none" stroke="rgba(244,239,250,0.18)" stroke-width="1"/>'
)
# Оси от центра
for i in range(n):
x, y = axis_xy(i)
lines.append(
f'<line x1="{cx:.2f}" y1="{cy:.2f}" x2="{x:.2f}" y2="{y:.2f}" '
f'stroke="rgba(244,239,250,0.22)" stroke-width="1"/>'
)
# Полигон данных
data_pts = [axis_xy(i, min(it["value"] / safe_max, 1.0)) for i, it in enumerate(items)]
lines.append(
f'<polygon points="{poly_pts(data_pts)}" '
f'fill="rgba(155,114,207,0.55)" stroke="#F4EFFA" stroke-width="2.5"/>'
)
# Точки на вершинах
for x, y in data_pts:
lines.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="5" fill="#F4EFFA"/>')
# Подписи
for i, item in enumerate(items):
a = math.radians(-90.0 + 360.0 * i / n)
lx = cx + lbl_r * math.cos(a)
ly = cy + lbl_r * math.sin(a)
col = item.get("dimension_color") or "#F4EFFA"
name = _xml_escape(item["dimension_name"])
val = int(item["value"])
lines.append(
f'<text x="{lx:.2f}" y="{ly:.2f}" text-anchor="middle" '
f'font-family="Arial,Helvetica,sans-serif" font-size="14" '
f'font-weight="bold" fill="{col}">{name}</text>'
)
lines.append(
f'<text x="{lx:.2f}" y="{ly + 20:.2f}" text-anchor="middle" '
f'font-family="Arial,Helvetica,sans-serif" font-size="13" '
f'fill="#F4EFFA">{val}</text>'
)
lines.append("</svg>")
return "\n".join(lines)
def _xml_escape(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def save_radar_svg(result_id: str, items: list[dict], max_val: float = 14.0) -> str:
"""
Генерирует SVG и сохраняет его на диск.
Возвращает относительный путь к файлу (относительно APP_PATH).
Файл пишется в: {APP_PATH}/data/radar/{result_id}.svg
Возвращает: data/radar/{result_id}.svg
"""
app_path = os.getenv("APP_PATH", "/app")
radar_dir = os.path.join(app_path, "data", "radar")
os.makedirs(radar_dir, exist_ok=True)
filename = f"{result_id}.svg"
full_path = os.path.join(radar_dir, filename)
svg = generate_radar_svg(items, max_val)
with open(full_path, "w", encoding="utf-8") as f:
f.write(svg)
return f"data/radar/{filename}"

View File

@@ -8,6 +8,7 @@ from bd import make_engine
from bd.tables.poll import Poll
from bd.tables.question import Question
from bd.tables.response import Response, Answer
from bd.tables.scale import ScaleDimension
router = APIRouter(tags=["responses"], prefix="/polls")
@@ -77,7 +78,26 @@ def submit_response(poll_id: str, payload: ResponseIn):
session.add(answer)
session.commit()
session.refresh(resp)
return {"id": str(resp.id)}
# Автоматически считаем радарный результат, если у теста есть оси
has_dimensions = (
session.query(ScaleDimension)
.filter(ScaleDimension.poll_id == pid)
.first()
) is not None
radar_id: Optional[str] = None
if has_dimensions:
try:
from route.radar_crud import compute_radar
radar = compute_radar(resp.id, session)
radar_id = str(radar.id)
except Exception:
pass # не ломаем сдачу, если расчёт не удался
result: dict = {"id": str(resp.id)}
if radar_id:
result["radar_result_id"] = radar_id
return result
@router.get("/responses/")

227
route/scale_crud.py Normal file
View File

@@ -0,0 +1,227 @@
"""
CRUD для осей (ScaleDimension) и расшифровок вариантов ответов (ChoiceScore).
Эндпоинты:
POST /polls/{poll_id}/dimensions — создать ось для теста
GET /polls/{poll_id}/dimensions — список осей теста
PUT /dimensions/{dimension_id} — изменить ось
DELETE /dimensions/{dimension_id} — удалить ось
POST /dimensions/{dimension_id}/scores — задать расшифровку для варианта ответа
GET /dimensions/{dimension_id}/scores — список расшифровок по оси
GET /choices/{choice_id}/scores — расшифровки конкретного варианта
DELETE /choice-scores/{score_id} — удалить расшифровку
"""
import uuid
from typing import List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from bd import make_engine
from bd.tables.poll import Poll
from bd.tables.choice import Choice
from bd.tables.scale import ScaleDimension, ChoiceScore
router = APIRouter(tags=["scale"])
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
# ---------------------------------------------------------------------------
# Схемы
# ---------------------------------------------------------------------------
class DimensionIn(BaseModel):
name: str
description: Optional[str] = None
color: Optional[str] = None
position: Optional[int] = None
class DimensionOut(BaseModel):
id: str
poll_id: str
name: str
description: Optional[str]
color: Optional[str]
position: Optional[int]
class ChoiceScoreIn(BaseModel):
choice_id: str
score: float = 1.0
class ChoiceScoreOut(BaseModel):
id: str
choice_id: str
dimension_id: str
score: float
# ---------------------------------------------------------------------------
# Оси (ScaleDimension)
# ---------------------------------------------------------------------------
@router.post("/polls/{poll_id}/dimensions", response_model=DimensionOut, status_code=201,
tags=["scale"])
def create_dimension(poll_id: str, payload: DimensionIn):
Session = get_session()
try:
pid = uuid.UUID(poll_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid poll UUID")
with Session() as session:
if not session.get(Poll, pid):
raise HTTPException(status_code=404, detail="Poll not found")
dim = ScaleDimension(
poll_id=pid,
name=payload.name,
description=payload.description,
color=payload.color,
position=payload.position,
)
session.add(dim)
session.commit()
session.refresh(dim)
return DimensionOut(
id=str(dim.id), poll_id=str(dim.poll_id),
name=dim.name, description=dim.description,
color=dim.color, position=dim.position,
)
@router.get("/polls/{poll_id}/dimensions", response_model=List[DimensionOut],
tags=["scale"])
def list_dimensions(poll_id: str):
Session = get_session()
try:
pid = uuid.UUID(poll_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid poll UUID")
with Session() as session:
dims = (
session.query(ScaleDimension)
.filter(ScaleDimension.poll_id == pid)
.order_by(ScaleDimension.position.asc().nullslast())
.all()
)
return [
DimensionOut(id=str(d.id), poll_id=str(d.poll_id),
name=d.name, description=d.description,
color=d.color, position=d.position)
for d in dims
]
@router.put("/dimensions/{dimension_id}", response_model=DimensionOut, tags=["scale"])
def update_dimension(dimension_id: str, payload: DimensionIn):
Session = get_session()
try:
did = uuid.UUID(dimension_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid dimension UUID")
with Session() as session:
dim = session.get(ScaleDimension, did)
if not dim:
raise HTTPException(status_code=404, detail="Dimension not found")
dim.name = payload.name
dim.description = payload.description
dim.color = payload.color
dim.position = payload.position
session.commit()
session.refresh(dim)
return DimensionOut(id=str(dim.id), poll_id=str(dim.poll_id),
name=dim.name, description=dim.description,
color=dim.color, position=dim.position)
@router.delete("/dimensions/{dimension_id}", status_code=204, tags=["scale"])
def delete_dimension(dimension_id: str):
Session = get_session()
try:
did = uuid.UUID(dimension_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid dimension UUID")
with Session() as session:
dim = session.get(ScaleDimension, did)
if not dim:
raise HTTPException(status_code=404, detail="Dimension not found")
session.delete(dim)
session.commit()
# ---------------------------------------------------------------------------
# Расшифровки вариантов ответов (ChoiceScore)
# ---------------------------------------------------------------------------
@router.post("/dimensions/{dimension_id}/scores", response_model=ChoiceScoreOut,
status_code=201, tags=["scale"])
def create_choice_score(dimension_id: str, payload: ChoiceScoreIn):
Session = get_session()
try:
did = uuid.UUID(dimension_id)
cid = uuid.UUID(payload.choice_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid UUID")
with Session() as session:
if not session.get(ScaleDimension, did):
raise HTTPException(status_code=404, detail="Dimension not found")
if not session.get(Choice, cid):
raise HTTPException(status_code=404, detail="Choice not found")
cs = ChoiceScore(choice_id=cid, dimension_id=did, score=payload.score)
session.add(cs)
session.commit()
session.refresh(cs)
return ChoiceScoreOut(id=str(cs.id), choice_id=str(cs.choice_id),
dimension_id=str(cs.dimension_id), score=cs.score)
@router.get("/dimensions/{dimension_id}/scores", response_model=List[ChoiceScoreOut],
tags=["scale"])
def list_scores_by_dimension(dimension_id: str):
Session = get_session()
try:
did = uuid.UUID(dimension_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid dimension UUID")
with Session() as session:
rows = session.query(ChoiceScore).filter(ChoiceScore.dimension_id == did).all()
return [ChoiceScoreOut(id=str(r.id), choice_id=str(r.choice_id),
dimension_id=str(r.dimension_id), score=r.score)
for r in rows]
@router.get("/choices/{choice_id}/scores", response_model=List[ChoiceScoreOut],
tags=["scale"])
def list_scores_by_choice(choice_id: str):
Session = get_session()
try:
cid = uuid.UUID(choice_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid choice UUID")
with Session() as session:
rows = session.query(ChoiceScore).filter(ChoiceScore.choice_id == cid).all()
return [ChoiceScoreOut(id=str(r.id), choice_id=str(r.choice_id),
dimension_id=str(r.dimension_id), score=r.score)
for r in rows]
@router.delete("/choice-scores/{score_id}", status_code=204, tags=["scale"])
def delete_choice_score(score_id: str):
Session = get_session()
try:
sid = uuid.UUID(score_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid UUID")
with Session() as session:
cs = session.get(ChoiceScore, sid)
if not cs:
raise HTTPException(status_code=404, detail="Score not found")
session.delete(cs)
session.commit()