new view testing

This commit is contained in:
2026-04-02 14:14:08 +05:00
parent 8a189e310b
commit fe078b8560
5 changed files with 772 additions and 165 deletions

View File

@@ -3,7 +3,8 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.api dockerfile: Dockerfile.api
# ports НЕ публикуются — API доступен только внутри Docker-сети ports:
- "8000:8000"
environment: environment:
- APP_PATH=/app - APP_PATH=/app
- DEBUG=True - DEBUG=True

View File

@@ -6,7 +6,7 @@ from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.security import HTTPBasic, HTTPBasicCredentials
from route import base, holland_crud, klimov_crud, init_data_base, groups_crud, organizations_crud, users_crud, question_crud, choice_crud, response_crud, poll_crud, scale_crud, radar_crud from route import base, holland_crud, klimov_crud, glomshtok_crud, init_data_base, groups_crud, organizations_crud, users_crud, question_crud, choice_crud, response_crud, poll_crud, scale_crud, radar_crud
from route import auth from route import auth
from bd import wait_for_db from bd import wait_for_db
import uvicorn import uvicorn
@@ -71,6 +71,7 @@ app.include_router(response_crud.router)
app.include_router(poll_crud.router) app.include_router(poll_crud.router)
app.include_router(holland_crud.router) app.include_router(holland_crud.router)
app.include_router(klimov_crud.router) app.include_router(klimov_crud.router)
app.include_router(glomshtok_crud.router)
app.include_router(scale_crud.router) app.include_router(scale_crud.router)
app.include_router(radar_crud.router) app.include_router(radar_crud.router)

504
route/glomshtok_crud.py Normal file
View File

