fix install diogram

This commit is contained in:
2026-04-02 12:00:11 +05:00
parent 22bbc3a0a6
commit 8a189e310b
23 changed files with 1212 additions and 189 deletions

293
route/klimov_crud.py Normal file
View File

@@ -0,0 +1,293 @@
"""
Эндпоинты для работы с тестом ДДО Е.А. Климова.
POST /polls/klimov — создать опрос в БД (42 пары → нет, 20 пар)
POST /polls/klimov/{id}/seed-scores — загрузить ключ расшифровки (5 типов)
GET /polls/klimov — список тестов Климова
После прохождения (POST /polls/{id}/responses) диаграмма строится автоматически
через response_crud → compute_radar и сохраняется в data/radar/<poll_title>/<user_id>/<result_id>.svg.
Путь к файлу хранится в таблице radar_results.image_path.
"""
from fastapi import APIRouter, HTTPException
from typing import Optional
import uuid
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")
# ---------------------------------------------------------------------------
# 20 пар вопросов ДДО Е.А. Климова
# Каждая пара: (вариант А, вариант Б)
# ---------------------------------------------------------------------------
PAIRS: list[tuple[str, str]] = [
("Ухаживать за животными.", # 1а
"Обслуживать машины, приборы (следить, регулировать)."), # 1б
("Помогать больным людям, лечить их.", # 2а
"Составлять таблицы, схемы, программы для вычислительных машин."), # 2б
("Следить за качеством книжных иллюстраций, плакатов, художественных открыток, грампластинок.", # 3а
"Следить за состоянием, развитием растений."), # 3б
("Обрабатывать материалы (дерево, ткань, пластмассу, металл и т.п.).", # 4а
"Доводить товары до потребителя (рекламировать, продавать)."), # 4б
("Обсуждать научно-популярные книги, статьи.", # 5а
"Обсуждать художественные книги (или пьесы, концерты)."), # 5б
("Выращивать молодняк (животных какой-либо породы).", # 6а
"Тренировать товарищей (или младших) в выполнении каких-либо действий (трудовых, учебных, спортивных)."), # 6б
("Копировать рисунки, изображения (или настраивать музыкальные инструменты).", # 7а
"Управлять каким-либо грузовым (подъёмным или транспортным) средством."), # 7б
("Сообщать, разъяснять людям нужные им сведения (в справочном бюро, на экскурсии и т.д.).", # 8а
"Художественно оформлять выставки, витрины (или участвовать в подготовке пьес, концертов)."), # 8б
("Ремонтировать вещи, изделия (одежду, технику), жилище.", # 9а
"Искать и исправлять ошибки в текстах, таблицах, рисунках."), # 9б
("Лечить животных.", # 10а
"Выполнять вычисления, расчёты."), # 10б
("Выводить новые сорта растений.", # 11а
"Конструировать, проектировать новые виды промышленных изделий (машины или одежду, дома, продукты питания и т.п.)."), # 11б
("Разбирать споры, ссоры между людьми, убеждать, разъяснять, поощрять, наказывать.", # 12а
"Разбираться в чертежах, схемах, таблицах (проверять, уточнять, приводить в порядок)."), # 12б
("Наблюдать, изучать работу кружков художественной самодеятельности.", # 13а
"Наблюдать, изучать жизнь микробов."), # 13б
("Обсуждать, налаживать медицинские приборы, аппараты.", # 14а
"Оказывать людям медицинскую помощь при ранениях, ушибах, ожогах и т.п."), # 14б
("Составлять точные описания, отчёты о наблюдаемых явлениях, событиях, измеряемых объектах.", # 15а
"Художественно описывать, изображать события (наблюдаемые или представляемые)."), # 15б
("Делать лабораторные анализы в больнице.", # 16а
"Принимать, осматривать больных, беседовать с ними, назначать лечение."), # 16б
("Красить или расписывать стены помещений, поверхность изделий.", # 17а
"Осуществлять монтаж зданий или сборку машин, приборов."), # 17б
("Организовывать культпоходы сверстников или младших в театры, музеи, экскурсии, туристические походы и т.п.", # 18а
"Играть на сцене, принимать участие в концертах."), # 18б
("Изготовлять по чертежам детали, изделия (машины, одежду), строить здания.", # 19а
"Заниматься черчением, копированием чертежей, карт."), # 19б
("Вести борьбу с болезнями растений, с вредителями леса, сада.", # 20а
"Работать на клавишных машинах."), # 20б
]
# ---------------------------------------------------------------------------
# Ключ расшифровки: номер пары → (тип варианта А, тип варианта Б)
# Типы: П — природа, Т — техника, Ч — человек, Х — худ. образ, З — знаки
# Источник: лист ответов ДДО Е.А. Климова
# ---------------------------------------------------------------------------
CHOICE_KEY: dict[int, tuple[str, str]] = {
1: ("П", "Т"),
2: ("Ч", "З"),
3: ("З", "П"), # 3а — знаковая, 3б — природа
4: ("Т", "Ч"),
5: ("З", "Х"),
6: ("П", "Ч"),
7: ("Х", "Т"),
8: ("Ч", "Х"),
9: ("Т", "З"),
10: ("П", "З"),
11: ("П", "Т"),
12: ("Ч", "З"),
13: ("Х", "П"),
14: ("Т", "Ч"),
15: ("З", "Х"),
16: ("П", "Ч"),
17: ("Х", "Т"),
18: ("Ч", "Х"),
19: ("Т", "З"),
20: ("П", "З"),
}
# ---------------------------------------------------------------------------
# 5 типов профессий по Климову
# ---------------------------------------------------------------------------
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": "#8E44AD",
"description": (
"Творческие профессии. Требуются воображение, образное мышление, талант, трудолюбие. "
"Примеры: художник, музыкант, актёр, архитектор, фотограф, дизайнер, писатель."
),
},
]
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
@router.post("/klimov", status_code=201)
def create_klimov_poll():
"""
Создаёт тест ДДО Е.А. Климова в базе данных (20 пар вопросов, 2 варианта каждый).
После создания вызовите POST /polls/klimov/{id}/seed-scores для загрузки ключа.
"""
Session = get_session()
with Session() as session:
poll = Poll(
title="ДДО Е.А. Климова — определение типа профессии",
description=(
"Дифференциально-диагностический опросник Е.А. Климова: выберите из каждой пары "
"предпочтительный вид занятий. 20 пар — выявляет ориентацию на 5 типов профессий: "
"«Человек — природа», «Человек — техника», «Человек — человек», "
"«Человек — знаковая система», «Человек — художественный образ»."
),
)
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)
session.add(ch1)
session.add(ch2)
session.commit()
session.refresh(poll)
return {"id": str(poll.id), "questions": len(poll.questions)}
@router.get("/klimov")
def list_klimov_polls():
"""Возвращает список тестов ДДО Климова."""
Session = get_session()
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("/klimov/{poll_id}/seed-scores", status_code=201)
def seed_klimov_scores(poll_id: str):
"""
Загружает расшифровку ДДО Климова в базу данных:
- создаёт 5 осей диаграммы (ScaleDimension) для указанного теста
- для каждого из 20 вопросов привязывает оба варианта ответа к своей оси (ChoiceScore)
При повторном вызове оси и расшифровки пересоздаются.
Диаграмма строится автоматически после прохождения теста (POST /polls/{id}/responses)
и сохраняется в data/radar/<poll_title>/<user_id>/<result_id>.svg.
"""
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) != 20:
raise HTTPException(
status_code=422,
detail=f"Ожидается 20 вопросов, найдено {len(questions)}. Убедитесь, что тест создан через POST /polls/klimov.",
)
# Удаляем старые оси (и ChoiceScore через cascade)
old_dims = session.query(ScaleDimension).filter(ScaleDimension.poll_id == pid).all()
for d in old_dims:
session.delete(d)
session.flush()
# Создаём 5 осей: код → объект
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..20
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": (
"Расшифровка ДДО Климова успешно загружена. "
"После прохождения теста диаграмма строится автоматически и "
"сохраняется в data/radar/."
),
}

