From fe078b8560cf956af7bd4b32cabdc9cfdbf5f5e7 Mon Sep 17 00:00:00 2001 From: jze9 Date: Thu, 2 Apr 2026 14:14:08 +0500 Subject: [PATCH] new view testing --- docker-compose.yml | 3 +- main.py | 3 +- route/glomshtok_crud.py | 504 +++++++++++++++++++++++++++ web/views/content_users/poll_page.py | 426 +++++++++++++--------- web/views/poll_view.py | 1 - 5 files changed, 772 insertions(+), 165 deletions(-) create mode 100644 route/glomshtok_crud.py diff --git a/docker-compose.yml b/docker-compose.yml index d49cf7b..52a805f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,8 @@ services: build: context: . dockerfile: Dockerfile.api - # ports НЕ публикуются — API доступен только внутри Docker-сети + ports: + - "8000:8000" environment: - APP_PATH=/app - DEBUG=True diff --git a/main.py b/main.py index 69453e3..7dd7b15 100644 --- a/main.py +++ b/main.py @@ -6,7 +6,7 @@ from fastapi import Depends, FastAPI, HTTPException, status from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html 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 bd import wait_for_db import uvicorn @@ -71,6 +71,7 @@ app.include_router(response_crud.router) app.include_router(poll_crud.router) app.include_router(holland_crud.router) app.include_router(klimov_crud.router) +app.include_router(glomshtok_crud.router) app.include_router(scale_crud.router) app.include_router(radar_crud.router) diff --git a/route/glomshtok_crud.py b/route/glomshtok_crud.py new file mode 100644 index 0000000..5bcc87d --- /dev/null +++ b/route/glomshtok_crud.py @@ -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///.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/." + ), + } diff --git a/web/views/content_users/poll_page.py b/web/views/content_users/poll_page.py index 91c1e3a..91d05c4 100644 --- a/web/views/content_users/poll_page.py +++ b/web/views/content_users/poll_page.py @@ -8,23 +8,19 @@ class PollPage(ft.Column): def __init__(self, page: ft.Page, poll_id: str): self._page = page self._poll_id = poll_id - self._answers: dict[str, str] = {} # question_id -> choice_id - self._error_text = ds.CastomText(text="", size=13, color="#FF6B6B") - self._submit_btn = ds.CastomButton( - text="Отправить ответы", - on_click=self._submit, - ) - self._submit_btn.visible = False - self._loading = ft.ProgressRing(width=40, height=40, color=ds.clolors.laer1) + self._answers: dict[str, str] = {} + self._questions: list[dict] = [] + self._current: int = 0 super().__init__( + expand=True, alignment=ft.MainAxisAlignment.START, horizontal_alignment=ft.CrossAxisAlignment.STRETCH, - spacing=10, + spacing=0, controls=[ ft.Row( 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): poll = await api_client.get_poll(self._poll_id) 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: - self.controls = [ds.CastomText(text="Тест не найден", size=16, color="#FF6B6B")] + self.controls = [ds.CastomText(text="Тест не найден", size=16)] self.update() - def _build_choice_group(self, choices: list, qid: str) -> ft.Column: - """Кастомные кнопки выбора с переносом текста вместо ft.Radio.""" - containers: list[ft.Container] = [] + def _render_question(self): + q = self._questions[self._current] + 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( border_radius=8, padding=ft.padding.symmetric(horizontal=12, vertical=10), - bgcolor=ds.clolors.laer3, - border=ft.border.all(2, ds.clolors.laer3), + bgcolor=ds.clolors.laer1 if is_selected else ds.clolors.laer3, content=ft.Row( spacing=10, vertical_alignment=ft.CrossAxisAlignment.CENTER, controls=[ ft.Icon( - ft.Icons.RADIO_BUTTON_UNCHECKED, + ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED, color=ds.clolors.ferst, size=20, ), ft.Text( - value=choice_text, + value=c["text"], color=ds.clolors.ferst, size=14, 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: - tile = make_tile(c["id"], c["text"]) - tile.on_click = on_tile_click - tile.ink = True - containers.append(tile) + tile = make_tile(c) + tile.on_click = lambda e, _qid=q["id"]: self._select_answer(_qid, e.control.data) + choice_tiles.append(tile) - return ft.Column(controls=containers, 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) + choices_col = ft.Column(controls=choice_tiles, spacing=6) self.controls = [ - ft.Container( - padding=ft.padding.only(bottom=4), - content=ft.Column( - spacing=4, - controls=[ - ds.CastomText( - text=poll.get("title", ""), - size=20, - weight=ft.FontWeight.BOLD, - ), - ds.CastomText( - text=poll.get("description") or "", - size=13, - ), - ], - ), + ft.Column( + expand=True, + spacing=12, + controls=[ + nav_row, + question_box, + choices_col, + ], + ) + ] + + # ------------------------------------------------------------------ + # Обработчики + # ------------------------------------------------------------------ + 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, - self._error_text, + data="__finish__", + style=ft.ButtonStyle( + bgcolor=ds.clolors.laer1, + color=ds.clolors.ferst, + ), + on_click=self._submit, + ) + col.controls.append( ft.Row( alignment=ft.MainAxisAlignment.END, - controls=[self._submit_btn], - ), - ] - self._submit_btn.visible = True + controls=[finish_btn], + ) + ) - async def _submit(self, e): - poll = getattr(self, "_poll", None) - if not poll: - return + def _go(self, idx: int): + self._current = idx + self._render_question() + 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] if unanswered: - self._error_text.value = f"Ответьте на все вопросы ({len(unanswered)} осталось)" - self._error_text.update() + first_idx = questions.index(unanswered[0]) + self._go(first_idx) return - self._error_text.value = "" - self._submit_btn.disabled = True - self._submit_btn.update() + url_key = self._page.session.store.get("url_key") or "" + + self.controls = [ + ft.Row( + alignment=ft.MainAxisAlignment.CENTER, + controls=[ft.ProgressRing(width=48, height=48, color=ds.clolors.laer1)], + ) + ] + self.update() answers = [ {"question_id": qid, "choice_id": cid, "text": None} @@ -170,82 +267,87 @@ class PollPage(ft.Column): ) 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 = [ - ft.Row( + ft.Column( 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() + return - 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 + 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 + ) - success_controls: list[ft.Control] = [ - ft.Icon(ft.Icons.CHECK_CIRCLE_OUTLINE, size=48, color=ds.clolors.laer1), - ds.CastomText(text="Тест пройден!", size=20, weight=ft.FontWeight.BOLD), - ] + success_controls: list[ft.Control] = [ + ft.Icon(ft.Icons.CHECK_CIRCLE_OUTLINE, size=48, color=ds.clolors.laer1), + ds.CastomText(text="Тест пройден!", size=20, weight=ft.FontWeight.BOLD), + ] - if svg_bytes and radar and radar.get("image_url"): - b64 = base64.b64encode(svg_bytes).decode() - data_uri = f"data:image/svg+xml;base64,{b64}" - 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, - ) - ], - ) - ) + if svg_bytes and radar and radar.get("image_url"): + b64 = base64.b64encode(svg_bytes).decode() + data_uri = f"data:image/svg+xml;base64,{b64}" + download_url = api_client.build_radar_download_url(self._page.url, response_id) success_controls.append( - ds.CastomButton( - text="Вернуться к тестам", - on_click=lambda _: self._page.go(f"/take_test/{url_key}"), + ft.Container( + expand=True, + content=ft.Image( + src=data_uri, + expand=True, + height=380, + fit=ft.BoxFit.CONTAIN, + ), ) ) - 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, - ), + 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, + ) + ], ) - ] - 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() diff --git a/web/views/poll_view.py b/web/views/poll_view.py index 82c1264..3b3adba 100644 --- a/web/views/poll_view.py +++ b/web/views/poll_view.py @@ -21,7 +21,6 @@ class PollView(ft.View): padding=ft.padding.symmetric(horizontal=12, vertical=8), horizontal_alignment=ft.CrossAxisAlignment.STRETCH, vertical_alignment=ft.MainAxisAlignment.START, - scroll=ft.ScrollMode.AUTO, controls=[ ft.Column( expand=True,