@@ -0,0 +1,504 @@
"""Эндпоинты для работы с тестом «Карта интересов»
(А.Е. Гломшток, модификация А.А. Азабель, А.Г. Грецова).
POST /polls/glomshtok — создать опрос в БД (144 утверждения, 5 вариантов ответа)
POST /polls/glomshtok/{id}/seed-scores — загрузить ключ расшифровки (24 сферы интересов)
GET /polls/glomshtok — список тестов «Карта интересов»
Ключ: вопрос с position=N относится к измерению dimension = ((N - 1) % 24) + 1.
Пример: вопросы 1, 25, 49, 73, 97, 121 → Биология; вопросы 13, 37, 61, 85, 109, 133 → Военные специальности.
Варианты ответа и баллы: ++ = +2 | + = +1 | 0 = 0 | - = 1 | -- = 2
Интерпретация суммы по сфере (6 вопросов × ±2, диапазон 12..+12):
12..6 → область деятельности активно отрицается («Что угодно, только не это!»)
5..1 → область деятельности интереса не вызывает
+1..+4 → интерес выражен слабо
+5..+7 → выраженный интерес
+8..+12 → ярко выраженный интерес
После прохождения теста (POST /polls/{id}/responses) диаграмма строится автоматически
и сохраняется в data/radar/<poll_title>/<user_id>/<result_id>.svg.
"""
from fastapi import APIRouter, HTTPException
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")
# ---------------------------------------------------------------------------
# 144 утверждения «Карты интересов»
# Порядок строго соответствует PDF; индекс + 1 = номер вопроса.
# ---------------------------------------------------------------------------
STATEMENTS: list[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
"Изучать спрос покупателей на определённый товар.", # 43
"Читать специальные спортивные новости в газетах и на сайтах.", # 44
"Исполнять произведения на музыкальных инструментах с использованием различных техник игры.", # 45
"Изучать основы сценического искусства, творчество знаменитых мастеров сцены.", # 46
"Искать наиболее рациональное и эстетическое цветовое решение для интерьеров.", # 47
"Проводить наблюдения и контроль загрязнения окружающей среды.", # 48
"Изучать анатомию и физиологию животных.", # 49
"Собирать установки для проведения физических экспериментов.", # 50
"В лабораторных условиях определять степень загрязнённости почвы химическими веществами.", # 51
"Создавать ландшафтные карты с помощью компьютерных геоинформационных систем.", # 52
"Интересоваться причинами и способами лечения болезни.", # 53
"Прокладывать сетевые кабели и кабели силового питания.", # 54
"Изучать новые технологии строительства.", # 55
"Изучать языки программирования на компьютере.", # 56
"Осуществлять финансовые расчёты между предприятиями.", # 57
"Читать литературу на иностранном языке или смотреть фильмы без перевода.", # 58
"Управлять современным поездом дальнего следования.", # 59
"Оказывать экстренную помощь людям на терпящих бедствие судах.", # 60
"Быть военным инженером или командиром.", # 61
"Обсуждать исторические и текущие политические события России и других стран.", # 62
"Подбирать цветовые оттенки, красить дома, расписывать стены.", # 63
"Добывать эксклюзивную информацию, передавать её читателям и зрителям.", # 64
"Контролировать своевременную уплату налогов фирмами и физическими лицами.", # 65
"Обучать маленьких детей, играя с ними.", # 66
"Встречать и размещать пассажиров в салоне самолёта.", # 67
"Тренироваться в профессиональном спортивном клубе.", # 68
"Играть в музыкальной группе или в оркестре, следуя указаниям дирижёра.", # 69
"Вести концертные программы, объявлять зрителям новые номера программы.", # 70
"Создавать необычные модели одежды.", # 71
"Разрабатывать меры по снижению количества вредных производственных выбросов в окружающую среду.", # 72
"Разрабатывать средства борьбы с возбудителями заболеваний животных и растений.", # 73
"Изучать процессы взаимодействия элементарных частиц в ядерных реакторах.", # 74
"Брать на анализ химические пробы воды, продуктов питания, почвы и пр.", # 75
"Вести разведку месторождений полезных ископаемых: нефти, газа, драгоценных металлов и др.", # 76
"Тренироваться в навыках первой медицинской помощи.", # 77
"Проверять, испытывать, регулировать работу узлов автомобиля, самолёта, корабля и т. д.", # 78
"Готовить растворы, смеси для строительных работ.", # 79
"Разрабатывать программные алгоритмы для выполнения сложных расчётов.", # 80
"Вести финансовую документацию фирмы, предприятия.", # 81
"Изучать иностранные языки.", # 82
"Регулировать движение транспортных потоков на улицах города.", # 83
"Оказывать экстренную помощь людям «с воздуха» (например, управляя вертолётом МЧС).", # 84
"Изучать устройство оружия, военной техники.", # 85
"Анализировать по книгам исторические факты.", # 86
"Обеспечивать в домах систему отопления, исправную работу водопровода.", # 87
"Редактировать тексты книг, статей, выступлений высокопоставленных лиц.", # 88
"Выдвигать обвинения преступникам, назначать им наказания в соответствии с законом.", # 89
"Готовить школьников к олимпиадным заданиям, конкурсам.", # 90
"Помогать подбирать людям наряды и украшения для торжественных церемоний.", # 91
"Сдавать спортивные нормативы.", # 92
"Выступать в качестве солиста перед публикой на концертах, в клубах.", # 93
"Подбирать актёров для съёмки художественного фильма или рекламного клипа.", # 94
"Делать фотоснимки, монтировать фотографии с помощью компьютера.", # 95
"Заниматься мониторингом состояния воздуха на оживлённых городских магистралях.", # 96
"Отбирать и заготавливать семена для посадки, высаживать растения в зимних садах.", # 97
"Моделировать на компьютере физические законы природы.", # 98
"Создавать новые синтетические вещества из нефтепродуктов и других полезных ископаемых.", # 99
"Изучать особенности флоры и фауны различных регионов.", # 100
"Осуществлять уход за человеком во время его выздоровления после болезни.", # 101
"Заниматься в технической или электротехнической секции (например, в авиа- или судомоделировании).", # 102
"Изучать качества и условия применения строительных материалов.", # 103
"Писать компьютерные программы на основе анализа математических алгоритмов.", # 104
"Рассчитывать предполагаемый размер прибыли предприятия.", # 105
"Осуществлять перевод телепередач на русский язык.", # 106
"Консультировать людей по соблюдению правил дорожного движения.", # 107
"Изучать особенности управления яхтой в штормовую погоду.", # 108
"Предупреждать незаконное пересечение государственной границы иностранными гражданами.", # 109
"Разыскивать и собирать материалы, свидетельствующие о событиях прошлого.", # 110
"Обрабатывать и изготовлять металлические детали на фрезерном станке.", # 111
"Работать в средствах массовой информации, вести телепередачи.", # 112
"Выяснять у людей причины незаконных поступков, которые они скрывают.", # 113
"Организовывать походы для школьников.", # 114
"Разрабатывать рекламные акции для продвижения товара в магазинах города.", # 115
"Ездить в качестве участника на спортивные соревнования в другой город.", # 116
"Сочинять музыку для кинофильмов, телепередач, для известных исполнителей.", # 117
"Подготавливать и ставить трюки в кино.", # 118
"Придумывать оригинальные ювелирные изделия, украшения.", # 119
"Изучать взаимоотношения живых организмов с их средой обитания.", # 120
"Заниматься дрессировкой служебных собак или других животных.", # 121
"Читать книги типа «Занимательная физика», «Физики шутят» и т. п.", # 122
"Управлять технологическими процессами производства лекарств.", # 123
"Участвовать в географических экспедициях.", # 124
"Работать врачом на станции «Скорой помощи».", # 125
"Ремонтировать радиоприборы и различную аппаратуру.", # 126
"Продумывать планировку домов, этажей, квартир. Намечать план строительства.", # 127
"Отражать в виде чисел и формул какие-либо события, процессы или явления.", # 128
"Заключать сделки, договоры на выполнение определённых работ.", # 129
"Изучать происхождение слов и различных словосочетаний в разных языках.", # 130
"Тренироваться водить машину или мотоцикл для подготовки к гонке.", # 131
"Изучать особенности управления лёгким самолётом в ветреную погоду.", # 132
"Жить согласно уставу, носить военную форму.", # 133
"Участвовать в археологических экспедициях, работать на раскопках.", # 134
"Делать из дерева различные предметы, мебель и пр.", # 135
"Осуществлять литературную обработку статей в соответствии с жанровым своеобразием.", # 136
"Продумывать новые законы и поправки в кодексы, которые были бы удобны для применения.", # 137
"Руководить одной из секций в доме детского творчества.", # 138
"Помогать человеку улучшить внешность с помощью причёски, макияжа, подбора красивой одежды и т. п.", # 139
"Осуществлять честное и грамотное судейство на спортивных соревнованиях.", # 140
"Изучать творчество выдающихся композиторов, поэтов песен и исполнителей.", # 141
"Создавать творческие проекты, «раскручивать» исполнителей.", # 142
"Заниматься дизайном интерьеров.", # 143
"Следить за качеством воды, поступающей в городскую водопроводную сеть.", # 144
]
# ---------------------------------------------------------------------------
# 24 сферы интересов
# Порядок соответствует столбцам бланка ответов из PDF.
# ---------------------------------------------------------------------------
DIMENSIONS = [
{
"code": "1", "name": "Биология", "position": 1, "color": "#6B8E5E",
"description": (
"Интерес к живой природе, биологии, зоологии, ботанике, экспедициям. "
"Рекомендуемые профессии: биолог, зоолог, ботаник, агроном, ветеринар, эколог."
),
},
{
"code": "2", "name": "Физика", "position": 2, "color": "#4A7FA5",
"description": (
"Интерес к физическим законам, экспериментам, моделированию. "
"Рекомендуемые профессии: физик, инженер-физик, научный сотрудник, метролог."
),
},
{
"code": "3", "name": "Химия", "position": 3, "color": "#9B59B6",
"description": (
"Интерес к химическим процессам, лабораторным опытам, синтезу веществ. "
"Рекомендуемые профессии: химик, технолог, лаборант, провизор."
),
},
{
"code": "4", "name": "География", "position": 4, "color": "#27AE60",
"description": (
"Интерес к изучению Земли, природных ресурсов, экспедициям, геоинформатике. "
"Рекомендуемые профессии: географ, геолог, картограф, метеоролог."
),
},
{
"code": "5", "name": "Медицина", "position": 5, "color": "#E74C3C",
"description": (
"Интерес к здоровью, лечению людей и животных, медицинским технологиям. "
"Рекомендуемые профессии: врач, медсестра, фельдшер, фармацевт."
),
},
{
"code": "6", "name": "Техника и электроника", "position": 6, "color": "#2980B9",
"description": (
"Интерес к приборам, компьютерам, электронике, ремонту техники. "
"Рекомендуемые профессии: инженер, электроник, системный администратор, радиотехник."
),
},
{
"code": "7", "name": "Строительство", "position": 7, "color": "#795548",
"description": (
"Интерес к строительным технологиям, архитектуре, планировке зданий. "
"Рекомендуемые профессии: архитектор, прораб, строитель, проектировщик."
),
},
{
"code": "8", "name": "Математика", "position": 8, "color": "#1ABC9C",
"description": (
"Интерес к числам, формулам, задачам, алгоритмам, программированию. "
"Рекомендуемые профессии: математик, программист, статистик, аналитик данных."
),
},
{
"code": "9", "name": "Экономика", "position": 9, "color": "#F39C12",
"description": (
"Интерес к финансам, торговле, производственному планированию. "
"Рекомендуемые профессии: экономист, бухгалтер, финансист, менеджер."
),
},
{
"code": "10", "name": "Иностранные языки", "position": 10, "color": "#3498DB",
"description": (
"Интерес к языкам, переводу, межкультурным коммуникациям. "
"Рекомендуемые профессии: переводчик, лингвист, преподаватель иностранного языка."
),
},
{
"code": "11", "name": "Транспорт", "position": 11, "color": "#E67E22",
"description": (
"Интерес к управлению транспортом, логистике, ПДД. "
"Рекомендуемые профессии: водитель, машинист, диспетчер, логист."
),
},
{
"code": "12", "name": "Авиация, морское дело", "position": 12, "color": "#16A085",
"description": (
"Интерес к полётам, мореплаванию, экстремальным видам деятельности. "
"Рекомендуемые профессии: лётчик, штурман, капитан, водолаз, парашютист."
),
},
{
"code": "13", "name": "Военные специальности", "position": 13, "color": "#2C3E50",
"description": (
"Интерес к армии, военной технике, патриотическим видам деятельности. "
"Рекомендуемые профессии: офицер, пограничник, военный инженер, командир."
),
},
{
"code": "14", "name": "История", "position": 14, "color": "#8E6B3E",
"description": (
"Интерес к истории, политике, археологии, изучению прошлого. "
"Рекомендуемые профессии: историк, археолог, политолог, архивист."
),
},
{
"code": "15", "name": "Рабочие специальности", "position": 15, "color": "#7F8C8D",
"description": (
"Интерес к ручному труду, ремёслам, работе на станках. "
"Рекомендуемые профессии: слесарь, токарь, столяр, сантехник, маляр."
),
},
{
"code": "16", "name": "Журналистика", "position": 16, "color": "#C0392B",
"description": (
"Интерес к написанию текстов, СМИ, репортажам, медиапроизводству. "
"Рекомендуемые профессии: журналист, редактор, корреспондент, телеведущий."
),
},
{
"code": "17", "name": "Юриспруденция", "position": 17, "color": "#1F3A5F",
"description": (
"Интерес к законам, правопорядку, криминалистике, судопроизводству. "
"Рекомендуемые профессии: юрист, адвокат, судья, следователь, нотариус."
),
},
{
"code": "18", "name": "Педагогика", "position": 18, "color": "#D4A84B",
"description": (
"Интерес к обучению, воспитанию, работе с детьми. "
"Рекомендуемые профессии: учитель, воспитатель, педагог-психолог, тренер."
),
},
{
"code": "19", "name": "Сфера обслуживания, торговля", "position": 19, "color": "#F1948A",
"description": (
"Интерес к торговле, обслуживанию клиентов, маркетингу, сервису. "
"Рекомендуемые профессии: продавец, менеджер по продажам, администратор, стилист."
),
},
{
"code": "20", "name": "Физкультура и спорт", "position": 20, "color": "#28B463",
"description": (
"Интерес к спорту, тренировкам, соревнованиям, физическому развитию. "
"Рекомендуемые профессии: спортсмен, тренер, инструктор, спортивный судья."
),
},
{
"code": "21", "name": "Музыка", "position": 21, "color": "#A569BD",
"description": (
"Интерес к музыке, игре на инструментах, сочинению, звукорежиссуре. "
"Рекомендуемые профессии: музыкант, композитор, дирижёр, звукорежиссёр."
),
},
{
"code": "22", "name": "Сценическое искусство", "position": 22, "color": "#EC407A",
"description": (
"Интерес к театру, кино, сцене, перевоплощению, продюсированию. "
"Рекомендуемые профессии: актёр, режиссёр, сценарист, продюсер."
),
},
{
"code": "23", "name": "Изобразительное искусство", "position": 23, "color": "#FF8C42",
"description": (
"Интерес к рисованию, дизайну, фотографии, созданию визуальных образов. "
"Рекомендуемые профессии: художник, дизайнер, фотограф, ювелир."
),
},
{
"code": "24", "name": "Экология", "position": 24, "color": "#2ECC71",
"description": (
"Интерес к охране природы, экологическому мониторингу, контролю загрязнений. "
"Рекомендуемые профессии: эколог, природоохранный инспектор, санитарный врач."
),
},
]
# ---------------------------------------------------------------------------
# Шкала оценок: позиция варианта ответа → числовой балл
# ---------------------------------------------------------------------------
CHOICE_SCORES: dict[int, float] = {
1: 2.0, # ++
2: 1.0, # +
3: 0.0, # 0
4: -1.0, # -
5: -2.0, # --
}
CHOICE_LABELS: list[str] = ["++", "+", "0", "-", "--"]
def get_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=make_engine(), future=True)
@router.post("/glomshtok", status_code=201)
def create_glomshtok_poll():
"""
Создаёт тест «Карта интересов» в базе данных (144 утверждения, 5 вариантов ответа).
После создания вызовите POST /polls/glomshtok/{id}/seed-scores для загрузки ключа расшифровки.
"""
Session = get_session()
with Session() as session:
poll = Poll(
title="Карта интересов — А.Е. Гломшток (модификация А.А. Азабель, А.Г. Грецова)",
description=(
"Методика «Карта интересов» для профориентации. Оцените 144 утверждения по шкале: "
"«++» (очень хотелось бы), «+» (нравится), «0» (сомневаюсь), "
"«-» (не нравится), «--» (совершенно не нравится). "
"Определяет выраженность интересов в 24 сферах деятельности."
),
)
session.add(poll)
for idx, text in enumerate(STATEMENTS, start=1):
q = Question(text=text, position=idx, type="single", poll=poll)
session.add(q)
for pos, label in enumerate(CHOICE_LABELS, start=1):
ch = Choice(question=q, text=label, position=pos)
session.add(ch)
session.commit()
session.refresh(poll)
return {"id": str(poll.id), "questions": len(poll.questions)}
@router.get("/glomshtok")
def list_glomshtok_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("/glomshtok/{poll_id}/seed-scores", status_code=201)
def seed_glomshtok_scores(poll_id: str):
"""
Загружает ключ расшифровки «Карты интересов» в базу данных:
- создаёт 24 оси диаграммы (ScaleDimension) для указанного теста
- для каждого из 144 вопросов привязывает 5 вариантов ответа к соответствующей оси (ChoiceScore)
с баллами: ++ = +2 | + = +1 | 0 = 0 | - = 1 | -- = 2
Формула: вопрос с position=N → измерение ((N 1) % 24) + 1
При повторном вызове оси и расшифровки пересоздаются.
"""
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) != 144:
raise HTTPException(
status_code=422,
detail=(
f"Ожидается 144 вопроса, найдено {len(questions)}. "
"Убедитесь, что тест создан через POST /polls/glomshtok."
),
)
# Удаляем старые оси (ChoiceScore удаляются каскадно)
old_dims = session.query(ScaleDimension).filter(ScaleDimension.poll_id == pid).all()
for d in old_dims:
session.delete(d)
session.flush()
# Создаём 24 оси: position → объект
dim_map: dict[int, 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["position"]] = dim
# Для каждого вопроса привязываем все 5 вариантов к соответствующей оси
scores_created = 0
for q in questions:
dim_idx = ((q.position - 1) % 24) + 1
dim = dim_map.get(dim_idx)
if dim is None:
continue
choices = (
session.query(Choice)
.filter(Choice.question_id == q.id)
.order_by(Choice.position.asc())
.all()
)
for ch in choices:
score_val = CHOICE_SCORES.get(ch.position, 0.0)
session.add(ChoiceScore(choice_id=ch.id, dimension_id=dim.id, score=score_val))
scores_created += 1
session.commit()
return {
"status": "ok",
"dimensions": len(DIMENSIONS),
"choice_scores": scores_created,
"message": (
"Расшифровка «Карты интересов» успешно загружена. "
"После прохождения теста диаграмма строится автоматически и "
"сохраняется в data/radar/."
),
}

