new shems
This commit is contained in:
@@ -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": "Расшифровка теста Голланда успешно загружена в базу данных.",
|
||||
}
|
||||
|
||||
@@ -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
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))
|
||||
],
|
||||
)
|
||||
118
route/radar_svg_gen.py
Normal file
118
route/radar_svg_gen.py
Normal 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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
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}"
|
||||
@@ -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
227
route/scale_crud.py
Normal 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()
|
||||
Reference in New Issue
Block a user