""" Сервисы для работы с системой тестирования Основная бизнес-логика для получения вопросов, сохранения ответов и работы с изображениями """ import json import io from typing import List, Optional, Dict, Any, Tuple from sqlalchemy.orm import Session from sqlalchemy import and_ from database.models import ( User, Test, TestCategory, Question, AnswerOption, TestResult, UserAnswer, Image ) import logging logger = logging.getLogger(__name__) class ImageService: """Сервис для работы с изображениями в базе данных""" @staticmethod def save_image_to_db( db: Session, filename: str, content_type: str, image_data: bytes, alt_text: str = None ) -> Image: """ Сохранение изображения в базу данных Args: db: Сессия базы данных filename: Имя файла content_type: MIME тип (image/jpeg, image/png и т.д.) image_data: Бинарные данные изображения alt_text: Альтернативный текст Returns: Image: Созданная запись изображения """ image = Image( filename=filename, content_type=content_type, file_size=len(image_data), image_data=image_data, alt_text=alt_text ) db.add(image) db.commit() db.refresh(image) return image @staticmethod def get_image_data(db: Session, image_id: int) -> Optional[Tuple[bytes, str, str]]: """ Получение данных изображения из БД Args: db: Сессия базы данных image_id: ID изображения Returns: Tuple[bytes, str, str]: (image_data, content_type, filename) или None """ image = db.query(Image).filter(Image.id == image_id).first() if image: return image.image_data, image.content_type, image.filename return None @staticmethod def get_image_by_filename(db: Session, filename: str) -> Optional[Image]: """Получение изображения по имени файла""" return db.query(Image).filter(Image.filename == filename).first() class TestService: """Сервис для работы с тестами""" @staticmethod def get_available_tests(db: Session) -> List[Test]: """Получение всех активных тестов""" return db.query(Test).filter(Test.is_active == True).all() @staticmethod def get_test_by_title(db: Session, title: str) -> Optional[Test]: """Получение теста по названию (например, '📘 Тест Климова')""" return db.query(Test).filter( and_(Test.title == title, Test.is_active == True) ).first() @staticmethod def get_test_questions(db: Session, test_id: int) -> List[Question]: """ Получение всех вопросов теста в правильном порядке Args: db: Сессия базы данных test_id: ID теста Returns: List[Question]: Список вопросов с загруженными связями """ return db.query(Question).filter( and_(Question.test_id == test_id, Question.is_active == True) ).order_by(Question.order_index).all() @staticmethod def start_test(db: Session, user_id: int, test_id: int) -> TestResult: """ Начало прохождения теста пользователем Args: db: Сессия базы данных user_id: ID пользователя test_id: ID теста Returns: TestResult: Созданная запись результата теста """ # Проверяем, есть ли незавершенный тест existing_result = db.query(TestResult).filter( and_( TestResult.user_id == user_id, TestResult.test_id == test_id, TestResult.is_completed == False ) ).first() if existing_result: return existing_result # Создаем новый результат теста test_result = TestResult( user_id=user_id, test_id=test_id, is_completed=False ) db.add(test_result) db.commit() db.refresh(test_result) return test_result @staticmethod def save_answer( db: Session, test_result_id: int, question_id: int, selected_option_id: int ) -> UserAnswer: """ Сохранение ответа пользователя на вопрос Args: db: Сессия базы данных test_result_id: ID результата теста question_id: ID вопроса selected_option_id: ID выбранного варианта ответа Returns: UserAnswer: Созданная запись ответа """ # Проверяем, есть ли уже ответ на этот вопрос existing_answer = db.query(UserAnswer).filter( and_( UserAnswer.test_result_id == test_result_id, UserAnswer.question_id == question_id ) ).first() if existing_answer: # Обновляем существующий ответ existing_answer.selected_option_id = selected_option_id db.commit() return existing_answer # Создаем новый ответ user_answer = UserAnswer( test_result_id=test_result_id, question_id=question_id, selected_option_id=selected_option_id ) db.add(user_answer) db.commit() db.refresh(user_answer) return user_answer @staticmethod def complete_test(db: Session, test_result_id: int) -> TestResult: """ Завершение теста и подсчет результатов Args: db: Сессия базы данных test_result_id: ID результата теста Returns: TestResult: Обновленная запись результата с подсчитанными данными """ from datetime import datetime test_result = db.query(TestResult).filter( TestResult.id == test_result_id ).first() if not test_result: raise ValueError(f"TestResult с ID {test_result_id} не найден") # Получаем все ответы пользователя для этого теста answers = db.query(UserAnswer).filter( UserAnswer.test_result_id == test_result_id ).all() # Подсчитываем результаты по категориям category_scores = {} for answer in answers: option = db.query(AnswerOption).filter( AnswerOption.id == answer.selected_option_id ).first() if option and option.category: category = option.category category_scores[category] = category_scores.get(category, 0) + 1 # Сохраняем результаты в JSON формате test_result.result_data = json.dumps(category_scores, ensure_ascii=False) test_result.completed_at = datetime.now() test_result.is_completed = True db.commit() db.refresh(test_result) return test_result @staticmethod def get_test_results(db: Session, test_result_id: int) -> Dict[str, Any]: """ Получение и форматирование результатов теста Args: db: Сессия базы данных test_result_id: ID результата теста Returns: Dict: Словарь с результатами и процентами """ test_result = db.query(TestResult).filter( TestResult.id == test_result_id ).first() if not test_result or not test_result.result_data: return {} # Парсим результаты category_scores = json.loads(test_result.result_data) # Получаем максимальные возможные баллы для каждой категории questions = TestService.get_test_questions(db, test_result.test_id) max_scores = {} for question in questions: for option in question.answer_options: if option.category: max_scores[option.category] = max_scores.get(option.category, 0) + 1 # Вычисляем проценты results = {} for category, score in category_scores.items(): max_score = max_scores.get(category, 1) percentage = (score / max_score) * 100 results[category] = { 'score': score, 'max_score': max_score, 'percentage': round(percentage, 1) } return results @staticmethod def get_user_test_history(db: Session, user_id: int) -> List[TestResult]: """Получение истории тестов пользователя""" return db.query(TestResult).filter( and_(TestResult.user_id == user_id, TestResult.is_completed == True) ).order_by(TestResult.completed_at.desc()).all() class QuestionService: """Сервис для работы с вопросами""" @staticmethod def get_question_with_options(db: Session, question_id: int) -> Optional[Question]: """ Получение вопроса со всеми вариантами ответов и изображением Args: db: Сессия базы данных question_id: ID вопроса Returns: Question: Вопрос с загруженными связями или None """ return db.query(Question).filter(Question.id == question_id).first() @staticmethod def format_question_for_bot(db: Session, question: Question) -> Dict[str, Any]: """ Форматирование вопроса для отправки в бот Args: db: Сессия базы данных question: Объект вопроса Returns: Dict: Сформатированные данные вопроса """ # Получаем варианты ответов options = db.query(AnswerOption).filter( AnswerOption.question_id == question.id ).order_by(AnswerOption.order_index).all() # Получаем данные изображения, если есть image_data = None if question.image_id: image_info = ImageService.get_image_data(db, question.image_id) if image_info: image_data = { 'data': image_info[0], 'content_type': image_info[1], 'filename': image_info[2] } return { 'id': question.id, 'text': question.question_text, 'order_index': question.order_index, 'image': image_data, 'options': [ { 'id': option.id, 'text': option.option_text, 'category': option.category, 'order_index': option.order_index } for option in options ] }