View File

@@ -8,23 +8,19 @@ class PollPage(ft.Column):
def __init__(self, page: ft.Page, poll_id: str): def __init__(self, page: ft.Page, poll_id: str):
self._page = page self._page = page
self._poll_id = poll_id self._poll_id = poll_id
self._answers: dict[str, str] = {} # question_id -> choice_id self._answers: dict[str, str] = {}
self._error_text = ds.CastomText(text="", size=13, color="#FF6B6B") self._questions: list[dict] = []
self._submit_btn = ds.CastomButton( self._current: int = 0
text="Отправить ответы",
on_click=self._submit,
)
self._submit_btn.visible = False
self._loading = ft.ProgressRing(width=40, height=40, color=ds.clolors.laer1)
super().__init__( super().__init__(
expand=True,
alignment=ft.MainAxisAlignment.START, alignment=ft.MainAxisAlignment.START,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH, horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
spacing=10, spacing=0,
controls=[ controls=[
ft.Row( ft.Row(
alignment=ft.MainAxisAlignment.CENTER, alignment=ft.MainAxisAlignment.CENTER,
controls=[self._loading], controls=[ft.ProgressRing(width=40, height=40, color=ds.clolors.laer1)],
) )
], ],
) )
@@ -32,32 +28,105 @@ class PollPage(ft.Column):
async def load(self): async def load(self):
poll = await api_client.get_poll(self._poll_id) poll = await api_client.get_poll(self._poll_id)
if poll: if poll:
self._render_poll(poll) self._poll = poll
self._questions = poll.get("questions", [])
if self._questions:
self._current = 0
self._render_question()
else:
self.controls = [ds.CastomText(text="В тесте нет вопросов", size=16)]
else: else:
self.controls = [ds.CastomText(text="Тест не найден", size=16, color="#FF6B6B")] self.controls = [ds.CastomText(text="Тест не найден", size=16)]
self.update() self.update()
def _build_choice_group(self, choices: list, qid: str) -> ft.Column: def _render_question(self):
"""Кастомные кнопки выбора с переносом текста вместо ft.Radio.""" q = self._questions[self._current]
containers: list[ft.Container] = [] total = len(self._questions)
idx = self._current
def make_tile(choice_id: str, choice_text: str) -> ft.Container: # --- Навигационная панель ---
prev_btn = ds.CastomButton(
text="← Предыдущий вопрос" if idx > 0 else "",
on_click=lambda _: self._go(self._current - 1),
)
if idx == 0:
prev_btn.disabled = True
stop_btn = ft.Button(
content=ft.Row(
tight=True,
spacing=6,
controls=[
ft.Icon(ft.Icons.STOP_CIRCLE_OUTLINED, color=ds.clolors.ferst, size=16),
ft.Text("Прервать тест", color=ds.clolors.ferst, size=13),
],
),
style=ft.ButtonStyle(
bgcolor=ds.clolors.laer3,
color=ds.clolors.ferst,
),
on_click=self._confirm_abort,
)
counter_badge = ft.Container(
bgcolor=ds.clolors.laer2,
border_radius=10,
padding=ft.padding.symmetric(horizontal=20, vertical=10),
content=ft.Text(
f"Вопрос {idx + 1} из {total}",
color=ds.clolors.ferst,
size=14,
weight=ft.FontWeight.BOLD,
text_align=ft.TextAlign.CENTER,
),
)
nav_row = ft.Row(
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
controls=[prev_btn, counter_badge, stop_btn],
)
# --- Блок вопроса (alignment на Container, не вложенный Column) ---
question_box = ft.Container(
expand=True,
border_radius=14,
padding=24,
bgcolor=ds.clolors.laer2,
alignment=ft.Alignment(0.0, 0.0),
content=ft.Text(
value=q.get("text", ""),
color=ds.clolors.ferst,
size=16,
weight=ft.FontWeight.BOLD,
text_align=ft.TextAlign.CENTER,
no_wrap=False,
),
)
# --- Варианты ответа (Container-тайлы как в оригинальном рабочем коде) ---
choices = q.get("choices", [])
selected_choice = self._answers.get(q["id"])
choice_tiles: list[ft.Container] = []
def make_tile(c: dict) -> ft.Container:
cid = c["id"]
is_selected = cid == selected_choice
return ft.Container( return ft.Container(
border_radius=8, border_radius=8,
padding=ft.padding.symmetric(horizontal=12, vertical=10), padding=ft.padding.symmetric(horizontal=12, vertical=10),
bgcolor=ds.clolors.laer3, bgcolor=ds.clolors.laer1 if is_selected else ds.clolors.laer3,
border=ft.border.all(2, ds.clolors.laer3),
content=ft.Row( content=ft.Row(
spacing=10, spacing=10,
vertical_alignment=ft.CrossAxisAlignment.CENTER, vertical_alignment=ft.CrossAxisAlignment.CENTER,
controls=[ controls=[
ft.Icon( ft.Icon(
ft.Icons.RADIO_BUTTON_UNCHECKED, ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED,
color=ds.clolors.ferst, color=ds.clolors.ferst,
size=20, size=20,
), ),
ft.Text( ft.Text(
value=choice_text, value=c["text"],
color=ds.clolors.ferst, color=ds.clolors.ferst,
size=14, size=14,
expand=True, expand=True,
@@ -65,98 +134,126 @@ class PollPage(ft.Column):
), ),
], ],
), ),
data=choice_id, data=cid,
ink=True,
) )
def on_tile_click(e, _qid=qid, _containers=containers):
selected_id = e.control.data
self._answers[_qid] = selected_id
for tile in _containers:
is_selected = tile.data == selected_id
tile.bgcolor = ds.clolors.laer1 if is_selected else ds.clolors.laer3
tile.border = ft.border.all(2, ds.clolors.ferst if is_selected else ds.clolors.laer3)
row = tile.content
row.controls[0].name = ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED
tile.update()
for c in choices: for c in choices:
tile = make_tile(c["id"], c["text"]) tile = make_tile(c)
tile.on_click = on_tile_click tile.on_click = lambda e, _qid=q["id"]: self._select_answer(_qid, e.control.data)
tile.ink = True choice_tiles.append(tile)
containers.append(tile)
return ft.Column(controls=containers, spacing=6) choices_col = ft.Column(controls=choice_tiles, spacing=6)
def _render_poll(self, poll: dict):
self._poll = poll
question_cards = []
for q in poll.get("questions", []):
choices = q.get("choices", [])
input_widget = self._build_choice_group(choices, q["id"])
card = ft.Container(
border_radius=12,
padding=16,
margin=ft.margin.only(bottom=8),
bgcolor=ds.clolors.laer2,
content=ft.Column(
spacing=10,
controls=[
ds.CastomText(
text=q.get("text", ""),
size=15,
weight=ft.FontWeight.BOLD,
),
input_widget,
],
),
)
question_cards.append(card)
self.controls = [ self.controls = [
ft.Container( ft.Column(
padding=ft.padding.only(bottom=4), expand=True,
content=ft.Column( spacing=12,
spacing=4, controls=[
controls=[ nav_row,
ds.CastomText( question_box,
text=poll.get("title", ""), choices_col,
size=20, ],
weight=ft.FontWeight.BOLD, )
), ]
ds.CastomText(
text=poll.get("description") or "", # ------------------------------------------------------------------
size=13, # Обработчики
), # ------------------------------------------------------------------
], def _select_answer(self, question_id: str, choice_id: str):
), self._answers[question_id] = choice_id
total = len(self._questions)
if self._current < total - 1:
self._go(self._current + 1)
else:
self._render_question()
self._append_finish_button()
self.update()
def _append_finish_button(self):
col = self.controls[0]
for ctrl in col.controls:
if getattr(ctrl, "data", None) == "__finish__":
return
finish_btn = ft.Button(
content=ft.Text(
"Завершить тест →",
color=ds.clolors.ferst,
size=16,
weight=ft.FontWeight.BOLD,
), ),
*question_cards, data="__finish__",
self._error_text, style=ft.ButtonStyle(
bgcolor=ds.clolors.laer1,
color=ds.clolors.ferst,
),
on_click=self._submit,
)
col.controls.append(
ft.Row( ft.Row(
alignment=ft.MainAxisAlignment.END, alignment=ft.MainAxisAlignment.END,
controls=[self._submit_btn], controls=[finish_btn],
), )
] )
self._submit_btn.visible = True
async def _submit(self, e): def _go(self, idx: int):
poll = getattr(self, "_poll", None) self._current = idx
if not poll: self._render_question()
return q = self._questions[self._current]
if self._current == len(self._questions) - 1 and q["id"] in self._answers:
self._append_finish_button()
self.update()
questions = poll.get("questions", []) def _confirm_abort(self, _):
url_key = self._page.session.store.get("url_key") or ""
def _do_abort(_):
dlg.open = False
self._page.update()
self._page.go(f"/take_test/{url_key}")
def _cancel(_):
dlg.open = False
self._page.update()
dlg = ft.AlertDialog(
modal=True,
title=ft.Text("Прервать тест?"),
content=ft.Text("Все ответы будут потеряны. Вы уверены?"),
actions=[
ft.TextButton(
content="Отмена",
on_click=_cancel,
),
ft.TextButton(
content="Прервать",
style=ft.ButtonStyle(color=ds.clolors.laer1),
on_click=_do_abort,
),
],
actions_alignment=ft.MainAxisAlignment.END,
)
self._page.overlay.append(dlg)
dlg.open = True
self._page.update()
async def _submit(self, _):
questions = self._questions
unanswered = [q for q in questions if q["id"] not in self._answers] unanswered = [q for q in questions if q["id"] not in self._answers]
if unanswered: if unanswered:
self._error_text.value = f"Ответьте на все вопросы ({len(unanswered)} осталось)" first_idx = questions.index(unanswered[0])
self._error_text.update() self._go(first_idx)
return return
self._error_text.value = "" url_key = self._page.session.store.get("url_key") or ""
self._submit_btn.disabled = True
self._submit_btn.update() self.controls = [
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
controls=[ft.ProgressRing(width=48, height=48, color=ds.clolors.laer1)],
)
]
self.update()
answers = [ answers = [
{"question_id": qid, "choice_id": cid, "text": None} {"question_id": qid, "choice_id": cid, "text": None}
@@ -170,82 +267,87 @@ class PollPage(ft.Column):
) )
if error: if error:
self._error_text.value = error
self._submit_btn.disabled = False
self._submit_btn.update()
self._error_text.update()
else:
url_key = self._page.session.store.get("url_key") or ""
# Показываем лоадер пока грузим радар
self.controls = [ self.controls = [
ft.Row( ft.Column(
alignment=ft.MainAxisAlignment.CENTER, alignment=ft.MainAxisAlignment.CENTER,
controls=[ft.ProgressRing(width=40, height=40, color=ds.clolors.laer1)], horizontal_alignment=ft.CrossAxisAlignment.CENTER,
controls=[
ft.Icon(ft.Icons.ERROR_OUTLINE, size=48, color=ds.clolors.laer1),
ds.CastomText(text=error, size=14),
ds.CastomButton(
text="Попробовать снова",
on_click=lambda _: self._go(self._current),
),
],
) )
] ]
self.update() self.update()
return
radar = await api_client.get_radar_result(response_id) radar = await api_client.get_radar_result(response_id)
svg_bytes = await api_client.get_radar_svg_bytes(response_id) if radar and radar.get("image_url") else None svg_bytes = (
await api_client.get_radar_svg_bytes(response_id)
if radar and radar.get("image_url")
else None
)
success_controls: list[ft.Control] = [ success_controls: list[ft.Control] = [
ft.Icon(ft.Icons.CHECK_CIRCLE_OUTLINE, size=48, color=ds.clolors.laer1), ft.Icon(ft.Icons.CHECK_CIRCLE_OUTLINE, size=48, color=ds.clolors.laer1),
ds.CastomText(text="Тест пройден!", size=20, weight=ft.FontWeight.BOLD), ds.CastomText(text="Тест пройден!", size=20, weight=ft.FontWeight.BOLD),
] ]
if svg_bytes and radar and radar.get("image_url"): if svg_bytes and radar and radar.get("image_url"):
b64 = base64.b64encode(svg_bytes).decode() b64 = base64.b64encode(svg_bytes).decode()
data_uri = f"data:image/svg+xml;base64,{b64}" data_uri = f"data:image/svg+xml;base64,{b64}"
download_url = api_client.build_radar_download_url(self._page.url, response_id) download_url = api_client.build_radar_download_url(self._page.url, response_id)
success_controls.append(
ft.Container(
expand=True,
content=ft.Image(
src=data_uri,
expand=True,
height=380,
fit=ft.BoxFit.CONTAIN,
),
)
)
async def _download(_evt, _url=download_url):
await self._page.launch_url(_url)
success_controls.append(
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
controls=[
ds.CastomIconButton(
text="Сохранить диаграмму",
icon=ft.Icons.DOWNLOAD,
on_click=_download,
)
],
)
)
success_controls.append( success_controls.append(
ds.CastomButton( ft.Container(
text="Вернуться к тестам", expand=True,
on_click=lambda _: self._page.go(f"/take_test/{url_key}"), content=ft.Image(
src=data_uri,
expand=True,
height=380,
fit=ft.BoxFit.CONTAIN,
),
) )
) )
self.controls = [ async def _download(_evt, _url=download_url):
ft.Container( await self._page.launch_url(_url)
padding=20,
bgcolor=ds.clolors.laer3, success_controls.append(
border_radius=16, ft.Row(
expand=True, alignment=ft.MainAxisAlignment.CENTER,
content=ft.Column( controls=[
alignment=ft.MainAxisAlignment.CENTER, ds.CastomIconButton(
horizontal_alignment=ft.CrossAxisAlignment.CENTER, text="Сохранить диаграмму",
spacing=16, icon=ft.Icons.DOWNLOAD,
controls=success_controls, on_click=_download,
), )
],
) )
] )
self.update()
success_controls.append(
ds.CastomButton(
text="Вернуться к тестам",
on_click=lambda _: self._page.go(f"/take_test/{url_key}"),
)
)
self.controls = [
ft.Container(
padding=20,
bgcolor=ds.clolors.laer3,
border_radius=16,
expand=True,
content=ft.Column(
alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
spacing=16,
controls=success_controls,
),
)
]
self.update()

View File

@@ -21,7 +21,6 @@ class PollView(ft.View):
padding=ft.padding.symmetric(horizontal=12, vertical=8), padding=ft.padding.symmetric(horizontal=12, vertical=8),
horizontal_alignment=ft.CrossAxisAlignment.STRETCH, horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
vertical_alignment=ft.MainAxisAlignment.START, vertical_alignment=ft.MainAxisAlignment.START,
scroll=ft.ScrollMode.AUTO,
controls=[ controls=[
ft.Column( ft.Column(
expand=True, expand=True,