/
Denis57
/
Examing
Обзор
Документация
Войти
/
Denis57
/
Examing
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
app.py
752 строки
37 KB
Денис Клушин
Загрузить файлы в «»
28 июн 2026, 17:10
Верифицирован
28 июн 2026, 17:10
bfb02ee
Код
Авторство
О чём код?
""" Модуль основного приложения для системы тестирования Этот модуль реализует веб-приложение на Flask для создания, прохождения и управления тестами с системой аутентификации пользователей и административной панелью. Основные функции: - Регистрация и аутентификация пользователей - Создание тестов с произвольным количеством вопросов и вариантов ответов - Прохождение тестов с автоматической проверкой - Сохранение результатов в базу данных и текстовые файлы - Административная панель для управления тестами - Личный кабинет пользователя с историей прохождений Технологии: - Flask: веб-фреймворк - SQLAlchemy: ORM для работы с базой данных - Flask-Login: управление сессиями пользователей - SQLite: база данных (может быть заменена на другую СУБД) """ import os import re from datetime import datetime from functools import wraps from flask import Flask, render_template, request, redirect, url_for, flash, session from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user from sqlalchemy.orm import joinedload # ===================================================================== # ИНИЦИАЛИЗАЦИЯ ПРИЛОЖЕНИЯ # ===================================================================== # Создание экземпляра Flask-приложения # Это главный объект, который обрабатывает HTTP-запросы и управляет маршрутами app = Flask(__name__) # Конфигурация приложения # SECRET_KEY: используется для подписи сессионных cookie и защиты от CSRF-атак # В production-среде должен быть заменен на случайную строку, хранящуюся в переменных окружения app.config['SECRET_KEY'] = 'super_secret_key_change_me' # SQLALCHEMY_DATABASE_URI: строка подключения к базе данных # Используется SQLite для простоты развертывания, файл tests.db создается автоматически app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///tests.db' # Отключение отслеживания изменений объектов для экономии ресурсов (рекомендуется Flask-SQLAlchemy) app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False # Инициализация объектов для работы с базой данных и аутентификацией db = SQLAlchemy(app) # Объект для взаимодействия с базой данных через ORM login_manager = LoginManager(app) # Менеджер для управления пользовательскими сессиями login_manager.login_view = 'login' # Перенаправление неавторизованных пользователей на страницу входа # ===================================================================== # МОДЕЛИ БАЗЫ ДАННЫХ (ORM-классы) # ===================================================================== class User(UserMixin, db.Model): """ Модель пользователя системы Хранит информацию о зарегистрированных пользователях. Наследует UserMixin для интеграции с Flask-Login. Атрибуты: id (int): Уникальный идентификатор пользователя (первичный ключ) fullname (str): Полное имя пользователя (обязательное поле) phone (str): Номер телефона в формате +7 (XXX) XXX-XX-XX (уникальный) email (str): Адрес электронной почты (уникальный, используется для входа) password (str): Пароль (в текущей версии хранится в открытом виде, рекомендуется использовать хеширование) results (relationship): Связь с результатами тестов пользователя Методы: get_id(): Возвращает идентификатор пользователя (требуется Flask-Login) """ id = db.Column(db.Integer, primary_key=True) fullname = db.Column(db.String(100), nullable=False) phone = db.Column(db.String(20), unique=True, nullable=False) email = db.Column(db.String(100), unique=True, nullable=False) password = db.Column(db.String(100), nullable=False) results = db.relationship('TestResult', backref='user', lazy=True) class Test(db.Model): """ Модель теста Представляет собой набор вопросов с вариантами ответов. Тесты создаются администратором и доступны для прохождения пользователями. Атрибуты: id (int): Уникальный идентификатор теста (первичный ключ) title (str): Название теста (отображается пользователям) created_at (datetime): Дата и время создания теста questions (relationship): Список вопросов, входящих в тест test_results (relationship): Список результатов прохождения теста Связи: Вопросы удаляются каскадно при удалении теста (настроено в моделях Question) """ id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(200), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) questions = db.relationship('Question', backref='test', lazy=True) test_results = db.relationship('TestResult', backref='test_obj', lazy=True) class Question(db.Model): """ Модель вопроса Каждый вопрос принадлежит конкретному тесту и содержит несколько вариантов ответов. Атрибуты: id (int): Уникальный идентификатор вопроса (первичный ключ) test_id (int): Идентификатор теста, к которому относится вопрос (внешний ключ) text (str): Текст вопроса (отображается пользователю) answers (relationship): Список вариантов ответов на вопрос Примечание: Для корректного отображения вопросов в тесте они должны быть упорядочены по id """ id = db.Column(db.Integer, primary_key=True) test_id = db.Column(db.Integer, db.ForeignKey('test.id'), nullable=False) text = db.Column(db.String(500), nullable=False) answers = db.relationship('Answer', backref='question', lazy=True) class Answer(db.Model): """ Модель варианта ответа Представляет собой один вариант ответа на вопрос. Может быть правильным или неправильным. Атрибуты: id (int): Уникальный идентификатор ответа (первичный ключ) question_id (int): Идентификатор вопроса (внешний ключ) text (str): Текст варианта ответа is_correct (bool): Флаг, указывающий на правильность ответа Примечание: Для каждого вопроса должен быть ровно один правильный ответ (это гарантируется логикой создания теста) """ id = db.Column(db.Integer, primary_key=True) question_id = db.Column(db.Integer, db.ForeignKey('question.id'), nullable=False) text = db.Column(db.String(200), nullable=False) is_correct = db.Column(db.Boolean, default=False) class TestResult(db.Model): """ Модель результата прохождения теста Сохраняет результаты каждого прохождения теста пользователем. Содержит как итоговый балл, так и детальную информацию о каждом ответе. Атрибуты: id (int): Уникальный идентификатор результата (первичный ключ) user_id (int): Идентификатор пользователя, прошедшего тест (внешний ключ) test_id (int): Идентификатор пройденного теста (внешний ключ) score (int): Количество правильных ответов total_questions (int): Общее количество вопросов в тесте date_taken (datetime): Дата и время прохождения теста details (str): Детальная информация по каждому вопросу (текстовое поле) Связи: user: Ссылка на модель User test: Ссылка на модель Test Примечание: Поле details содержит структурированный текст с ответами пользователя и правильными ответами для последующего анализа """ id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) test_id = db.Column(db.Integer, db.ForeignKey('test.id'), nullable=False) score = db.Column(db.Integer, nullable=False) total_questions = db.Column(db.Integer, nullable=False) date_taken = db.Column(db.DateTime, default=datetime.utcnow) details = db.Column(db.Text, nullable=True) # Явное объявление связи для оптимизации запросов через joinedload test = db.relationship('Test', backref='results') # ===================================================================== # ИНИЦИАЛИЗАЦИЯ БАЗЫ ДАННЫХ # ===================================================================== # Создание всех таблиц в базе данных, если они еще не существуют # Этот код выполняется при запуске приложения в контексте приложения with app.app_context(): db.create_all() # ===================================================================== # ЗАГРУЗЧИК ПОЛЬЗОВАТЕЛЯ ДЛЯ FLASK-LOGIN # ===================================================================== @login_manager.user_loader def load_user(user_id): """ Загрузчик пользователя для Flask-Login Функция вызывается Flask-Login для получения объекта пользователя по его ID. Результат кэшируется в сессии. Аргументы: user_id (str): Идентификатор пользователя в строковом формате Возвращает: User: Объект пользователя или None, если пользователь не найден Примечание: Flask-Login передает user_id как строку, поэтому необходимо преобразование """ return User.query.get(int(user_id)) # ===================================================================== # ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ И ДЕКОРАТОРЫ # ===================================================================== def admin_required(f): """ Декоратор для ограничения доступа к маршрутам только для администраторов Проверяет, аутентифицирован ли пользователь и имеет ли он email admin@admin.com. В случае нарушения доступа перенаправляет на страницу входа с сообщением об ошибке. Аргументы: f (function): Декорируемая функция-обработчик маршрута Возвращает: function: Обернутую функцию с проверкой прав Примечание: В production-среде рекомендуется использовать более надежную систему ролей и хранить список администраторов в базе данных """ @wraps(f) def decorated_function(*args, **kwargs): if not current_user.is_authenticated or current_user.email != 'admin@admin.com': flash('Доступ запрещен. Требуются права администратора.', 'danger') return redirect(url_for('login')) return f(*args, **kwargs) return decorated_function def validate_phone(phone): """ Проверка соответствия номера телефона требуемому формату Используется регулярное выражение для валидации ввода номера телефона. Аргументы: phone (str): Строка с номером телефона для проверки Возвращает: bool: True, если номер соответствует формату +7 (XXX) XXX-XX-XX, иначе False Формат: +7 (XXX) XXX-XX-XX Пример: +7 (999) 123-45-67 Примечание: Используется для проверки ввода при регистрации пользователя """ pattern = r'^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$' return re.match(pattern, phone) is not None def save_result_to_file(result_obj, user, test): """ Сохранение подробного результата прохождения теста в текстовый файл Создает или дополняет файл detailed_results.txt структурированной информацией о прохождении теста: дата, пользователь, контакты, результаты и детализация ответов. Аргументы: result_obj (TestResult): Объект результата теста user (User): Объект пользователя, прошедшего тест test (Test): Объект пройденного теста Примечание: Файл создается в корневой директории приложения. В production-среде рекомендуется настроить отдельную директорию для логов. """ filename = "detailed_results.txt" with open(filename, "a", encoding="utf-8") as f: f.write(f"{'='*60}\n") f.write(f"Дата прохождения: {result_obj.date_taken.strftime('%Y-%m-%d %H:%M:%S')}\n") f.write(f"Пользователь: {user.fullname}\n") f.write(f"Контакты: {user.phone} | {user.email}\n") f.write(f"Название теста: {test.title} (ID: {test.id})\n") f.write(f"Итоговый балл: {result_obj.score} из {result_obj.total_questions}\n") f.write(f"--- Детализация ответов ---\n") f.write(f"{result_obj.details}\n") f.write(f"{'='*60}\n\n") # ===================================================================== # МАРШРУТЫ АУТЕНТИФИКАЦИИ # ===================================================================== @app.route('/') def index(): """ Главная страница приложения Перенаправляет авторизованных пользователей в личный кабинет, неавторизованных - на страницу входа. Возвращает: redirect: Перенаправление на соответствующий маршрут """ if current_user.is_authenticated: return redirect(url_for('profile')) return redirect(url_for('login')) @app.route('/register', methods=['GET', 'POST']) def register(): """ Страница регистрации нового пользователя GET-запрос: отображает форму регистрации POST-запрос: обрабатывает данные регистрации Валидация: - Проверка формата телефона через validate_phone() - Проверка уникальности email и телефона При успешной регистрации: - Создается новый пользователь в базе данных - Выводится сообщение об успехе - Происходит перенаправление на страницу входа Возвращает: str: HTML-шаблон страницы регистрации или перенаправление """ if request.method == 'POST': fullname = request.form.get('fullname') phone = request.form.get('phone') email = request.form.get('email') password = request.form.get('password') # Валидация номера телефона if not validate_phone(phone): flash('Неверный формат телефона. Используйте формат +7 (XXX) XXX-XX-XX', 'danger') return redirect(url_for('register')) # Проверка на существующего пользователя if User.query.filter_by(email=email).first() or User.query.filter_by(phone=phone).first(): flash('Пользователь с таким Email или телефоном уже зарегистрирован.', 'danger') return redirect(url_for('register')) # Создание нового пользователя new_user = User(fullname=fullname, phone=phone, email=email, password=password) db.session.add(new_user) db.session.commit() flash('Регистрация успешна! Теперь вы можете войти.', 'success') return redirect(url_for('login')) return render_template('register.html') @app.route('/login', methods=['GET', 'POST']) def login(): """ Страница входа в систему GET-запрос: отображает форму входа POST-запрос: проверяет учетные данные и выполняет вход Процесс входа: 1. Поиск пользователя по email 2. Проверка пароля (сравнение строк, в будущем - хеширование) 3. При успехе - вход через login_user() 4. Перенаправление на запрошенную страницу или профиль Возвращает: str: HTML-шаблон страницы входа или перенаправление Примечание: Пароли хранятся в открытом виде, что небезопасно. Рекомендуется использовать библиотеку bcrypt или werkzeug.security """ if request.method == 'POST': email = request.form.get('email') password = request.form.get('password') user = User.query.filter_by(email=email).first() if user and user.password == password: login_user(user) next_page = request.args.get('next') return redirect(next_page or url_for('profile')) else: flash('Неверный логин или пароль', 'danger') return render_template('login.html') @app.route('/logout') @login_required def logout(): """ Выход из системы Завершает пользовательскую сессию через logout_user() и перенаправляет на страницу входа. Возвращает: redirect: Перенаправление на страницу входа """ logout_user() return redirect(url_for('login')) # ===================================================================== # МАРШРУТЫ ЛИЧНОГО КАБИНЕТА # ===================================================================== @app.route('/profile') @login_required def profile(): """ Личный кабинет пользователя Отображает профиль пользователя и историю прохождения тестов. Результаты сортируются по дате прохождения (сначала новые). Возвращает: str: HTML-шаблон страницы профиля с результатами Примечание: Используется joinedload для оптимизации запросов к БД (предотвращает проблему N+1 запросов) """ results = TestResult.query\ .options(joinedload(TestResult.test))\ .filter_by(user_id=current_user.id)\ .order_by(TestResult.date_taken.desc())\ .all() return render_template('profile.html', results=results) # ===================================================================== # МАРШРУТЫ АДМИНИСТРАТИВНОЙ ПАНЕЛИ # ===================================================================== @app.route('/admin') @admin_required def admin_dashboard(): """ Административная панель Отображает список всех тестов и результаты всех пользователей. Доступна только для администратора (admin@admin.com). Возвращает: str: HTML-шаблон административной панели Примечание: Используется joinedload для подгрузки связанных данных и оптимизации производительности """ tests = Test.query.order_by(Test.created_at.desc()).all() # Загрузка всех результатов с подгрузкой пользователей и тестов all_results = TestResult.query\ .options(joinedload(TestResult.user), joinedload(TestResult.test))\ .order_by(TestResult.date_taken.desc())\ .all() return render_template('admin_dashboard.html', tests=tests, all_results=all_results) @app.route('/admin/create', methods=['GET', 'POST']) @admin_required def create_test(): """ Создание нового теста (двухшаговый процесс) Шаг 1 (структура): - Запрос количества вопросов (от 1 до 50) - Сохранение в сессию Шаг 2 (контент): - Ввод названия теста - Для каждого вопроса: текст вопроса, варианты ответов (динамическое добавление) - Выбор правильного ответа для каждого вопроса - Сохранение всего теста в базу данных Особенности реализации: - Используется сессия для хранения промежуточных данных - Динамическое количество вариантов ответов на вопрос - Обработка ошибок при некорректном вводе Возвращает: str: HTML-шаблон страницы создания теста или перенаправление Примечание: При создании теста автоматически генерируется дата создания (created_at) """ # Инициализация сессии для хранения промежуточных данных if 'new_test_data' not in session: session['new_test_data'] = {} if request.method == 'POST': step = request.form.get('step') # Шаг 1: Определение структуры теста (количество вопросов) if step == 'structure': try: q_count = int(request.form.get('q_count')) if q_count < 1 or q_count > 50: raise ValueError # Сохранение количества вопросов в сессию session['new_test_data'] = { 'q_count': q_count } session.modified = True return redirect(url_for('create_test')) except (ValueError, TypeError): flash('Ошибка: введите корректное количество вопросов (от 1 до 50).', 'danger') return redirect(url_for('create_test')) # Шаг 2: Заполнение содержания и сохранение теста elif step == 'content': title = request.form.get('title') if not title: flash('Введите название теста', 'danger') return redirect(url_for('create_test')) structure = session.get('new_test_data') if not structure: flash('Сессия истекла. Начните создание теста заново.', 'danger') return redirect(url_for('create_test')) # Создание нового теста new_test = Test(title=title) db.session.add(new_test) db.session.flush() # Получение ID до коммита q_count = structure['q_count'] # Цикл создания вопросов for i in range(q_count): q_text = request.form.get(f'q_text_{i}') if not q_text: flash(f'Текст вопроса №{i+1} не может быть пустым.', 'danger') return redirect(url_for('create_test')) # Создание вопроса new_q = Question(test_id=new_test.id, text=q_text) db.session.add(new_q) db.session.flush() # Получение индекса правильного ответа correct_idx_str = request.form.get(f'correct_idx_{i}') correct_idx = int(correct_idx_str) if correct_idx_str else 0 # Динамический сбор вариантов ответов j = 0 while True: ans_text = request.form.get(f'ans_text_{i}_{j}') if not ans_text: break is_corr = (j == correct_idx) new_ans = Answer(question_id=new_q.id, text=ans_text, is_correct=is_corr) db.session.add(new_ans) j += 1 # Проверка наличия хотя бы одного варианта ответа if j == 0: flash(f'Для вопроса №{i+1} не указано ни одного варианта ответа.', 'danger') return redirect(url_for('create_test')) # Сохранение всех данных в базу db.session.commit() session.pop('new_test_data', None) # Очистка сессии flash('Тест успешно создан!', 'success') return redirect(url_for('admin_dashboard')) # GET-запрос: отображение формы return render_template('create_test.html', data=session.get('new_test_data')) # ===================================================================== # МАРШРУТЫ УДАЛЕНИЯ ТЕСТА # ===================================================================== @app.route('/admin/delete/<int:test_id>', methods=['POST']) @admin_required def delete_test(test_id): """ Удаление теста и всех связанных данных Каскадное удаление в правильном порядке: 1. Удаление результатов прохождений теста 2. Удаление ответов на вопросы 3. Удаление вопросов 4. Удаление самого теста Аргументы: test_id (int): Идентификатор удаляемого теста Возвращает: redirect: Перенаправление на административную панель Примечание: Используется try-except для обеспечения целостности данных при возможных ошибках """ test = Test.query.get_or_404(test_id) test_title = test.title try: # 1. Удаление результатов прохождений TestResult.query.filter_by(test_id=test_id).delete() # 2-3. Удаление всех вопросов и ответов for question in test.questions: Answer.query.filter_by(question_id=question.id).delete() Question.query.filter_by(test_id=test_id).delete() # 4. Удаление самого теста db.session.delete(test) db.session.commit() flash(f'Тест «{test_title}» и все связанные результаты успешно удалены.', 'success') except Exception as e: db.session.rollback() flash(f'Ошибка при удалении теста: {str(e)}', 'danger') return redirect(url_for('admin_dashboard')) # ===================================================================== # МАРШРУТЫ ПРОХОЖДЕНИЯ ТЕСТА # ===================================================================== @app.route('/test/<int:test_id>', methods=['GET', 'POST']) @login_required def take_test(test_id): """ Страница прохождения теста GET-запрос: Отображает тест для прохождения POST-запрос: Обрабатывает ответы и сохраняет результат Проверки: - Существование теста - Повторное прохождение (запрещено) Процесс обработки: 1. Получение всех вопросов теста 2. Для каждого вопроса: получение выбранного ответа 3. Подсчет правильных ответов 4. Формирование детальной информации 5. Сохранение результата в базу данных 6. Запись в текстовый файл 7. Отображение результата Аргументы: test_id (int): Идентификатор проходимого теста Возвращает: str: HTML-шаблон страницы прохождения или результатов Примечание: Форма отправляется методом POST со всеми ответами Используется индексация по ID вопроса для идентификации ответов """ test = Test.query.get_or_404(test_id) # Проверка на повторное прохождение already_taken = TestResult.query.filter_by(user_id=current_user.id, test_id=test_id).first() if already_taken: flash('Вы уже проходили этот тест. Повторное прохождение запрещено.', 'warning') return redirect(url_for('profile')) if request.method == 'POST': score = 0 total = len(test.questions) details_log = [] # Обработка каждого вопроса for q in test.questions: selected_answer_id = request.form.get(f'q_{q.id}') user_answer_obj = Answer.query.get(selected_answer_id) if selected_answer_id else None # Проверка правильности ответа is_correct = False if user_answer_obj and user_answer_obj.is_correct: score += 1 is_correct = True # Формирование детальной информации user_ans_text = user_answer_obj.text if user_answer_obj else "Ответ не дан" correct_ans_obj = next((a for a in q.answers if a.is_correct), None) correct_ans_text = correct_ans_obj.text if correct_ans_obj else "Н/Д" details_log.append( f"Вопрос: {q.text}\n" f" -> Выбор пользователя: {user_ans_text} [{'Верно' if is_correct else 'Ошибка'}]\n" f" -> Правильный ответ: {correct_ans_text}\n" ) # Сохранение результата result = TestResult( user_id=current_user.id, test_id=test.id, score=score, total_questions=total, details="\n".join(details_log) ) db.session.add(result) db.session.commit() # Запись в файл save_result_to_file(result, current_user, test) return render_template('result_page.html', score=score, total=total, test_title=test.title) # GET-запрос: отображение теста return render_template('take_test.html', test=test) # ===================================================================== # ЗАПУСК ПРИЛОЖЕНИЯ # ===================================================================== if __name__ == '__main__': """ Точка входа в приложение Запускает встроенный сервер Flask в режиме отладки (debug=True). В production-среде режим отладки должен быть отключен, а запуск производиться через WSGI-сервер (Gunicorn, uWSGI и т.д.). Примечание: - debug=True позволяет автоматически перезагружать сервер при изменении кода - В production используйте переменные окружения для конфигурации - SECRET_KEY должен быть уникальным для каждого развертывания """ app.run(debug=True)