Initial commit
This commit is contained in:
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Используем официальный образ Python
|
||||||
|
FROM python:3.10
|
||||||
|
|
||||||
|
# Устанавливаем рабочую директорию
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Копируем файлы проекта в контейнер
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
# Устанавливаем зависимости
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Открываем порт Flask (по умолчанию 5000)
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# Запускаем приложение
|
||||||
|
CMD ["python", "-u", "main.py"]
|
||||||
0
__init__.py
Normal file
0
__init__.py
Normal file
BIN
__pycache__/models.cpython-310.pyc
Normal file
BIN
__pycache__/models.cpython-310.pyc
Normal file
Binary file not shown.
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15
|
||||||
|
container_name: postgres-survey
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: servey
|
||||||
|
POSTGRES_PASSWORD: utOgbZ09servey
|
||||||
|
POSTGRES_DB: survey
|
||||||
|
volumes:
|
||||||
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
flask-app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3333:5000"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- ./survey.db:/app/survey.db
|
||||||
|
environment:
|
||||||
|
- FLASK_ENV=development
|
||||||
|
- POSTGRES_USER=servey
|
||||||
|
- POSTGRES_PASSWORD=utOgbZ09servey
|
||||||
|
- POSTGRES_DB=survey
|
||||||
|
- POSTGRES_HOST=db
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data:
|
||||||
293
inquation.py
Normal file
293
inquation.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
|
||||||
|
from peewee import *
|
||||||
|
from models import SurveyType, Product, Feature, Platform, PlatformFeature
|
||||||
|
from models import initialize_db
|
||||||
|
initialize_db()
|
||||||
|
# Данные
|
||||||
|
questions = {
|
||||||
|
"Безопасность": {
|
||||||
|
"sec_fstec": "Имеется ли сертификат ФСТЭК у данной версии СУБД?",
|
||||||
|
"sec_registry": "Включена ли СУБД в Единый реестр российского ПО?",
|
||||||
|
"sec_masking": "Поддерживается ли маскирование данных?",
|
||||||
|
"sec_admin": "Реализована ли роль 'Администратор без доступа к данным'?",
|
||||||
|
"sec_memory_clean": "Осуществляется ли очистка выделенной оперативной и дисковой памяти?",
|
||||||
|
"sec_audit": "Поддерживается ли расширение pg_proaudit для аудита?",
|
||||||
|
"sec_integrity": "Доступна ли утилита pg_integrity_check для проверки целостности данных?",
|
||||||
|
"sec_profiles": "Поддерживаются ли профили пользователей?"
|
||||||
|
},
|
||||||
|
"Управляемость": {
|
||||||
|
"mgmt_ppem": "Предоставляется ли Postgres Pro Enterprise Manager для управления СУБД?",
|
||||||
|
"mgmt_online_move": "Поддерживается ли online-перемещение таблиц?"
|
||||||
|
},
|
||||||
|
"Надежность и Масштабируемость": {
|
||||||
|
"reliability_biha": "Реализован ли встроенный отказоустойчивый кластер BiHA?",
|
||||||
|
"reliability_multimaster": "Поддерживается ли отказоустойчивая конфигурация: мастер-мастер (мультимастер)?",
|
||||||
|
"reliability_pool": "Встроен ли пул соединений?",
|
||||||
|
"reliability_64bit_xid": "Используется ли 64-разрядный счетчик транзакций?",
|
||||||
|
"reliability_cfs": "Поддерживается ли сжатие данных (CFS)?",
|
||||||
|
"reliability_incremental_backup": "Поддерживается ли инкрементальное блочное резервное копирование?",
|
||||||
|
"reliability_partitioning": "Реализовано ли эффективное секционирование (десятки тысяч секций)?",
|
||||||
|
"reliability_autonomous_tx": "Поддерживаются ли автономные транзакции?",
|
||||||
|
"reliability_scheduler": "Встроен ли планировщик заданий?"
|
||||||
|
},
|
||||||
|
"Производительность": {
|
||||||
|
"perf_aqo": "Реализована ли интеллектуальная система адаптивной оптимизации запросов (AQO)?",
|
||||||
|
"perf_pwr": "Доступно ли расширение pgpro_pwr для анализа производительности?",
|
||||||
|
"perf_monitoring": "Предоставляется ли агент мониторинга Mamonsu?",
|
||||||
|
"perf_hints": "Поддерживаются ли хинты?",
|
||||||
|
"perf_covering_indexes": "Поддерживаются ли покрывающие индексы?",
|
||||||
|
"perf_knn": "Поддерживается ли индексный поиск ближайших соседей (KNN)?",
|
||||||
|
"perf_rum": "Поддерживается ли комбинирование полнотекстового индекса с индексом релевантности (RUM)?",
|
||||||
|
"perf_wait_sampling": "Поддерживается ли pg_wait_sampling – история и профиль ожиданий?"
|
||||||
|
},
|
||||||
|
"Разработка": {
|
||||||
|
"dev_plsql": "Поддерживаются ли PL/SQL-коллекции?",
|
||||||
|
"dev_bfile": "Поддерживаются ли внешние файлы (BFile)?",
|
||||||
|
"dev_superfile": "Реализована ли технология Postgres Pro Superfile для работы с большими данными?",
|
||||||
|
"dev_sql_json": "Поддерживается ли расширенная поддержка SQL/JSON?"
|
||||||
|
},
|
||||||
|
"Упрощение миграции с Oracle": {
|
||||||
|
"oracle_migration": "Облегчена ли миграция с Oracle (пакеты, ассоциативные массивы, автономные транзакции и т.д.)?"
|
||||||
|
},
|
||||||
|
"Совместимость": {
|
||||||
|
"comp_apps": "Совместима ли СУБД с 250+ популярными прикладными решениями?",
|
||||||
|
"comp_platforms": "Поддерживается ли работа на 35+ платформах?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
platforms = {
|
||||||
|
"Postgres Pro Enterprise": {
|
||||||
|
"sec_fstec": False,
|
||||||
|
"sec_registry": True,
|
||||||
|
"sec_masking": True,
|
||||||
|
"sec_admin": True,
|
||||||
|
"sec_memory_clean": False,
|
||||||
|
"sec_audit": True,
|
||||||
|
"sec_integrity": False,
|
||||||
|
"sec_profiles": True,
|
||||||
|
"mgmt_ppem": True,
|
||||||
|
"mgmt_online_move": True,
|
||||||
|
"reliability_biha": True,
|
||||||
|
"reliability_multimaster": True,
|
||||||
|
"reliability_pool": True,
|
||||||
|
"reliability_64bit_xid": True,
|
||||||
|
"reliability_cfs": True,
|
||||||
|
"reliability_incremental_backup": True,
|
||||||
|
"reliability_partitioning": True,
|
||||||
|
"reliability_autonomous_tx": True,
|
||||||
|
"reliability_scheduler": True,
|
||||||
|
"perf_aqo": True,
|
||||||
|
"perf_pwr": True,
|
||||||
|
"perf_monitoring": True,
|
||||||
|
"perf_hints": True,
|
||||||
|
"perf_covering_indexes": True,
|
||||||
|
"perf_knn": True,
|
||||||
|
"perf_rum": True,
|
||||||
|
"perf_wait_sampling": True,
|
||||||
|
"dev_plsql": True,
|
||||||
|
"dev_bfile": True,
|
||||||
|
"dev_superfile": True,
|
||||||
|
"dev_sql_json": True,
|
||||||
|
"oracle_migration": True,
|
||||||
|
"comp_apps": True,
|
||||||
|
"comp_platforms": True
|
||||||
|
},
|
||||||
|
"Postgres Pro Enterprise Certified": {
|
||||||
|
"sec_fstec": True,
|
||||||
|
"sec_registry": True,
|
||||||
|
"sec_masking": True,
|
||||||
|
"sec_admin": True,
|
||||||
|
"sec_memory_clean": True,
|
||||||
|
"sec_audit": True,
|
||||||
|
"sec_integrity": True,
|
||||||
|
"sec_profiles": True,
|
||||||
|
"mgmt_ppem": True,
|
||||||
|
"mgmt_online_move": True,
|
||||||
|
"reliability_biha": True,
|
||||||
|
"reliability_multimaster": True,
|
||||||
|
"reliability_pool": True,
|
||||||
|
"reliability_64bit_xid": True,
|
||||||
|
"reliability_cfs": True,
|
||||||
|
"reliability_incremental_backup": True,
|
||||||
|
"reliability_partitioning": True,
|
||||||
|
"reliability_autonomous_tx": True,
|
||||||
|
"reliability_scheduler": True,
|
||||||
|
"perf_aqo": True,
|
||||||
|
"perf_pwr": True,
|
||||||
|
"perf_monitoring": True,
|
||||||
|
"perf_hints": True,
|
||||||
|
"perf_covering_indexes": True,
|
||||||
|
"perf_knn": True,
|
||||||
|
"perf_rum": True,
|
||||||
|
"perf_wait_sampling": True,
|
||||||
|
"dev_plsql": True,
|
||||||
|
"dev_bfile": True,
|
||||||
|
"dev_superfile": True,
|
||||||
|
"dev_sql_json": True,
|
||||||
|
"oracle_migration": True,
|
||||||
|
"comp_apps": True,
|
||||||
|
"comp_platforms": True
|
||||||
|
},
|
||||||
|
"Postgres Pro Standard": {
|
||||||
|
"sec_fstec": False,
|
||||||
|
"sec_registry": True,
|
||||||
|
"sec_masking": False,
|
||||||
|
"sec_admin": False,
|
||||||
|
"sec_memory_clean": False,
|
||||||
|
"sec_audit": True,
|
||||||
|
"sec_integrity": False,
|
||||||
|
"sec_profiles": True,
|
||||||
|
"mgmt_ppem": True,
|
||||||
|
"mgmt_online_move": False,
|
||||||
|
"reliability_biha": False,
|
||||||
|
"reliability_multimaster": False,
|
||||||
|
"reliability_pool": False,
|
||||||
|
"reliability_64bit_xid": False,
|
||||||
|
"reliability_cfs": False,
|
||||||
|
"reliability_incremental_backup": True,
|
||||||
|
"reliability_partitioning": False,
|
||||||
|
"reliability_autonomous_tx": False,
|
||||||
|
"reliability_scheduler": False,
|
||||||
|
"perf_aqo": False,
|
||||||
|
"perf_pwr": False,
|
||||||
|
"perf_monitoring": True,
|
||||||
|
"perf_hints": False,
|
||||||
|
"perf_covering_indexes": False,
|
||||||
|
"perf_knn": False,
|
||||||
|
"perf_rum": False,
|
||||||
|
"perf_wait_sampling": False,
|
||||||
|
"dev_plsql": False,
|
||||||
|
"dev_bfile": False,
|
||||||
|
"dev_superfile": False,
|
||||||
|
"dev_sql_json": True,
|
||||||
|
"oracle_migration": False,
|
||||||
|
"comp_apps": True,
|
||||||
|
"comp_platforms": True
|
||||||
|
},
|
||||||
|
"Postgres Pro Certified": {
|
||||||
|
"sec_fstec": True,
|
||||||
|
"sec_registry": True,
|
||||||
|
"sec_masking": False,
|
||||||
|
"sec_admin": False,
|
||||||
|
"sec_memory_clean": True,
|
||||||
|
"sec_audit": True,
|
||||||
|
"sec_integrity": True,
|
||||||
|
"sec_profiles": True,
|
||||||
|
"mgmt_ppem": True,
|
||||||
|
"mgmt_online_move": False,
|
||||||
|
"reliability_biha": False,
|
||||||
|
"reliability_multimaster": False,
|
||||||
|
"reliability_pool": False,
|
||||||
|
"reliability_64bit_xid": False,
|
||||||
|
"reliability_cfs": False,
|
||||||
|
"reliability_incremental_backup": True,
|
||||||
|
"reliability_partitioning": False,
|
||||||
|
"reliability_autonomous_tx": False,
|
||||||
|
"reliability_scheduler": False,
|
||||||
|
"perf_aqo": False,
|
||||||
|
"perf_pwr": False,
|
||||||
|
"perf_monitoring": True,
|
||||||
|
"perf_hints": False,
|
||||||
|
"perf_covering_indexes": False,
|
||||||
|
"perf_knn": False,
|
||||||
|
"perf_rum": False,
|
||||||
|
"perf_wait_sampling": False,
|
||||||
|
"dev_plsql": False,
|
||||||
|
"dev_bfile": False,
|
||||||
|
"dev_superfile": False,
|
||||||
|
"dev_sql_json": True,
|
||||||
|
"oracle_migration": False,
|
||||||
|
"comp_apps": True,
|
||||||
|
"comp_platforms": True
|
||||||
|
},
|
||||||
|
"Postgres Pro Shardman": {
|
||||||
|
"sec_fstec": True,
|
||||||
|
"sec_registry": True,
|
||||||
|
"sec_masking": False,
|
||||||
|
"sec_admin": False,
|
||||||
|
"sec_memory_clean": False,
|
||||||
|
"sec_audit": True,
|
||||||
|
"sec_integrity": False,
|
||||||
|
"sec_profiles": False,
|
||||||
|
"mgmt_ppem": True,
|
||||||
|
"mgmt_online_move": False,
|
||||||
|
"reliability_biha": False,
|
||||||
|
"reliability_multimaster": False,
|
||||||
|
"reliability_pool": True,
|
||||||
|
"reliability_64bit_xid": True,
|
||||||
|
"reliability_cfs": True,
|
||||||
|
"reliability_incremental_backup": True,
|
||||||
|
"reliability_partitioning": False,
|
||||||
|
"reliability_autonomous_tx": False,
|
||||||
|
"reliability_scheduler": False,
|
||||||
|
"perf_aqo": False,
|
||||||
|
"perf_pwr": False,
|
||||||
|
"perf_monitoring": False,
|
||||||
|
"perf_hints": False,
|
||||||
|
"perf_covering_indexes": True,
|
||||||
|
"perf_knn": True,
|
||||||
|
"perf_rum": False,
|
||||||
|
"perf_wait_sampling": False,
|
||||||
|
"dev_plsql": False,
|
||||||
|
"dev_bfile": True,
|
||||||
|
"dev_superfile": True,
|
||||||
|
"dev_sql_json": False,
|
||||||
|
"oracle_migration": False,
|
||||||
|
"comp_apps": True,
|
||||||
|
"comp_platforms": True
|
||||||
|
},
|
||||||
|
"PostgreSQL (сообщество)": {
|
||||||
|
"sec_fstec": False,
|
||||||
|
"sec_registry": False,
|
||||||
|
"sec_masking": False,
|
||||||
|
"sec_admin": False,
|
||||||
|
"sec_memory_clean": False,
|
||||||
|
"sec_audit": False,
|
||||||
|
"sec_integrity": False,
|
||||||
|
"sec_profiles": False,
|
||||||
|
"mgmt_ppem": False,
|
||||||
|
"mgmt_online_move": False,
|
||||||
|
"reliability_biha": False,
|
||||||
|
"reliability_multimaster": False,
|
||||||
|
"reliability_pool": False,
|
||||||
|
"reliability_64bit_xid": False,
|
||||||
|
"reliability_cfs": False,
|
||||||
|
"reliability_incremental_backup": False,
|
||||||
|
"reliability_partitioning": False,
|
||||||
|
"reliability_autonomous_tx": False,
|
||||||
|
"reliability_scheduler": False,
|
||||||
|
"perf_aqo": False,
|
||||||
|
"perf_pwr": False,
|
||||||
|
"perf_monitoring": False,
|
||||||
|
"perf_hints": False,
|
||||||
|
"perf_covering_indexes": False,
|
||||||
|
"perf_knn": False,
|
||||||
|
"perf_rum": False,
|
||||||
|
"perf_wait_sampling": False,
|
||||||
|
"dev_plsql": False,
|
||||||
|
"dev_bfile": False,
|
||||||
|
"dev_superfile": False,
|
||||||
|
"dev_sql_json": False,
|
||||||
|
"oracle_migration": False,
|
||||||
|
"comp_apps": False,
|
||||||
|
"comp_platforms": False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Создание опроса
|
||||||
|
survey, _ = SurveyType.get_or_create(name="Сравнение версий Postgres Pro")
|
||||||
|
|
||||||
|
# Загрузка вопросов
|
||||||
|
feature_map = {}
|
||||||
|
for category, feature_group in questions.items():
|
||||||
|
product, _ = Product.get_or_create(name=category, survey_type=survey)
|
||||||
|
for name, text in feature_group.items():
|
||||||
|
f, _ = Feature.get_or_create(name=name, question_text=text, product=product)
|
||||||
|
feature_map[name] = f
|
||||||
|
|
||||||
|
# Загрузка платформ и поддержки
|
||||||
|
for platform_name, feature_support in platforms.items():
|
||||||
|
platform, _ = Platform.get_or_create(name=platform_name, survey_type=survey)
|
||||||
|
for feature_key, is_supported in feature_support.items():
|
||||||
|
f = feature_map.get(feature_key)
|
||||||
|
if f:
|
||||||
|
PlatformFeature.get_or_create(platform=platform, feature=f, defaults={"supported": is_supported})
|
||||||
|
print("✅ Загружено: категории, функции, платформы и поддержка")
|
||||||
768
main.py
Normal file
768
main.py
Normal file
@@ -0,0 +1,768 @@
|
|||||||
|
from flask import Flask, render_template, request, redirect, url_for, session, flash
|
||||||
|
from flask_login import LoginManager, login_user, logout_user, login_required, UserMixin, current_user
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
from models import *
|
||||||
|
import smtplib
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = 'sk_f098a9f7206d40f89bc2a0dd1d2d9182' # нужен для сессий
|
||||||
|
app.jinja_env.filters['from_json'] = json.loads
|
||||||
|
initialize_db()
|
||||||
|
|
||||||
|
login_manager = LoginManager()
|
||||||
|
login_manager.init_app(app)
|
||||||
|
login_manager.login_view = 'login'
|
||||||
|
|
||||||
|
# Фиксированный админ (можно позже перенести в БД)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
return User.get_or_none(User.id == int(user_id))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
if request.method == 'POST':
|
||||||
|
username = request.form['username']
|
||||||
|
password = request.form['password']
|
||||||
|
user = User.get_or_none(User.username == username)
|
||||||
|
|
||||||
|
if user and check_password_hash(user.password_hash, password):
|
||||||
|
login_user(user)
|
||||||
|
print("✅ Авторизация успешна:", current_user.is_authenticated)
|
||||||
|
print("📍 current_user:", current_user.username)
|
||||||
|
print("📍 Сессия:", dict(session))
|
||||||
|
return redirect(url_for('dashboard' if not user.is_admin else 'manage_surveys'))
|
||||||
|
else:
|
||||||
|
|
||||||
|
flash("Неверные учетные данные", "danger")
|
||||||
|
|
||||||
|
|
||||||
|
flash("Неверные учетные данные", "danger")
|
||||||
|
return render_template('login.html', title="Вход")
|
||||||
|
|
||||||
|
def send_invite_email(to_email, link, sender_name, survey_name):
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg['Subject'] = f"📨 Приглашение пройти опрос: {survey_name}"
|
||||||
|
msg['From'] = "quiz@4mont.ru"
|
||||||
|
msg['To'] = to_email
|
||||||
|
|
||||||
|
plain_text = f"""\
|
||||||
|
Вы получили приглашение на участие в опросе от компании МОНТ.
|
||||||
|
|
||||||
|
Опрос: {survey_name}
|
||||||
|
Отправитель: {sender_name}
|
||||||
|
|
||||||
|
Пройдите опрос по ссылке:
|
||||||
|
{link}
|
||||||
|
"""
|
||||||
|
|
||||||
|
html = f"""\
|
||||||
|
<html>
|
||||||
|
<body style="font-family: sans-serif;">
|
||||||
|
<h3>📨 Вас пригласили пройти опрос от компании <strong>МОНТ</strong></h3>
|
||||||
|
<p><strong>Опрос:</strong> {survey_name}<br>
|
||||||
|
<strong>Отправитель:</strong> {sender_name}</p>
|
||||||
|
|
||||||
|
<p style="margin-top: 20px;">
|
||||||
|
👉 <a href="{link}" style="font-size: 16px; font-weight: bold;">Перейти к опросу</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<small>Если вы не ожидали этого письма — просто проигнорируйте его.</small>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
msg.attach(MIMEText(plain_text, "plain"))
|
||||||
|
msg.attach(MIMEText(html, "html"))
|
||||||
|
|
||||||
|
with smtplib.SMTP("mail.hosting.reg.ru", 587) as server:
|
||||||
|
server.starttls()
|
||||||
|
server.login("quiz@4mont.ru", "utOgbZ09quizpochta")
|
||||||
|
server.send_message(msg)
|
||||||
|
|
||||||
|
def send_result_email(to_email, from_email, survey_name, scores, unsupported, sender_name):
|
||||||
|
report_link = "https://quiz.4mont.ru/dashboard"
|
||||||
|
|
||||||
|
# Текстовая версия
|
||||||
|
plain_text = f"""\
|
||||||
|
Компания МОНТ
|
||||||
|
Отправитель: {sender_name} ({from_email})
|
||||||
|
Опрос: {survey_name}
|
||||||
|
|
||||||
|
Результаты:
|
||||||
|
{chr(10).join(f"{p}: {v}%" for p, v in scores.items())}
|
||||||
|
|
||||||
|
Неподдерживаемые функции:
|
||||||
|
{chr(10).join(f"{p}: {', '.join(u)}" for p, u in unsupported.items() if u) or 'Все функции поддерживаются'}
|
||||||
|
|
||||||
|
Ссылка на отчёты: {report_link}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# HTML-версия письма
|
||||||
|
html = f"""\
|
||||||
|
<html>
|
||||||
|
<body style="font-family: sans-serif;">
|
||||||
|
<h3>📩 Опрос от компании <strong>МОНТ</strong></h3>
|
||||||
|
<p><strong>Отправитель:</strong> {sender_name} ({from_email})<br>
|
||||||
|
<strong>Опрос:</strong> {survey_name}</p>
|
||||||
|
|
||||||
|
<h4>📊 Результаты:</h4>
|
||||||
|
<ul>
|
||||||
|
{''.join(f"<li><strong>{p}</strong>: {v}%</li>" for p, v in scores.items())}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>🚫 Неподдерживаемые функции:</h4>
|
||||||
|
<ul>
|
||||||
|
{''.join(f"<li><strong>{p}</strong>: {', '.join(u)}</li>" for p, u in unsupported.items() if u) or '<li>Все функции поддерживаются</li>'}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p><a href="{report_link}" style="font-weight: bold;">🔗 Перейти к отчётам</a></p>
|
||||||
|
<hr>
|
||||||
|
<small>Это автоматическое уведомление. Пожалуйста, не отвечайте на него.</small>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg['Subject'] = f"📥 Ответ на опрос: {survey_name}"
|
||||||
|
msg['From'] = "quiz@4mont.ru"
|
||||||
|
msg['To'] = to_email
|
||||||
|
|
||||||
|
msg.attach(MIMEText(plain_text, "plain"))
|
||||||
|
msg.attach(MIMEText(html, "html"))
|
||||||
|
|
||||||
|
with smtplib.SMTP("mail.hosting.reg.ru", 587) as server:
|
||||||
|
server.starttls()
|
||||||
|
server.login("quiz@4mont.ru", "utOgbZ09quizpochta")
|
||||||
|
server.send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/invite/<uuid_str>', methods=['GET', 'POST'])
|
||||||
|
def handle_invite(uuid_str):
|
||||||
|
invite = SurveyInvite.get_or_none(SurveyInvite.uuid == uuid_str)
|
||||||
|
if not invite:
|
||||||
|
return "Ссылка недействительна", 404
|
||||||
|
|
||||||
|
user_survey = invite.survey
|
||||||
|
survey_type = user_survey.survey_type
|
||||||
|
products = Product.select().where(Product.survey_type == survey_type)
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
questions = {}
|
||||||
|
for product in products:
|
||||||
|
features = Feature.select().where(Feature.product == product)
|
||||||
|
questions[product.name] = {f.name: f.question_text for f in features}
|
||||||
|
|
||||||
|
return render_template("invite/form.html", invite=invite, survey=user_survey, questions=questions)
|
||||||
|
|
||||||
|
# POST: обработка результатов
|
||||||
|
full_name = request.form.get('full_name')
|
||||||
|
phone = request.form.get('phone')
|
||||||
|
organization = request.form.get('organization')
|
||||||
|
answers = dict(request.form)
|
||||||
|
|
||||||
|
for k in ['full_name', 'phone', 'organization']:
|
||||||
|
answers.pop(k, None)
|
||||||
|
|
||||||
|
selected_keys = list(answers.keys())
|
||||||
|
|
||||||
|
# Расчёт результатов
|
||||||
|
platforms = Platform.select().where(Platform.survey_type == survey_type)
|
||||||
|
features = Feature.select().join(Product).where(
|
||||||
|
Feature.name.in_(selected_keys),
|
||||||
|
Product.survey_type == survey_type
|
||||||
|
)
|
||||||
|
comment = request.form.get('comment')
|
||||||
|
scores = {}
|
||||||
|
unsupported = {}
|
||||||
|
total = len(selected_keys)
|
||||||
|
|
||||||
|
for platform in platforms:
|
||||||
|
match = 0
|
||||||
|
unsup = []
|
||||||
|
for feature in features:
|
||||||
|
pf = PlatformFeature.get_or_none(platform=platform, feature=feature)
|
||||||
|
if pf and pf.supported:
|
||||||
|
match += 1
|
||||||
|
else:
|
||||||
|
unsup.append(feature.question_text)
|
||||||
|
scores[platform.name] = round((match / total) * 100) if total else 0
|
||||||
|
unsupported[platform.name] = unsup
|
||||||
|
|
||||||
|
# Сохраняем результат
|
||||||
|
SurveyResult.create(
|
||||||
|
invite=invite,
|
||||||
|
full_name=full_name,
|
||||||
|
phone=phone,
|
||||||
|
organization=organization,
|
||||||
|
answers=json.dumps(answers),
|
||||||
|
platform_scores=json.dumps(scores),
|
||||||
|
unsupported=json.dumps(unsupported),
|
||||||
|
submitted_at=datetime.datetime.now(),
|
||||||
|
comment=comment # ← добавлено
|
||||||
|
)
|
||||||
|
|
||||||
|
invite.responded = True
|
||||||
|
invite.save()
|
||||||
|
|
||||||
|
send_result_email(
|
||||||
|
to_email=invite.survey.user.email,
|
||||||
|
from_email=invite.recipient_email,
|
||||||
|
survey_name=survey_type.name,
|
||||||
|
scores=scores,
|
||||||
|
unsupported=unsupported,
|
||||||
|
sender_name=invite.survey.user.full_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# ✅ если результат не нужно показывать — редирект
|
||||||
|
if not invite.show_result:
|
||||||
|
flash("Спасибо, ваш ответ сохранён!", "success")
|
||||||
|
return redirect("https://www.mont.ru/ru-ru")
|
||||||
|
|
||||||
|
return render_template("invite/result.html",
|
||||||
|
scores=scores,
|
||||||
|
unsupported_features=unsupported,
|
||||||
|
chart_labels=list(scores.keys()),
|
||||||
|
chart_data=list(scores.values()))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/send-survey/<int:survey_id>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def send_survey(survey_id):
|
||||||
|
user_survey = UserSurvey.get_or_none(UserSurvey.id == survey_id, UserSurvey.user == current_user.id)
|
||||||
|
if not user_survey:
|
||||||
|
flash("Опрос не найден", "danger")
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
|
recipient_email = request.form['email']
|
||||||
|
show_result = 'show_result' in request.form
|
||||||
|
ask_full_name = 'ask_full_name' in request.form
|
||||||
|
ask_phone = 'ask_phone' in request.form
|
||||||
|
ask_organization = 'ask_organization' in request.form
|
||||||
|
|
||||||
|
invite = SurveyInvite.create(
|
||||||
|
survey=user_survey,
|
||||||
|
recipient_email=recipient_email,
|
||||||
|
show_result=show_result,
|
||||||
|
ask_full_name=ask_full_name,
|
||||||
|
ask_phone=ask_phone,
|
||||||
|
ask_organization=ask_organization
|
||||||
|
)
|
||||||
|
|
||||||
|
link = f"http://quiz.4mont.ru/invite/{invite.uuid}"
|
||||||
|
|
||||||
|
send_invite_email(
|
||||||
|
to_email=recipient_email,
|
||||||
|
link=link,
|
||||||
|
sender_name=current_user.full_name,
|
||||||
|
survey_name=user_survey.survey_type.name
|
||||||
|
)
|
||||||
|
|
||||||
|
flash("Опрос отправлен!", "success")
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/admin/features/<int:feature_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def delete_feature(feature_id):
|
||||||
|
feature = Feature.get_or_none(Feature.id == feature_id)
|
||||||
|
if not feature:
|
||||||
|
flash("Вопрос не найден", "danger")
|
||||||
|
return redirect(request.referrer)
|
||||||
|
|
||||||
|
# Удалим все связанные записи в PlatformFeature
|
||||||
|
PlatformFeature.delete().where(PlatformFeature.feature == feature).execute()
|
||||||
|
|
||||||
|
feature.delete_instance()
|
||||||
|
flash("Вопрос удалён", "success")
|
||||||
|
return redirect(request.referrer)
|
||||||
|
|
||||||
|
@app.route('/register', methods=['GET', 'POST'])
|
||||||
|
def register():
|
||||||
|
if request.method == 'POST':
|
||||||
|
username = request.form['username']
|
||||||
|
email = request.form['email']
|
||||||
|
password = request.form['password']
|
||||||
|
full_name = request.form['full_name']
|
||||||
|
|
||||||
|
# Корректная проверка уникальности логина/почты и создание пользователя
|
||||||
|
if User.select().where((User.username == username) | (User.email == email)).exists():
|
||||||
|
flash("Пользователь с таким логином или e-mail уже существует", "danger")
|
||||||
|
return redirect(url_for('register'))
|
||||||
|
|
||||||
|
user = User.create(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
full_name=full_name,
|
||||||
|
password_hash=generate_password_hash(password),
|
||||||
|
is_admin=False
|
||||||
|
)
|
||||||
|
login_user(user)
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
|
if User.get_or_none(User.username == username or User.email == email):
|
||||||
|
flash("Пользователь с таким логином или почтой уже существует", "danger")
|
||||||
|
return redirect(url_for('register'))
|
||||||
|
|
||||||
|
user = User.create(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
full_name=full_name,
|
||||||
|
password_hash=generate_password_hash(password),
|
||||||
|
is_admin=False
|
||||||
|
)
|
||||||
|
login_user(user)
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
|
return render_template("register.html", title="Регистрация")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/logout')
|
||||||
|
def logout():
|
||||||
|
logout_user()
|
||||||
|
return redirect(url_for('choose_survey'))
|
||||||
|
|
||||||
|
# Защита всех админских маршрутов
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
@app.route('/dashboard')
|
||||||
|
@login_required
|
||||||
|
def dashboard():
|
||||||
|
if current_user.is_admin:
|
||||||
|
invites = (SurveyInvite
|
||||||
|
.select()
|
||||||
|
.join(UserSurvey)
|
||||||
|
.order_by(SurveyInvite.sent_at.desc()))
|
||||||
|
else:
|
||||||
|
invites = (SurveyInvite
|
||||||
|
.select()
|
||||||
|
.join(UserSurvey)
|
||||||
|
.where(UserSurvey.user == current_user.id)
|
||||||
|
.order_by(SurveyInvite.sent_at.desc()))
|
||||||
|
|
||||||
|
results = {
|
||||||
|
r.invite.id: r
|
||||||
|
for r in SurveyResult.select().where(SurveyResult.invite.in_([i.id for i in invites]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template("user/dashboard.html", invites=invites, results=results)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/dashboard/invite/<int:invite_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def delete_invite(invite_id):
|
||||||
|
invite = SurveyInvite.get_or_none(SurveyInvite.id == invite_id)
|
||||||
|
|
||||||
|
if invite and invite.survey.user == current_user:
|
||||||
|
# Удалим связанный результат, если есть
|
||||||
|
SurveyResult.delete().where(SurveyResult.invite == invite).execute()
|
||||||
|
invite.delete_instance()
|
||||||
|
|
||||||
|
flash("Приглашение удалено", "success")
|
||||||
|
else:
|
||||||
|
flash("Удаление невозможно", "danger")
|
||||||
|
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
if not getattr(current_user, 'is_admin', False):
|
||||||
|
flash("Доступ запрещен: требуется администратор", "danger")
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
@app.route('/admin/support', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
def manage_support():
|
||||||
|
platforms = list(Platform.select())
|
||||||
|
features = list(Feature.select())
|
||||||
|
|
||||||
|
# Словарь [platform_id][feature_id] = True/False
|
||||||
|
support = {p.id: {f.id: False for f in features} for p in platforms}
|
||||||
|
|
||||||
|
# Заполнение текущей поддержки
|
||||||
|
for pf in PlatformFeature.select():
|
||||||
|
support[pf.platform.id][pf.feature.id] = pf.supported
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
for platform in platforms:
|
||||||
|
for feature in features:
|
||||||
|
key = f"support_{platform.id}_{feature.id}"
|
||||||
|
is_checked = key in request.form
|
||||||
|
|
||||||
|
pf, created = PlatformFeature.get_or_create(
|
||||||
|
platform=platform,
|
||||||
|
feature=feature,
|
||||||
|
defaults={'supported': is_checked}
|
||||||
|
)
|
||||||
|
if not created and pf.supported != is_checked:
|
||||||
|
pf.supported = is_checked
|
||||||
|
pf.save()
|
||||||
|
|
||||||
|
return redirect(url_for('manage_support'))
|
||||||
|
return render_template("admin/support.html",
|
||||||
|
platforms=platforms,
|
||||||
|
features=features,
|
||||||
|
support=support,
|
||||||
|
title="Матрица поддержки")
|
||||||
|
|
||||||
|
@app.route('/admin/platforms/delete/<int:platform_id>', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def delete_platform(platform_id):
|
||||||
|
platform = Platform.get_or_none(Platform.id == platform_id)
|
||||||
|
if platform:
|
||||||
|
platform.delete_instance(recursive=True) # также удалит связанные PlatformFeature
|
||||||
|
return redirect(url_for('manage_platforms'))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/admin/platforms', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
def manage_platforms():
|
||||||
|
if request.method == 'POST':
|
||||||
|
name = request.form['name']
|
||||||
|
Platform.get_or_create(name=name)
|
||||||
|
return redirect(url_for('manage_platforms'))
|
||||||
|
|
||||||
|
platforms = Platform.select()
|
||||||
|
return render_template('admin/platforms.html', platforms=platforms, title="Платформы")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/admin/products/<int:product_id>/features', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
def manage_features(product_id):
|
||||||
|
product = Product.get_by_id(product_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
text = request.form['question_text']
|
||||||
|
Feature.get_or_create(question_text=text, product=product)
|
||||||
|
return redirect(url_for('manage_features', product_id=product_id))
|
||||||
|
|
||||||
|
features = Feature.select().where(Feature.product == product)
|
||||||
|
return render_template('admin/features.html', product=product, features=features, title="Вопросы")
|
||||||
|
|
||||||
|
|
||||||
|
#Удаление вопроса
|
||||||
|
@app.route('/admin/products/<int:product_id>/features/<int:feature_id>/delete', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def delete_product_feature(product_id, feature_id):
|
||||||
|
feature = Feature.get_or_none(Feature.id == feature_id)
|
||||||
|
if feature:
|
||||||
|
feature.delete_instance()
|
||||||
|
return redirect(url_for('manage_features', product_id=product_id))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/admin/surveys', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def manage_surveys():
|
||||||
|
if request.method == 'POST':
|
||||||
|
name = request.form['name'].strip()
|
||||||
|
if not name:
|
||||||
|
flash("Название опроса не может быть пустым", "danger")
|
||||||
|
return redirect(url_for('manage_surveys'))
|
||||||
|
|
||||||
|
# Создаём или получаем SurveyType
|
||||||
|
survey_type, created = SurveyType.get_or_create(name=name)
|
||||||
|
|
||||||
|
# Назначаем текущему пользователю (если не назначен ранее)
|
||||||
|
if not UserSurvey.get_or_none(survey_type=survey_type, user=current_user):
|
||||||
|
UserSurvey.create(survey_type=survey_type, user=current_user, name=name)
|
||||||
|
|
||||||
|
flash("Опрос создан и назначен вам", "success")
|
||||||
|
return redirect(url_for('manage_surveys'))
|
||||||
|
|
||||||
|
if current_user.is_admin:
|
||||||
|
surveys = SurveyType.select()
|
||||||
|
users = User.select().where(User.is_admin == False)
|
||||||
|
assignments = {
|
||||||
|
s.id: [us.user.id for us in UserSurvey.select().where(UserSurvey.survey_type == s)]
|
||||||
|
for s in surveys
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
surveys = (SurveyType
|
||||||
|
.select()
|
||||||
|
.join(UserSurvey)
|
||||||
|
.where(UserSurvey.user == current_user.id)
|
||||||
|
.distinct())
|
||||||
|
users = []
|
||||||
|
assignments = {}
|
||||||
|
|
||||||
|
return render_template('admin/surveys.html',
|
||||||
|
surveys=surveys,
|
||||||
|
users=users,
|
||||||
|
assignments=assignments,
|
||||||
|
current_user=current_user,
|
||||||
|
title="Опросники")
|
||||||
|
|
||||||
|
@app.route('/admin/surveys/assign_bulk', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def assign_bulk_surveys():
|
||||||
|
survey_id = int(request.form['survey_id'])
|
||||||
|
selected_user_ids = request.form.getlist('user_ids')
|
||||||
|
|
||||||
|
# Удалить все старые назначения
|
||||||
|
UserSurvey.delete().where(UserSurvey.survey_type == survey_id).execute()
|
||||||
|
|
||||||
|
# Добавить новые
|
||||||
|
for uid in selected_user_ids:
|
||||||
|
UserSurvey.create(
|
||||||
|
survey_type=survey_id,
|
||||||
|
user=User.get_by_id(uid),
|
||||||
|
name=f"Опрос {survey_id} для пользователя {uid}"
|
||||||
|
)
|
||||||
|
|
||||||
|
flash("Назначения обновлены", "success")
|
||||||
|
return redirect(url_for('manage_surveys'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/admin/surveys/assign', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def assign_survey_to_user():
|
||||||
|
survey_id = request.form['survey_id']
|
||||||
|
user_id = request.form['user_id']
|
||||||
|
|
||||||
|
# Проверка: если такой связи нет — создать
|
||||||
|
if not UserSurvey.get_or_none(survey_type=survey_id, user=user_id):
|
||||||
|
UserSurvey.create(survey_type=survey_id, user=user_id, name=f"Опрос {survey_id} для пользователя {user_id}")
|
||||||
|
|
||||||
|
flash("Опрос назначен пользователю", "success")
|
||||||
|
return redirect(url_for('manage_surveys'))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/admin/surveys/<int:survey_id>/products', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
def manage_products(survey_id):
|
||||||
|
survey = SurveyType.get_by_id(survey_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
name = request.form['name']
|
||||||
|
Product.get_or_create(name=name, survey_type=survey)
|
||||||
|
return redirect(url_for('manage_products', survey_id=survey_id))
|
||||||
|
|
||||||
|
products = Product.select().where(Product.survey_type == survey)
|
||||||
|
return render_template('admin/products.html', survey=survey, products=products, title="Категории опроса")
|
||||||
|
|
||||||
|
@app.route('/admin/surveys/<int:survey_id>/products/<int:product_id>/delete', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def delete_product(survey_id, product_id):
|
||||||
|
product = Product.get_or_none(Product.id == product_id)
|
||||||
|
if product:
|
||||||
|
product.delete_instance(recursive=True) # удалит и связанные features
|
||||||
|
return redirect(url_for('manage_products', survey_id=survey_id))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/quiz')
|
||||||
|
@login_required
|
||||||
|
def choose_survey():
|
||||||
|
if current_user.is_admin:
|
||||||
|
surveys = SurveyType.select()
|
||||||
|
users = User.select().where(User.is_admin == False)
|
||||||
|
|
||||||
|
# Словарь: survey_id -> [user_ids]
|
||||||
|
assignments = {
|
||||||
|
s.id: [us.user.id for us in UserSurvey.select().where(UserSurvey.survey_type == s)]
|
||||||
|
for s in surveys
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template("choose_survey.html",
|
||||||
|
surveys=surveys,
|
||||||
|
users=users,
|
||||||
|
assignments=assignments,
|
||||||
|
current_user=current_user)
|
||||||
|
else:
|
||||||
|
surveys = (UserSurvey
|
||||||
|
.select()
|
||||||
|
.where(UserSurvey.user == current_user.id)
|
||||||
|
.order_by(UserSurvey.created_at.desc()))
|
||||||
|
return render_template("choose_survey.html", surveys=surveys)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/quiz/assign', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def assign_user_survey():
|
||||||
|
user_id = request.form['user_id']
|
||||||
|
survey_id = request.form['survey_id']
|
||||||
|
|
||||||
|
us = UserSurvey.get_or_none(UserSurvey.id == survey_id)
|
||||||
|
if not us:
|
||||||
|
flash("Опрос не найден", "danger")
|
||||||
|
return redirect(url_for('choose_survey'))
|
||||||
|
|
||||||
|
UserSurvey.create(
|
||||||
|
user=User.get_by_id(user_id),
|
||||||
|
survey_type=us.survey_type,
|
||||||
|
name=f"{us.name} (копия)",
|
||||||
|
)
|
||||||
|
flash("Опрос назначен пользователю", "success")
|
||||||
|
return redirect(url_for('choose_survey'))
|
||||||
|
|
||||||
|
@app.route('/quiz/<int:survey_id>', methods=['GET', 'POST'])
|
||||||
|
def show_quiz(survey_id):
|
||||||
|
survey = SurveyType.get_by_id(survey_id)
|
||||||
|
products = Product.select().where(Product.survey_type == survey)
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
questions = {}
|
||||||
|
for product in products:
|
||||||
|
features = Feature.select().where(Feature.product == product)
|
||||||
|
questions[product.name] = list(features)
|
||||||
|
return render_template("quiz.html", survey=survey, questions=questions)
|
||||||
|
|
||||||
|
# POST: обработка результатов
|
||||||
|
selected_feature_ids = []
|
||||||
|
for key in request.form.keys():
|
||||||
|
if key.startswith('feature_'):
|
||||||
|
try:
|
||||||
|
fid = int(key.replace('feature_', ''))
|
||||||
|
selected_feature_ids.append(fid)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
platforms = Platform.select().where(Platform.survey_type == survey)
|
||||||
|
features = Feature.select().where(Feature.id.in_(selected_feature_ids))
|
||||||
|
|
||||||
|
scores = {p.name: 0 for p in platforms}
|
||||||
|
unsupported = {p.name: [] for p in platforms}
|
||||||
|
total = len(selected_feature_ids)
|
||||||
|
|
||||||
|
for platform in platforms:
|
||||||
|
for feature in features:
|
||||||
|
pf = PlatformFeature.get_or_none(platform=platform, feature=feature)
|
||||||
|
if pf and pf.supported:
|
||||||
|
scores[platform.name] += 1
|
||||||
|
else:
|
||||||
|
unsupported[platform.name].append(feature.question_text)
|
||||||
|
|
||||||
|
for p in scores:
|
||||||
|
scores[p] = (scores[p] / total) * 100 if total else 0
|
||||||
|
|
||||||
|
return render_template("result.html",
|
||||||
|
scores=scores,
|
||||||
|
unsupported_features=unsupported,
|
||||||
|
chart_labels=list(scores.keys()),
|
||||||
|
chart_data=list(scores.values()))
|
||||||
|
|
||||||
|
@app.route('/quiz/assign_bulk', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def assign_user_survey_bulk():
|
||||||
|
survey_id = int(request.form['survey_id'])
|
||||||
|
user_ids = request.form.getlist('user_ids')
|
||||||
|
|
||||||
|
# Удалить старые назначения
|
||||||
|
UserSurvey.delete().where(UserSurvey.survey_type == survey_id).execute()
|
||||||
|
|
||||||
|
# Добавить новые
|
||||||
|
for uid in user_ids:
|
||||||
|
UserSurvey.create(
|
||||||
|
survey_type=survey_id,
|
||||||
|
user=User.get_by_id(uid),
|
||||||
|
name=f"Опрос {survey_id} для пользователя {uid}"
|
||||||
|
)
|
||||||
|
|
||||||
|
flash("Назначения обновлены", "success")
|
||||||
|
return redirect(url_for('choose_survey'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/admin/surveys/delete/<int:survey_id>', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def delete_survey(survey_id):
|
||||||
|
survey = SurveyType.get_or_none(SurveyType.id == survey_id)
|
||||||
|
if survey:
|
||||||
|
survey.delete_instance(recursive=True)
|
||||||
|
return redirect(url_for('manage_surveys'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/admin/surveys/<int:survey_id>/platforms', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
def manage_platforms_by_survey(survey_id):
|
||||||
|
survey = SurveyType.get_by_id(survey_id)
|
||||||
|
if request.method == 'POST':
|
||||||
|
name = request.form['name']
|
||||||
|
Platform.get_or_create(name=name, survey_type=survey)
|
||||||
|
return redirect(url_for('manage_platforms_by_survey', survey_id=survey.id))
|
||||||
|
|
||||||
|
platforms = Platform.select().where(Platform.survey_type == survey)
|
||||||
|
return render_template('admin/platforms_by_survey.html', survey=survey, platforms=platforms, title="Платформы опроса")
|
||||||
|
|
||||||
|
@app.route('/admin/surveys/<int:survey_id>/platforms/delete/<int:platform_id>', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def delete_platform_by_survey(survey_id, platform_id):
|
||||||
|
platform = Platform.get_or_none(id=platform_id, survey_type=survey_id)
|
||||||
|
if platform:
|
||||||
|
platform.delete_instance(recursive=True)
|
||||||
|
return redirect(url_for('manage_platforms_by_survey', survey_id=survey_id))
|
||||||
|
|
||||||
|
@app.route('/admin/surveys/<int:survey_id>/support', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
def manage_support_by_survey(survey_id):
|
||||||
|
survey = SurveyType.get_by_id(survey_id)
|
||||||
|
platforms = list(Platform.select().where(Platform.survey_type == survey))
|
||||||
|
products = list(Product.select().where(Product.survey_type == survey))
|
||||||
|
features = Feature.select().where(Feature.product.in_(products))
|
||||||
|
|
||||||
|
support = {p.id: {f.id: False for f in features} for p in platforms}
|
||||||
|
for pf in PlatformFeature.select().where(PlatformFeature.platform.in_(platforms)):
|
||||||
|
support[pf.platform.id][pf.feature.id] = pf.supported
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
for p in platforms:
|
||||||
|
for f in features:
|
||||||
|
key = f"support_{p.id}_{f.id}"
|
||||||
|
checked = key in request.form
|
||||||
|
|
||||||
|
pf, created = PlatformFeature.get_or_create(
|
||||||
|
platform=p, feature=f,
|
||||||
|
defaults={'supported': checked}
|
||||||
|
)
|
||||||
|
if not created and pf.supported != checked:
|
||||||
|
pf.supported = checked
|
||||||
|
pf.save()
|
||||||
|
|
||||||
|
return redirect(url_for('manage_support_by_survey', survey_id=survey_id))
|
||||||
|
|
||||||
|
return render_template("admin/support_by_survey.html",
|
||||||
|
survey=survey,
|
||||||
|
platforms=platforms,
|
||||||
|
features=features,
|
||||||
|
support=support,
|
||||||
|
title="Матрица поддержки")
|
||||||
|
@app.route('/')
|
||||||
|
def home():
|
||||||
|
return redirect(url_for('choose_survey'))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host='0.0.0.0', debug=True)
|
||||||
110
migrate_sqlite_to_postgres.py
Normal file
110
migrate_sqlite_to_postgres.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
from peewee import *
|
||||||
|
from models import SurveyType, Product, Feature, Platform, PlatformFeature
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Подключение к SQLite
|
||||||
|
sqlite_db = SqliteDatabase('survey.db')
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteBaseModel(Model):
|
||||||
|
class Meta:
|
||||||
|
database = sqlite_db
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteSurveyType(SQLiteBaseModel):
|
||||||
|
name = CharField(unique=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table_name = 'surveytype' # Имя таблицы в SQLite
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteProduct(SQLiteBaseModel):
|
||||||
|
name = CharField()
|
||||||
|
survey_type = ForeignKeyField(SQLiteSurveyType, backref='products')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table_name = 'product'
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteFeature(SQLiteBaseModel):
|
||||||
|
name = CharField()
|
||||||
|
question_text = TextField()
|
||||||
|
product = ForeignKeyField(SQLiteProduct, backref='features')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table_name = 'feature'
|
||||||
|
|
||||||
|
|
||||||
|
class SQLitePlatform(SQLiteBaseModel):
|
||||||
|
name = CharField(unique=True)
|
||||||
|
survey_type = ForeignKeyField(SQLiteSurveyType, backref='platforms')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table_name = 'platform'
|
||||||
|
|
||||||
|
|
||||||
|
class SQLitePlatformFeature(SQLiteBaseModel):
|
||||||
|
platform = ForeignKeyField(SQLitePlatform, backref='features')
|
||||||
|
feature = ForeignKeyField(SQLiteFeature, backref='platforms')
|
||||||
|
supported = BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table_name = 'platformfeature'
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
# Подключаемся к SQLite и создаём соединение
|
||||||
|
sqlite_db.connect()
|
||||||
|
|
||||||
|
# Проверка наличия таблиц (для отладки можно вывести список таблиц)
|
||||||
|
cur = sqlite_db.execute_sql("SELECT name FROM sqlite_master WHERE type='table';")
|
||||||
|
tables = [row[0] for row in cur.fetchall()]
|
||||||
|
print("SQLite tables:", tables)
|
||||||
|
|
||||||
|
# Миграция SurveyType
|
||||||
|
survey_map = {}
|
||||||
|
for s in SQLiteSurveyType.select():
|
||||||
|
new_survey, _ = SurveyType.get_or_create(name=s.name)
|
||||||
|
survey_map[s.id] = new_survey
|
||||||
|
|
||||||
|
# Миграция Product
|
||||||
|
product_map = {}
|
||||||
|
for p in SQLiteProduct.select():
|
||||||
|
new_product, _ = Product.get_or_create(
|
||||||
|
name=p.name,
|
||||||
|
survey_type=survey_map[p.survey_type.id]
|
||||||
|
)
|
||||||
|
product_map[p.id] = new_product
|
||||||
|
|
||||||
|
# Миграция Feature
|
||||||
|
feature_map = {}
|
||||||
|
for f in SQLiteFeature.select():
|
||||||
|
new_feature, _ = Feature.get_or_create(
|
||||||
|
name=f.name,
|
||||||
|
question_text=f.question_text,
|
||||||
|
product=product_map[f.product.id]
|
||||||
|
)
|
||||||
|
feature_map[f.id] = new_feature
|
||||||
|
|
||||||
|
# Миграция Platform
|
||||||
|
platform_map = {}
|
||||||
|
for pl in SQLitePlatform.select():
|
||||||
|
new_platform, _ = Platform.get_or_create(
|
||||||
|
name=pl.name,
|
||||||
|
survey_type=survey_map[pl.survey_type.id]
|
||||||
|
)
|
||||||
|
platform_map[pl.id] = new_platform
|
||||||
|
|
||||||
|
# Миграция PlatformFeature
|
||||||
|
for pf in SQLitePlatformFeature.select():
|
||||||
|
PlatformFeature.get_or_create(
|
||||||
|
platform=platform_map[pf.platform.id],
|
||||||
|
feature=feature_map[pf.feature.id],
|
||||||
|
defaults={"supported": pf.supported}
|
||||||
|
)
|
||||||
|
|
||||||
|
print("✅ Данные успешно мигрированы из SQLite в PostgreSQL.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
97
models.py
Normal file
97
models.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
from peewee import *
|
||||||
|
from flask_login import UserMixin
|
||||||
|
import os
|
||||||
|
import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
USE_SQLITE = os.environ.get("USE_SQLITE") == "1"
|
||||||
|
|
||||||
|
if USE_SQLITE:
|
||||||
|
db = SqliteDatabase(os.environ.get("SQLITE_PATH", "survey.db"))
|
||||||
|
else:
|
||||||
|
db = PostgresqlDatabase(
|
||||||
|
os.environ.get("POSTGRES_DB", "survey"),
|
||||||
|
user=os.environ.get("POSTGRES_USER", "servey"),
|
||||||
|
password=os.environ.get("POSTGRES_PASSWORD", "utOgbZ09servey"),
|
||||||
|
host=os.environ.get("POSTGRES_HOST", "db"),
|
||||||
|
port=5432
|
||||||
|
)
|
||||||
|
|
||||||
|
class BaseModel(Model):
|
||||||
|
class Meta:
|
||||||
|
database = db
|
||||||
|
|
||||||
|
class User(BaseModel, UserMixin):
|
||||||
|
username = CharField(unique=True)
|
||||||
|
email = CharField(unique=True)
|
||||||
|
password_hash = CharField()
|
||||||
|
full_name = CharField(null=True)
|
||||||
|
is_admin = BooleanField(default=False)
|
||||||
|
|
||||||
|
def get_id(self):
|
||||||
|
return str(self.id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
return True # ✅ теперь Flask-Login доволен
|
||||||
|
|
||||||
|
class SurveyType(BaseModel):
|
||||||
|
name = CharField(unique=True)
|
||||||
|
|
||||||
|
class Product(BaseModel):
|
||||||
|
name = CharField()
|
||||||
|
survey_type = ForeignKeyField(SurveyType, backref='products', on_delete='CASCADE')
|
||||||
|
|
||||||
|
class Feature(BaseModel):
|
||||||
|
question_text = TextField()
|
||||||
|
product = ForeignKeyField(Product, backref='features', on_delete='CASCADE')
|
||||||
|
|
||||||
|
class Platform(BaseModel):
|
||||||
|
name = CharField(unique=True)
|
||||||
|
survey_type = ForeignKeyField(SurveyType, backref='platforms', on_delete='CASCADE')
|
||||||
|
|
||||||
|
class PlatformFeature(BaseModel):
|
||||||
|
platform = ForeignKeyField(Platform, backref='features', on_delete='CASCADE')
|
||||||
|
feature = ForeignKeyField(Feature, backref='platforms', on_delete='CASCADE')
|
||||||
|
supported = BooleanField(default=True)
|
||||||
|
|
||||||
|
# 🔹 Опросы, созданные пользователями
|
||||||
|
class UserSurvey(BaseModel):
|
||||||
|
user = ForeignKeyField(User, backref='surveys', on_delete='CASCADE')
|
||||||
|
survey_type = ForeignKeyField(SurveyType, on_delete='CASCADE')
|
||||||
|
name = CharField()
|
||||||
|
created_at = DateTimeField(default=datetime.datetime.now)
|
||||||
|
|
||||||
|
# 🔹 Отправленные приглашения
|
||||||
|
class SurveyInvite(BaseModel):
|
||||||
|
survey = ForeignKeyField(UserSurvey, backref='invites', on_delete='CASCADE')
|
||||||
|
recipient_email = CharField()
|
||||||
|
uuid = UUIDField(default=uuid.uuid4, unique=True)
|
||||||
|
show_result = BooleanField(default=False)
|
||||||
|
ask_full_name = BooleanField(default=False)
|
||||||
|
ask_phone = BooleanField(default=False)
|
||||||
|
ask_organization = BooleanField(default=False)
|
||||||
|
sent_at = DateTimeField(default=datetime.datetime.now)
|
||||||
|
responded = BooleanField(default=False)
|
||||||
|
|
||||||
|
# 🔹 Результаты прохождения опроса
|
||||||
|
class SurveyResult(BaseModel):
|
||||||
|
invite = ForeignKeyField(SurveyInvite, backref='result')
|
||||||
|
full_name = CharField(null=True)
|
||||||
|
phone = CharField(null=True)
|
||||||
|
organization = CharField(null=True)
|
||||||
|
answers = TextField(null=True) # JSON строка ответов
|
||||||
|
platform_scores = TextField(null=True) # JSON: { "Platform1": 80, ... }
|
||||||
|
unsupported = TextField(null=True) # JSON: { "Platform1": [q1, q2], ... }
|
||||||
|
submitted_at = DateTimeField(default=datetime.datetime.now, null=False)
|
||||||
|
comment = TextField(null=True) # ← вот это добавляем
|
||||||
|
|
||||||
|
# Можно добавить поле для процента или JSON с ответами, если нужно
|
||||||
|
|
||||||
|
def initialize_db():
|
||||||
|
db.connect()
|
||||||
|
db.create_tables([
|
||||||
|
User, SurveyType, Product, Feature, Platform, PlatformFeature,
|
||||||
|
UserSurvey, SurveyInvite, SurveyResult
|
||||||
|
], safe=True)
|
||||||
|
db.close()
|
||||||
BIN
requirements.txt
Normal file
BIN
requirements.txt
Normal file
Binary file not shown.
2
static/js/jspdf.roboto.js
Normal file
2
static/js/jspdf.roboto.js
Normal file
File diff suppressed because one or more lines are too long
BIN
static/mont.png
Normal file
BIN
static/mont.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
35
templates/admin/features.html
Normal file
35
templates/admin/features.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4">Вопросы категории: <span class="text-primary">{{ product.name }}</span></h2>
|
||||||
|
|
||||||
|
<form method="post" class="mb-4">
|
||||||
|
<div class="row g-2">
|
||||||
|
|
||||||
|
<div class="col-md-10">
|
||||||
|
<textarea name="question_text" class="form-control" placeholder="Текст вопроса" rows="1" required></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-grid">
|
||||||
|
<button type="submit" class="btn btn-success">➕ Добавить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ul class="list-group shadow-sm">
|
||||||
|
{% for feature in features %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>{{ feature.name }}</strong><br>
|
||||||
|
<small class="text-muted">{{ feature.question_text }}</small>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('delete_feature', feature_id=feature.id, product_id=product.id) }}"
|
||||||
|
onsubmit="return confirm('Удалить вопрос «{{ feature.name }}»?');">
|
||||||
|
<button class="btn btn-sm btn-outline-danger">🗑 Удалить</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="list-group-item text-center text-muted">Нет добавленных вопросов</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<a href="{{ url_for('manage_products', survey_id=product.survey_type.id) }}" class="btn btn-link mt-4">← Назад к категориям</a>
|
||||||
|
{% endblock %}
|
||||||
33
templates/admin/platforms_by_survey.html
Normal file
33
templates/admin/platforms_by_survey.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4">Платформы опроса: {{ survey.name }}</h2>
|
||||||
|
|
||||||
|
<form method="post" class="mb-4">
|
||||||
|
<div class="input-group shadow-sm">
|
||||||
|
<input type="text" name="name" class="form-control" placeholder="Название платформы" required>
|
||||||
|
<button type="submit" class="btn btn-success">➕ Добавить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="list-group shadow-sm">
|
||||||
|
{% for platform in platforms %}
|
||||||
|
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<strong>{{ platform.name }}</strong>
|
||||||
|
<form method="post"
|
||||||
|
action="{{ url_for('delete_platform_by_survey', survey_id=survey.id, platform_id=platform.id) }}"
|
||||||
|
onsubmit="return confirm('Удалить платформу «{{ platform.name }}»?');">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">🗑</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="list-group-item text-muted text-center">Платформ пока нет</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 d-flex justify-content-between">
|
||||||
|
<a href="{{ url_for('manage_support_by_survey', survey_id=survey.id) }}" class="btn btn-outline-secondary">
|
||||||
|
⚙ Управление поддержкой функциональности
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('manage_surveys') }}" class="btn btn-link">← Назад к опросам</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
32
templates/admin/products.html
Normal file
32
templates/admin/products.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4">Категории опроса: <span class="text-primary">{{ survey.name }}</span></h2>
|
||||||
|
|
||||||
|
<form method="post" class="mb-4">
|
||||||
|
<div class="input-group shadow-sm">
|
||||||
|
<input type="text" name="name" class="form-control" placeholder="Название категории (например: Базовые возможности)" required>
|
||||||
|
<button type="submit" class="btn btn-success">➕ Добавить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="list-group shadow-sm">
|
||||||
|
{% for product in products %}
|
||||||
|
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>{{ product.name }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ url_for('manage_features', product_id=product.id) }}" class="btn btn-sm btn-outline-primary">Вопросы</a>
|
||||||
|
<form method="post" action="{{ url_for('delete_product', product_id=product.id, survey_id=survey.id) }}"
|
||||||
|
onsubmit="return confirm('Удалить категорию «{{ product.name }}»?');">
|
||||||
|
<button class="btn btn-sm btn-outline-danger">🗑</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="list-group-item text-muted text-center">Нет категорий</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{{ url_for('manage_surveys') }}" class="btn btn-link mt-4">← Назад к опросникам</a>
|
||||||
|
{% endblock %}
|
||||||
37
templates/admin/support.html
Normal file
37
templates/admin/support.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4">Матрица поддержки: {{ survey.name }}</h2>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<table class="table table-bordered table-sm align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Функция</th>
|
||||||
|
{% for platform in platforms %}
|
||||||
|
<th class="text-center">{{ platform.name }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for feature in features %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ feature.name }}</strong><br>
|
||||||
|
<small>{{ feature.question_text }}</small>
|
||||||
|
</td>
|
||||||
|
{% for platform in platforms %}
|
||||||
|
<td class="text-center">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="support_{{ platform.id }}_{{ feature.id }}"
|
||||||
|
{% if support[platform.id][feature.id] %}checked{% endif %}>
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button type="submit" class="btn btn-success mt-3">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<a href="{{ url_for('manage_platforms_by_survey', survey_id=survey.id) }}" class="btn btn-link mt-4">← Назад к платформам</a>
|
||||||
|
{% endblock %}
|
||||||
48
templates/admin/support_by_survey.html
Normal file
48
templates/admin/support_by_survey.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2 class="mb-0">Матрица поддержки: {{ survey.name }}</h2>
|
||||||
|
<a href="{{ url_for('manage_platforms_by_survey', survey_id=survey.id) }}" class="btn btn-outline-secondary">← Назад к платформам</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-bordered table-hover align-middle">
|
||||||
|
<thead class="table-dark text-center">
|
||||||
|
<tr>
|
||||||
|
<th class="text-start">Функция</th>
|
||||||
|
{% for platform in platforms %}
|
||||||
|
<th>{{ platform.name }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for feature in features %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ feature.question_text }}</strong><br>
|
||||||
|
<!-- <span class="text-muted small">{{ feature.question_text }}</span>-->
|
||||||
|
</td>
|
||||||
|
{% for platform in platforms %}
|
||||||
|
<td class="text-center">
|
||||||
|
<div class="form-check form-switch d-flex justify-content-center">
|
||||||
|
<input class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
name="support_{{ platform.id }}_{{ feature.id }}"
|
||||||
|
{% if support[platform.id][feature.id] %}checked{% endif %}>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary">💾 Сохранить</button>
|
||||||
|
<a href="{{ url_for('choose_survey') }}" class="btn btn-link">На главную</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
BIN
templates/admin/survey.db
Normal file
BIN
templates/admin/survey.db
Normal file
Binary file not shown.
51
templates/admin/surveys.html
Normal file
51
templates/admin/surveys.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4">Опросники</h2>
|
||||||
|
|
||||||
|
<form method="post" class="mb-4">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" name="name" class="form-control" placeholder="Название опроса" required>
|
||||||
|
<button type="submit" class="btn btn-primary">Создать</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% for survey in surveys %}
|
||||||
|
<div class="card mb-4 p-3">
|
||||||
|
<h5>{{ survey.name }}</h5>
|
||||||
|
|
||||||
|
<!-- Старая админская панель -->
|
||||||
|
<div class="btn-group mb-3">
|
||||||
|
<a href="{{ url_for('manage_products', survey_id=survey.id) }}" class="btn btn-sm btn-outline-secondary">Категории и вопросы</a>
|
||||||
|
<a href="{{ url_for('manage_platforms_by_survey', survey_id=survey.id) }}" class="btn btn-sm btn-outline-primary">Продукты</a>
|
||||||
|
<a href="{{ url_for('manage_support_by_survey', survey_id=survey.id) }}" class="btn btn-sm btn-outline-success">Матрица</a>
|
||||||
|
<form method="post" action="{{ url_for('delete_survey', survey_id=survey.id) }}" onsubmit="return confirm('Удалить опрос {{ survey.name }}?')">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Назначения пользователям -->
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<!-- Назначения пользователям (только для админа) -->
|
||||||
|
<form method="POST" action="{{ url_for('assign_bulk_surveys') }}">
|
||||||
|
<input type="hidden" name="survey_id" value="{{ survey.id }}">
|
||||||
|
<div class="row row-cols-2 row-cols-md-3 row-cols-lg-4 g-2">
|
||||||
|
{% for user in users %}
|
||||||
|
<div class="form-check col">
|
||||||
|
<input class="form-check-input" type="checkbox" name="user_ids" value="{{ user.id }}"
|
||||||
|
id="user{{ user.id }}_survey{{ survey.id }}"
|
||||||
|
{% if user.id in assignments[survey.id] %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="user{{ user.id }}_survey{{ survey.id }}">
|
||||||
|
{{ user.username }} ({{ user.full_name or "без ФИО" }})
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button class="btn btn-sm btn-primary">Сохранить назначения</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
61
templates/choose_survey.html
Normal file
61
templates/choose_survey.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<h2>Доступные опросы</h2>
|
||||||
|
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
{% for survey in surveys %}
|
||||||
|
<div class="card mb-4 p-3">
|
||||||
|
<h5>{{ survey.name }}</h5>
|
||||||
|
|
||||||
|
<!-- Кнопка пройти -->
|
||||||
|
<a href="{{ url_for('show_quiz', survey_id=survey.id) }}" class="btn btn-sm btn-primary mb-2">Пройти</a>
|
||||||
|
|
||||||
|
<!-- Расшаривание пользователям -->
|
||||||
|
<form method="POST" action="{{ url_for('assign_user_survey_bulk') }}">
|
||||||
|
<input type="hidden" name="survey_id" value="{{ survey.id }}">
|
||||||
|
<div class="row row-cols-2 row-cols-md-3 row-cols-lg-4 g-2">
|
||||||
|
{% for user in users %}
|
||||||
|
<div class="form-check col">
|
||||||
|
<input class="form-check-input" type="checkbox" name="user_ids"
|
||||||
|
value="{{ user.id }}"
|
||||||
|
id="survey{{ survey.id }}_user{{ user.id }}"
|
||||||
|
{% if survey.id in assignments and user.id in assignments[survey.id] %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="survey{{ survey.id }}_user{{ user.id }}">
|
||||||
|
{{ user.username }} ({{ user.full_name or "без ФИО" }})
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button class="btn btn-sm btn-warning">Сохранить назначения</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
{% for us in surveys %}
|
||||||
|
<div class="list-group mb-2">
|
||||||
|
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div class="fw-bold">{{ us.survey_type.name }}</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="{{ url_for('show_quiz', survey_id=us.survey_type.id) }}" class="btn btn-sm btn-primary">Пройти</a>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="collapse" data-bs-target="#send{{ us.id }}">Отправить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="collapse p-3 border rounded" id="send{{ us.id }}">
|
||||||
|
<form id="send-survey-form" method="POST" action="{{ url_for('send_survey', survey_id=us.id) }}">
|
||||||
|
<input type="email" name="email" class="form-control mb-2" placeholder="Email" required>
|
||||||
|
<div class="form-check"><input class="form-check-input" type="checkbox" name="show_result"> Показывать результат</div>
|
||||||
|
<div class="form-check"><input class="form-check-input" type="checkbox" name="ask_full_name"> Указать ФИО</div>
|
||||||
|
<div class="form-check"><input class="form-check-input" type="checkbox" name="ask_phone"> Указать телефон</div>
|
||||||
|
<div class="form-check"><input class="form-check-input" type="checkbox" name="ask_organization"> Указать организацию</div>
|
||||||
|
<button class="btn btn-success btn-sm mt-2">Отправить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
61
templates/invite/form.html
Normal file
61
templates/invite/form.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container my-5">
|
||||||
|
<h2 class="text-center mb-4">Опрос: {{ survey.survey_type.name }}</h2>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
{% if invite.ask_full_name or invite.ask_phone or invite.ask_organization %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-info text-white fw-bold">
|
||||||
|
Информация о респонденте
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if invite.ask_full_name %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="full_name" class="form-label">ФИО</label>
|
||||||
|
<input type="text" name="full_name" id="full_name" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if invite.ask_phone %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="phone" class="form-label">Телефон</label>
|
||||||
|
<input type="text" name="phone" id="phone" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if invite.ask_organization %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="organization" class="form-label">Наименование организации</label>
|
||||||
|
<input type="text" name="organization" id="organization" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for group, items in questions.items() %}
|
||||||
|
<div class="card mb-4 shadow-sm">
|
||||||
|
<div class="card-header bg-primary text-white fw-bold">
|
||||||
|
{{ group }}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% for key, text in items.items() %}
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" name="{{ key }}" id="{{ key }}">
|
||||||
|
<label class="form-check-label" for="{{ key }}">{{ text }}</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="comment" class="form-label">Комментарий</label>
|
||||||
|
<textarea name="comment" id="comment" class="form-control" rows="4" placeholder="Оставьте ваш комментарий..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn">
|
||||||
|
Отправить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
44
templates/invite/result.html
Normal file
44
templates/invite/result.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<h2 class="text-center mb-4">Результат сравнения платформ</h2>
|
||||||
|
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="mb-3">Процент соответствия платформ:</h5>
|
||||||
|
<ul class="list-group mb-4">
|
||||||
|
{% for platform, score in scores.items() %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
{{ platform }}
|
||||||
|
<span class="badge bg-primary rounded-pill">{{ score }}%</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h5 class="mb-3">Неподдерживаемые функции:</h5>
|
||||||
|
{% for platform, features in unsupported_features.items() %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>{{ platform }}:</strong>
|
||||||
|
{% if features %}
|
||||||
|
<ul class="mt-1">
|
||||||
|
{% for f in features %}
|
||||||
|
<li>{{ f }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-success mb-0">Все функции поддерживаются</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="text-end">
|
||||||
|
<a href="https://mont.ru" class="btn btn-outline-secondary mt-4">Перейти на сайт МОНТ</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
34
templates/invite/thankyou.html
Normal file
34
templates/invite/thankyou.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="text-center">
|
||||||
|
<h2 class="text-success mb-3">Спасибо за участие!</h2>
|
||||||
|
<p class="lead">Ваши ответы сохранены.</p>
|
||||||
|
|
||||||
|
{% if show_result %}
|
||||||
|
<hr>
|
||||||
|
<h4>Результаты:</h4>
|
||||||
|
<ul class="list-group mb-3">
|
||||||
|
{% for platform, percent in scores.items() %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
{{ platform }}
|
||||||
|
<span class="badge bg-primary rounded-pill">{{ percent }}%</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h5 class="text-danger">Неподдерживаемые функции:</h5>
|
||||||
|
{% for platform, feature_list in unsupported.items() %}
|
||||||
|
{% if feature_list %}
|
||||||
|
<p><strong>{{ platform }}:</strong></p>
|
||||||
|
<ul>
|
||||||
|
{% for text in feature_list %}
|
||||||
|
<li>{{ text }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
106
templates/layout.html
Normal file
106
templates/layout.html
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{{ title or "Админка" }}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
.navbar {
|
||||||
|
background-color: #e9f6ff !important;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 0 0 10px 10px;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
.navbar-brand {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: #000 !important;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 960px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="navbar navbar-expand-lg">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand d-flex align-items-center" href="{{ url_for('choose_survey') }}">
|
||||||
|
<img src="{{ url_for('static', filename='mont.png') }}" height="40" class="me-2">
|
||||||
|
<span>Опросник</span>
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navbarNav" aria-controls="navbarNav"
|
||||||
|
aria-expanded="false" aria-label="Меню">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse justify-content-end" id="navbarNav">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link{% if request.path.startswith('/quiz') %} active{% endif %}" href="{{ url_for('choose_survey') }}">Опросы</a>
|
||||||
|
</li>
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link{% if request.path.startswith('/admin') %} active{% endif %}" href="{{ url_for('manage_surveys') }}">Админка</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link{% if request.path.startswith('/dashboard') %} active{% endif %}" href="{{ url_for('dashboard') }}">Отчёты</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item d-flex align-items-center text-dark ms-3">
|
||||||
|
<i class="bi bi-person-circle me-1"></i> {{ current_user.full_name or current_user.username }}
|
||||||
|
{% if current_user.is_admin %}<span class="badge bg-danger ms-2">Админ</span>{% endif %}
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('logout') }}">Выйти</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link{% if request.path.startswith('/login') %} active{% endif %}" href="{{ url_for('login') }}">Вход</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container my-5">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
function showLoading(form) {
|
||||||
|
const button = form.querySelector('button[type="submit"]');
|
||||||
|
button.disabled = true;
|
||||||
|
button.innerHTML = `<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Отправка...`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const form = document.querySelector('form');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
|
||||||
|
if (form && submitBtn) {
|
||||||
|
form.addEventListener('submit', function () {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '⏳ Отправка...';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
18
templates/login.html
Normal file
18
templates/login.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<h2>Вход</h2>
|
||||||
|
<form method="POST">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Имя пользователя</label>
|
||||||
|
<input type="text" name="username" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Пароль</label>
|
||||||
|
<input type="password" name="password" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Войти</button>
|
||||||
|
<a href="{{ url_for('register') }}" class="btn btn-link">Регистрация</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
27
templates/quiz.html
Normal file
27
templates/quiz.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4 text-center">Опрос: {{ survey.name }}</h2>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('show_quiz', survey_id=survey.id) }}">
|
||||||
|
{% for category, features in questions.items() %}
|
||||||
|
<div class="card mb-4 shadow-sm">
|
||||||
|
<div class="card-header bg-primary text-white fw-semibold fs-5">
|
||||||
|
{{ category }}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% for feature in features %}
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" value="1" name="feature_{{ feature.id }}" id="f{{ feature.id }}">
|
||||||
|
<label class="form-check-label" for="f{{ feature.id }}">
|
||||||
|
{{ feature.question_text }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="text-end">
|
||||||
|
<button type="submit" class="btn btn-success btn-lg" id="submitBtn">Отправить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
26
templates/register.html
Normal file
26
templates/register.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<h2>Регистрация</h2>
|
||||||
|
<form method="POST">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Имя пользователя</label>
|
||||||
|
<input type="text" name="username" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">ФИО</label>
|
||||||
|
<input type="text" name="full_name" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<input type="email" name="email" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Пароль</label>
|
||||||
|
<input type="password" name="password" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Зарегистрироваться</button>
|
||||||
|
<a href="{{ url_for('login') }}" class="btn btn-link">Уже есть аккаунт?</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
132
templates/result.html
Normal file
132
templates/result.html
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="text-center mb-5">
|
||||||
|
<h1 class="fw-bold">Результаты выбора платформы</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="results-container">
|
||||||
|
<div class="card shadow-sm mb-4 p-4">
|
||||||
|
<canvas id="platformChart" height="100"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for platform, score in scores.items() %}
|
||||||
|
<div class="card mb-3 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="card-title text-primary mb-2">{{ platform }} — {{ "%.2f"|format(score) }}%</h4>
|
||||||
|
|
||||||
|
{% if unsupported_features[platform] %}
|
||||||
|
<p class="text-danger fw-semibold mb-1">Не поддерживается:</p>
|
||||||
|
<ul class="text-danger mb-0">
|
||||||
|
{% for feature in unsupported_features[platform] %}
|
||||||
|
<li>{{ feature }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-success mb-0">Все необходимые функции поддерживаются ✅</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<button class="btn btn-outline-primary btn-lg" onclick="downloadPDF()">Скачать в PDF</button>
|
||||||
|
<a class="btn btn-link mt-3 d-block" href="{{ url_for('choose_survey') }}">← Вернуться к выбору</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/jspdf.roboto.js') }}"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const labels = {{ chart_labels|tojson }};
|
||||||
|
const data = {{ chart_data|tojson }};
|
||||||
|
const scores = {{ scores|tojson }};
|
||||||
|
const unsupported = {{ unsupported_features|tojson }};
|
||||||
|
|
||||||
|
const ctx = document.getElementById('platformChart').getContext('2d');
|
||||||
|
const platformChart = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Совпадение (%)',
|
||||||
|
data: data,
|
||||||
|
backgroundColor: 'rgba(52, 152, 219, 0.6)',
|
||||||
|
borderColor: 'rgba(52, 152, 219, 1)',
|
||||||
|
borderWidth: 1
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
max: 100,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Процент совпадения'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Платформы'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function downloadPDF() {
|
||||||
|
const { jsPDF } = window.jspdf;
|
||||||
|
const doc = new jsPDF();
|
||||||
|
|
||||||
|
doc.addFileToVFS("Roboto.ttf", roboto_base64);
|
||||||
|
doc.addFont("Roboto.ttf", "Roboto", "normal");
|
||||||
|
doc.setFont("Roboto");
|
||||||
|
doc.setFontSize(18);
|
||||||
|
doc.text("Результаты выбора платформы", 10, 15);
|
||||||
|
|
||||||
|
const chartCanvas = document.getElementById('platformChart');
|
||||||
|
const chartImage = await html2canvas(chartCanvas);
|
||||||
|
const chartDataUrl = chartImage.toDataURL('image/png');
|
||||||
|
doc.addImage(chartDataUrl, 'PNG', 10, 25, 180, 90);
|
||||||
|
|
||||||
|
let y = 120;
|
||||||
|
doc.setFontSize(12);
|
||||||
|
|
||||||
|
for (let platform of labels) {
|
||||||
|
const score = scores[platform].toFixed(2);
|
||||||
|
doc.text(`${platform}: ${score}%`, 10, y);
|
||||||
|
y += 8;
|
||||||
|
|
||||||
|
const unsupp = unsupported[platform];
|
||||||
|
if (unsupp && unsupp.length) {
|
||||||
|
doc.text("Не поддерживается:", 12, y);
|
||||||
|
y += 8;
|
||||||
|
for (let f of unsupp) {
|
||||||
|
doc.text(`- ${f}`, 16, y);
|
||||||
|
y += 6;
|
||||||
|
if (y > 270) {
|
||||||
|
doc.addPage();
|
||||||
|
y = 20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
doc.text("✔ Все необходимые функции поддерживаются", 12, y);
|
||||||
|
y += 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.save("Результаты_платформы.pdf");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
88
templates/user/dashboard.html
Normal file
88
templates/user/dashboard.html
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<h2>Мои отправленные опросы</h2>
|
||||||
|
|
||||||
|
{% if invites %}
|
||||||
|
{% for invite in invites %}
|
||||||
|
<div class="card mb-3 border {% if invite.responded %}border-success bg-light{% else %}border-danger bg-light{% endif %}">
|
||||||
|
<div class="card-body d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>{{ invite.survey.survey_type.name }}</strong><br>
|
||||||
|
<small class="text-muted">Отправлено: {{ invite.sent_at.strftime('%d.%m.%Y %H:%M') }}</small><br>
|
||||||
|
<small>Кому: {{ invite.recipient_email }}</small><br>
|
||||||
|
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<small class="text-muted">Отправитель: {{ invite.survey.user.full_name }} ({{ invite.survey.user.email }})</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-group">
|
||||||
|
{% if invite.responded %}
|
||||||
|
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" data-bs-target="#r{{ invite.id }}">
|
||||||
|
Показать результат
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if not invite.responded %}
|
||||||
|
<form method="POST" action="{{ url_for('send_survey', survey_id=invite.survey.id) }}">
|
||||||
|
<input type="hidden" name="email" value="{{ invite.recipient_email }}">
|
||||||
|
<input type="hidden" name="show_result" value="{{ 1 if invite.show_result else 0 }}">
|
||||||
|
<input type="hidden" name="ask_full_name" value="{{ 1 if invite.ask_full_name else 0 }}">
|
||||||
|
<input type="hidden" name="ask_phone" value="{{ 1 if invite.ask_phone else 0 }}">
|
||||||
|
<input type="hidden" name="ask_organization" value="{{ 1 if invite.ask_organization else 0 }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-warning">Отправить повторно</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="POST" action="{{ url_for('delete_invite', invite_id=invite.id) }}" onsubmit="return confirm('Удалить приглашение?')">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">Удалить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="collapse" id="r{{ invite.id }}">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if invite.responded and results[invite.id] %}
|
||||||
|
{% set result = results[invite.id] %}
|
||||||
|
{% set scores = result.platform_scores | from_json %}
|
||||||
|
{% set unsupported = result.unsupported | from_json %}
|
||||||
|
|
||||||
|
<p><strong>ФИО:</strong> {{ result.full_name or '—' }}</p>
|
||||||
|
<p><strong>Телефон:</strong> {{ result.phone or '—' }}</p>
|
||||||
|
<p><strong>Организация:</strong> {{ result.organization or '—' }}</p>
|
||||||
|
<p><strong>Получен:</strong> {{ result.submitted_at.strftime('%d.%m.%Y %H:%M') }}</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<p><strong>Процент соответствия платформ:</strong></p>
|
||||||
|
<ul>
|
||||||
|
{% for p, percent in scores.items() %}
|
||||||
|
<li>{{ p }}: {{ percent }}%</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p><strong>Неподдерживаемые функции:</strong></p>
|
||||||
|
<ul>
|
||||||
|
{% for p, items in unsupported.items() %}
|
||||||
|
<li>{{ p }}:
|
||||||
|
{% if items %}
|
||||||
|
{{ items | join(", ") }}
|
||||||
|
{% else %}
|
||||||
|
все поддерживаются
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p><strong>Комментарий:</strong><br>{{ results[invite.id].comment or "—" }}</p>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<p class="text-danger">Ответ ещё не получен.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p>Вы ещё не отправляли опросы.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user