View File

@@ -24,6 +24,7 @@ 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
@@ -110,7 +111,8 @@ def compute_radar(response_id: uuid.UUID, session: SASession) -> RadarResult:
session.add(result)
session.flush() # чтобы получить result.id до добавления items
for dim in sorted(dimensions, key=lambda d: (d.position is None, d.position)):
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,
@@ -121,29 +123,35 @@ def compute_radar(response_id: uuid.UUID, session: SASession) -> RadarResult:
)
session.add(item)
session.commit()
session.refresh(result)
# Сохраняем SVG-файл на сервере
# Генерируем SVG до коммита — result.id уже известен после flush(),
# items_data строим из scores_map/dimensions (они в памяти, не из БД).
# Так result + items + image_path фиксируются в одной транзакции.
items_data = [
{
"dimension_name": item.dimension_name,
"dimension_color": item.dimension_color,
"value": item.value,
"dimension_name": dim.name,
"dimension_color": dim.color,
"value": scores_map[dim.id],
}
for item in sorted(result.items, key=lambda i: (i.dimension_position is None, i.dimension_position))
for dim in dims_sorted
]
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)
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 # не ломаем сдачу, если запись файла не удалась
pass # не ломаем сдачу, если генерация файла не удалась
session.commit() # единственный коммит: result + items + image_path
session.refresh(result)
return result
@@ -186,7 +194,7 @@ def get_radar_result(response_id: str):
@router.get("/responses/{response_id}/radar/image")
def get_radar_image(response_id: str):
"""Отдаёт SVG-файл диаграммы для скачивания."""
"""Отдаёт SVG-файл диаграммы. Если файл отсутствует — пересчитывает автоматически."""
Session = get_session()
try:
rid = uuid.UUID(response_id)
@@ -198,16 +206,21 @@ def get_radar_image(response_id: str):
.filter(RadarResult.response_id == rid)
.first()
)
if not result or not result.image_path:
raise HTTPException(status_code=404, detail="Image not found")
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 not os.path.isfile(full_path):
raise HTTPException(status_code=404, detail="Image file missing on server")
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"'},
)
@@ -237,9 +250,8 @@ def get_user_radar_history(user_id: str):
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"
# 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),

