358 lines
12 KiB
Python
358 lines
12 KiB
Python
"""
|
||
Сервисы для работы с системой тестирования
|
||
Основная бизнес-логика для получения вопросов, сохранения ответов и работы с изображениями
|
||
"""
|
||
|
||
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
|
||
]
|
||
} |