View File

@@ -8,27 +8,46 @@ import os
import base64
def generate_radar_svg(items: list[dict], max_val: float = 14.0, size: int = 520) -> str:
def generate_radar_svg(
items: list[dict],
max_val: float = 0.0,
size: int = 520,
scale_padding: float = 0.2,
min_scale: float = 5.0,
grid_levels: int = 5,
show_scale_labels: bool = True,
) -> str:
"""
Возвращает строку SVG паутинной диаграммы.
items — список осей: [{dimension_name, dimension_color, value}, ...]
max_val — максимум шкалы (14 для теста Голланда)
size — размер холста в пикселях
items — список осей: [{dimension_name, dimension_color, value}, ...]
max_val — максимум шкалы; 0 = авто (вычисляется из items)
size — размер холста в пикселях
scale_padding — запас над максимальным значением (доля, 0.2 = +20%)
min_scale — минимальное значение шкалы (чтобы маленькие значения не выглядели огромными)
grid_levels — количество концентрических колец
show_scale_labels — рисовать ли подписи значений на одной из осей
"""
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'<text x="{size//2}" y="{size//2}" text-anchor="middle" fill="#333" '
f'font-family="Arial,sans-serif" font-size="16">Нет данных</text></svg>'
)
# ---- масштаб ----
data_max = max((it["value"] for it in items), default=1.0)
if max_val <= 0:
max_val = data_max
safe_max = max(max_val * (1.0 + scale_padding), min_scale, 1.0)
# ---- геометрия ----
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
# Радиус сетки: оставляем место для подписей со всех сторон
lbl_margin = size * 0.25 # запас под подписи
R = (size / 2) - lbl_margin # сетка заполняет максимум пространства
lbl_r = R + size * 0.06 # подписи чуть дальше от сетки
def axis_xy(i: int, frac: float = 1.0) -> tuple[float, float]:
a = math.radians(-90.0 + 360.0 * i / n)
@@ -39,15 +58,16 @@ def generate_radar_svg(items: list[dict], max_val: float = 14.0, size: int = 520
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)]
for lvl in range(1, grid_levels + 1):
frac = lvl / grid_levels
pts = [axis_xy(i, frac) for i in range(n)]
alpha = 0.28 + 0.14 * (lvl / grid_levels) # внешние кольца чуть ярче
lines.append(
f'<polygon points="{poly_pts(pts)}" '
f'fill="none" stroke="rgba(244,239,250,0.18)" stroke-width="1"/>'
f'fill="none" stroke="rgba(170,130,230,{alpha:.2f})" stroke-width="1.2"/>'
)
# Оси от центра
@@ -55,38 +75,57 @@ def generate_radar_svg(items: list[dict], max_val: float = 14.0, size: int = 520
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"/>'
f'stroke="rgba(170,130,230,0.45)" stroke-width="1.2"/>'
)
# Подписи значений на первой оси (вертикально вверх)
if show_scale_labels:
for lvl in range(1, grid_levels + 1):
frac = lvl / grid_levels
tick_val = safe_max * frac
tx, ty = axis_xy(0, frac)
lines.append(
f'<text x="{tx + 4:.2f}" y="{ty:.2f}" '
f'font-family="Arial,sans-serif" font-size="10" '
f'fill="#A070D0">{tick_val:.0f}</text>'
)
# Полигон данных
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"/>'
f'fill="rgba(180,140,240,0.35)" stroke="#A070D0" stroke-width="3.5"/>'
)
# Точки на вершинах
for x, y in data_pts:
lines.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="5" fill="#F4EFFA"/>')
lines.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="6" fill="#A070D0"/>')
# Подписи
# Подписи осей (с переносом длинных строк по ' — ' или ' ')
font_size = max(11, min(14, int(size / 40)))
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"])
col = item.get("dimension_color") or "#2F184B"
val = int(item["value"])
word_lines = _wrap_label(item["dimension_name"])
# Вертикальное центрирование: сдвигаем вверх на половину суммарной высоты
line_h = font_size + 3
total_h = line_h * len(word_lines) + (font_size + 2) # +строка со значением
y0 = ly - total_h / 2 + line_h
for k, part in enumerate(word_lines):
lines.append(
f'<text x="{lx:.2f}" y="{y0 + k * line_h:.2f}" text-anchor="middle" '
f'font-family="Arial,Helvetica,sans-serif" font-size="{font_size}" '
f'font-weight="bold" fill="{col}">{_xml_escape(part)}</text>'
)
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>'
f'<text x="{lx:.2f}" y="{y0 + len(word_lines) * line_h:.2f}" '
f'text-anchor="middle" font-family="Arial,Helvetica,sans-serif" '
f'font-size="{font_size + 2}" fill="#A070D0">{val}</text>'
)
lines.append("</svg>")
@@ -97,22 +136,66 @@ 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:
def _wrap_label(text: str, max_chars: int = 14) -> list[str]:
"""
Разбивает длинные подписи осей на строки.
Сначала пробует разрезать по '', затем по пробелам.
"""
if len(text) <= max_chars:
return [text]
# разрезаем по длинному тире
if "" in text:
parts = text.split("", 1)
return [parts[0] + "", parts[1]]
# разрезаем по последнему пробелу ≤ max_chars
idx = text.rfind(" ", 0, max_chars)
if idx == -1:
return [text]
return [text[:idx], text[idx + 1:]]
def save_radar_svg(
result_id: str,
items: list[dict],
max_val: float = 0.0,
poll_title: str = "unknown",
user_id: str = "anonymous",
size: int = 520,
scale_padding: float = 0.2,
min_scale: float = 5.0,
) -> str:
"""
Генерирует SVG и сохраняет его на диск.
Возвращает относительный путь к файлу (относительно APP_PATH).
Файл пишется в: {APP_PATH}/data/radar/{result_id}.svg
Возвращает: data/radar/{result_id}.svg
Файл пишется в: {APP_PATH}/data/radar/{poll_title}/{user_id}/{result_id}.svg
Возвращает: data/radar/{poll_title}/{user_id}/{result_id}.svg
max_val=0 — автомасштаб (рекомендуется)
scale_padding — запас над максимумом (0.2 = +20%)
min_scale — нижняя граница шкалы
"""
import re
def _safe(s: str) -> str:
s = re.sub(r"[^\w\-. ]", "_", s, flags=re.UNICODE)
return s.strip("_. ").replace(" ", "_") or "_"
app_path = os.getenv("APP_PATH", "/app")
radar_dir = os.path.join(app_path, "data", "radar")
os.makedirs(radar_dir, exist_ok=True)
rel_dir = os.path.join("data", "radar", _safe(poll_title), str(user_id))
full_dir = os.path.join(app_path, rel_dir)
os.makedirs(full_dir, exist_ok=True)
filename = f"{result_id}.svg"
full_path = os.path.join(radar_dir, filename)
svg = generate_radar_svg(items, max_val)
full_path = os.path.join(full_dir, filename)
svg = generate_radar_svg(
items,
max_val=max_val,
size=size,
scale_padding=scale_padding,
min_scale=min_scale,
)
with open(full_path, "w", encoding="utf-8") as f:
f.write(svg)
return f"data/radar/{filename}"
return os.path.join(rel_dir, filename)