- Add QuizQuestion and QuizOption domain entities with QuestionType enum - Add IQuizQuestionRepository and IQuizOptionRepository interfaces - Add QuizQuestionRepository and QuizOptionRepository EF Core implementations - Add QuizQuestionService with CRUD, quiz submission, and statistics - Add QuizQuestionsController with comprehensive REST API endpoints - Add QuizQuestionDto and related DTOs for API communication - Add EF Core migration (20260612050652_AddQuizQuestionTables) for QuizQuestion and QuizOption - Add seed data with sample quiz questions for Greetings, Numbers, Grammar lessons - Update AppDbContext with DbSets and entity configurations - Update Program.cs with migration retry logic for Docker - Update docker-compose.yml with SeedDatabase configuration - Add unit tests for QuizQuestionService Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
420 lines
18 KiB
C#
420 lines
18 KiB
C#
using GermanApp.Application.DTOs;
|
|
using GermanApp.Domain.Entities;
|
|
using GermanApp.Domain.Interfaces;
|
|
|
|
namespace GermanApp.Application.Services;
|
|
|
|
/// <summary>
|
|
/// Application service for managing quiz questions.
|
|
/// This is part of the Application layer.
|
|
/// </summary>
|
|
public class QuizQuestionService
|
|
{
|
|
private readonly IQuizQuestionRepository _quizQuestionRepository;
|
|
private readonly IQuizOptionRepository _quizOptionRepository;
|
|
private readonly ILessonRepository _lessonRepository;
|
|
|
|
public QuizQuestionService(
|
|
IQuizQuestionRepository quizQuestionRepository,
|
|
IQuizOptionRepository quizOptionRepository,
|
|
ILessonRepository lessonRepository)
|
|
{
|
|
_quizQuestionRepository = quizQuestionRepository;
|
|
_quizOptionRepository = quizOptionRepository;
|
|
_lessonRepository = lessonRepository;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets all quiz questions.
|
|
/// </summary>
|
|
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetAllQuizQuestionsAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetAllOrderedAsync(cancellationToken);
|
|
return questions.Select(q => q.ToDto()).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a quiz question by its ID.
|
|
/// </summary>
|
|
public virtual async Task<QuizQuestionDto?> GetQuizQuestionByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var question = await _quizQuestionRepository.GetByIdAsync(id, cancellationToken);
|
|
return question?.ToDto();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets quiz questions by lesson ID.
|
|
/// </summary>
|
|
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetByLessonAsync(lessonId, cancellationToken);
|
|
return questions.Select(q => q.ToDto()).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets active quiz questions by lesson ID.
|
|
/// </summary>
|
|
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetActiveQuizQuestionsByLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
|
|
return questions.Select(q => q.ToDto()).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a random set of quiz questions for a lesson.
|
|
/// </summary>
|
|
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetRandomQuestionsForLessonAsync(lessonId, count, cancellationToken);
|
|
return questions.Select(q => q.ToDto()).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets quiz questions by difficulty level.
|
|
/// </summary>
|
|
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetQuizQuestionsByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetByDifficultyAsync(difficulty, cancellationToken);
|
|
return questions.Select(q => q.ToDto()).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets quiz questions by type.
|
|
/// </summary>
|
|
public virtual async Task<IReadOnlyList<QuizQuestionDto>> GetQuizQuestionsByTypeAsync(QuestionType type, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetByTypeAsync(type, cancellationToken);
|
|
return questions.Select(q => q.ToDto()).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new quiz question with its options.
|
|
/// </summary>
|
|
public virtual async Task<QuizQuestionDto> CreateQuizQuestionAsync(CreateQuizQuestionDto dto, CancellationToken cancellationToken = default)
|
|
{
|
|
// Validate that the lesson exists
|
|
var lesson = await _lessonRepository.GetByIdAsync(dto.LessonId, cancellationToken);
|
|
if (lesson == null)
|
|
throw new ArgumentException("Lesson does not exist");
|
|
|
|
// Validate question order uniqueness within the lesson
|
|
await ValidateQuestionOrderUniquenessAsync(0, dto.LessonId, dto.Order, cancellationToken);
|
|
|
|
// Create the question
|
|
var question = dto.ToEntity();
|
|
var createdQuestion = await _quizQuestionRepository.AddAsync(question, cancellationToken);
|
|
|
|
// Create options if provided
|
|
if (dto.Options != null && dto.Options.Any())
|
|
{
|
|
foreach (var optionDto in dto.Options)
|
|
{
|
|
var option = optionDto.ToEntity(createdQuestion.Id);
|
|
await _quizOptionRepository.AddAsync(option, cancellationToken);
|
|
}
|
|
}
|
|
|
|
// Reload the question with options
|
|
var questionWithOptions = await _quizQuestionRepository.GetByIdAsync(createdQuestion.Id, cancellationToken);
|
|
return questionWithOptions?.ToDto() ?? createdQuestion.ToDto();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates an existing quiz question and its options.
|
|
/// </summary>
|
|
public virtual async Task<QuizQuestionDto?> UpdateQuizQuestionAsync(int id, UpdateQuizQuestionDto dto, CancellationToken cancellationToken = default)
|
|
{
|
|
var question = await _quizQuestionRepository.GetByIdAsync(id, cancellationToken);
|
|
if (question == null)
|
|
return null;
|
|
|
|
// Validate that the lesson exists
|
|
var lesson = await _lessonRepository.GetByIdAsync(dto.LessonId, cancellationToken);
|
|
if (lesson == null)
|
|
throw new ArgumentException("Lesson does not exist");
|
|
|
|
// Validate question order uniqueness within the lesson
|
|
await ValidateQuestionOrderUniquenessAsync(id, dto.LessonId, dto.Order, cancellationToken);
|
|
|
|
// Update the question
|
|
question.UpdateFromDto(dto);
|
|
await _quizQuestionRepository.UpdateAsync(question, cancellationToken);
|
|
|
|
// Update options if provided
|
|
if (dto.Options != null)
|
|
{
|
|
// Delete existing options
|
|
await _quizOptionRepository.DeleteByQuestionAsync(id, cancellationToken);
|
|
|
|
// Add new options
|
|
foreach (var optionDto in dto.Options)
|
|
{
|
|
var option = new CreateQuizOptionDto(optionDto.Text, optionDto.IsCorrect, optionDto.Order).ToEntity(id);
|
|
await _quizOptionRepository.AddAsync(option, cancellationToken);
|
|
}
|
|
}
|
|
|
|
// Reload the question with updated options
|
|
var updatedQuestion = await _quizQuestionRepository.GetByIdAsync(id, cancellationToken);
|
|
return updatedQuestion?.ToDto();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that the question order is unique within its lesson.
|
|
/// </summary>
|
|
private async Task ValidateQuestionOrderUniquenessAsync(int excludeQuestionId, int lessonId, int order, CancellationToken cancellationToken)
|
|
{
|
|
var exists = await _quizQuestionRepository.OrderExistsInLessonAsync(lessonId, order, excludeQuestionId, cancellationToken);
|
|
if (exists)
|
|
throw new ArgumentException("A quiz question with this order already exists in the specified lesson");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a quiz question and its options by its ID.
|
|
/// </summary>
|
|
public virtual async Task<bool> DeleteQuizQuestionAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var question = await _quizQuestionRepository.GetByIdAsync(id, cancellationToken);
|
|
if (question == null)
|
|
return false;
|
|
|
|
// Delete all options for this question
|
|
await _quizOptionRepository.DeleteByQuestionAsync(id, cancellationToken);
|
|
|
|
// Delete the question
|
|
await _quizQuestionRepository.DeleteAsync(question, cancellationToken);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets quiz options for a specific question.
|
|
/// </summary>
|
|
public virtual async Task<IReadOnlyList<QuizOptionDto>> GetQuizOptionsByQuestionAsync(int questionId, CancellationToken cancellationToken = default)
|
|
{
|
|
var options = await _quizOptionRepository.GetByQuestionAsync(questionId, cancellationToken);
|
|
return options.Select(o => o.ToDto()).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the correct option for a question.
|
|
/// </summary>
|
|
public virtual async Task<QuizOptionDto?> GetCorrectOptionAsync(int questionId, CancellationToken cancellationToken = default)
|
|
{
|
|
var option = await _quizOptionRepository.GetCorrectOptionAsync(questionId, cancellationToken);
|
|
return option?.ToDto();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets all correct options for a question (for MultipleSelect type).
|
|
/// </summary>
|
|
public virtual async Task<IReadOnlyList<QuizOptionDto>> GetCorrectOptionsAsync(int questionId, CancellationToken cancellationToken = default)
|
|
{
|
|
var options = await _quizOptionRepository.GetCorrectOptionsAsync(questionId, cancellationToken);
|
|
return options.Select(o => o.ToDto()).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a quiz option for a question.
|
|
/// </summary>
|
|
public virtual async Task<QuizOptionDto> CreateQuizOptionAsync(int questionId, CreateQuizOptionDto dto, CancellationToken cancellationToken = default)
|
|
{
|
|
// Validate that the question exists
|
|
var question = await _quizQuestionRepository.GetByIdAsync(questionId, cancellationToken);
|
|
if (question == null)
|
|
throw new ArgumentException("Quiz question does not exist");
|
|
|
|
// Validate option order uniqueness within the question
|
|
await ValidateOptionOrderUniquenessAsync(questionId, dto.Order, null, cancellationToken);
|
|
|
|
var option = dto.ToEntity(questionId);
|
|
var createdOption = await _quizOptionRepository.AddAsync(option, cancellationToken);
|
|
return createdOption.ToDto();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates an existing quiz option.
|
|
/// </summary>
|
|
public virtual async Task<QuizOptionDto?> UpdateQuizOptionAsync(int id, UpdateQuizOptionDto dto, CancellationToken cancellationToken = default)
|
|
{
|
|
var option = await _quizOptionRepository.GetByIdAsync(id, cancellationToken);
|
|
if (option == null)
|
|
return null;
|
|
|
|
// Validate that the question exists
|
|
var question = await _quizQuestionRepository.GetByIdAsync(dto.Id, cancellationToken);
|
|
if (question == null)
|
|
throw new ArgumentException("Quiz question does not exist");
|
|
|
|
// Validate option order uniqueness within the question
|
|
await ValidateOptionOrderUniquenessAsync(option.QuizQuestionId, dto.Order, id, cancellationToken);
|
|
|
|
option.UpdateFromDto(dto);
|
|
await _quizOptionRepository.UpdateAsync(option, cancellationToken);
|
|
return option.ToDto();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that the option order is unique within its question.
|
|
/// </summary>
|
|
private async Task ValidateOptionOrderUniquenessAsync(int questionId, int order, int? excludeOptionId, CancellationToken cancellationToken)
|
|
{
|
|
var exists = await _quizOptionRepository.OrderExistsForQuestionAsync(questionId, order, excludeOptionId, cancellationToken);
|
|
if (exists)
|
|
throw new ArgumentException("An option with this order already exists for the specified question");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a quiz option by its ID.
|
|
/// </summary>
|
|
public virtual async Task<bool> DeleteQuizOptionAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var option = await _quizOptionRepository.GetByIdAsync(id, cancellationToken);
|
|
if (option == null)
|
|
return false;
|
|
|
|
await _quizOptionRepository.DeleteAsync(option, cancellationToken);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Submits quiz answers and returns the result.
|
|
/// </summary>
|
|
public virtual async Task<QuizResultDto> SubmitQuizAnswersAsync(int userId, SubmitQuizAnswersDto dto, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetByLessonAsync(dto.LessonId, cancellationToken);
|
|
var questionDictionary = questions.ToDictionary(q => q.Id);
|
|
|
|
var totalQuestions = questions.Count;
|
|
var correctAnswers = 0;
|
|
var questionResults = new List<QuizQuestionResultDto>();
|
|
|
|
// Get user's answers
|
|
var userAnswers = dto.Answers.ToDictionary(a => a.QuizQuestionId);
|
|
|
|
foreach (var question in questions)
|
|
{
|
|
var isCorrect = false;
|
|
var userAnswerText = string.Empty;
|
|
var correctAnswer = question.CorrectAnswer ?? string.Empty;
|
|
|
|
if (userAnswers.TryGetValue(question.Id, out var userAnswer))
|
|
{
|
|
userAnswerText = userAnswer?.AnswerText ?? string.Empty;
|
|
|
|
switch (question.Type)
|
|
{
|
|
case QuestionType.MultipleChoice:
|
|
// Check if the selected option is correct
|
|
if (userAnswer.SelectedOptionIds != null && userAnswer.SelectedOptionIds.Length == 1)
|
|
{
|
|
var selectedOptionId = userAnswer.SelectedOptionIds[0];
|
|
var isOptionCorrect = await _quizOptionRepository.GetByIdAsync(selectedOptionId, cancellationToken);
|
|
isCorrect = isOptionCorrect?.IsCorrect == true;
|
|
}
|
|
break;
|
|
|
|
case QuestionType.MultipleSelect:
|
|
// Check if all selected options are correct and no incorrect ones are selected
|
|
if (userAnswer.SelectedOptionIds != null && userAnswer.SelectedOptionIds.Length > 0)
|
|
{
|
|
var correctOptions = await _quizOptionRepository.GetCorrectOptionsAsync(question.Id, cancellationToken);
|
|
var selectedOptions = await _quizOptionRepository.GetByQuestionAsync(question.Id, cancellationToken);
|
|
|
|
var correctIds = new HashSet<int>(correctOptions.Select(o => o.Id));
|
|
var selectedIds = new HashSet<int>(userAnswer.SelectedOptionIds);
|
|
|
|
// All selected must be correct, and all correct must be selected
|
|
isCorrect = selectedIds.SetEquals(correctIds);
|
|
}
|
|
break;
|
|
|
|
case QuestionType.TrueFalse:
|
|
// Check if the answer matches
|
|
if (userAnswer.AnswerText != null)
|
|
{
|
|
isCorrect = userAnswer.AnswerText.Equals(correctAnswer, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
break;
|
|
|
|
case QuestionType.FillInTheBlank:
|
|
case QuestionType.ShortAnswer:
|
|
// Case-insensitive comparison with trimming
|
|
if (!string.IsNullOrEmpty(userAnswerText) && !string.IsNullOrEmpty(correctAnswer))
|
|
{
|
|
isCorrect = userAnswerText.Equals(correctAnswer, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
break;
|
|
|
|
case QuestionType.Matching:
|
|
// For matching, we would need more complex logic
|
|
// This is a simplified implementation
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (isCorrect)
|
|
correctAnswers++;
|
|
|
|
questionResults.Add(new QuizQuestionResultDto(
|
|
question.Id,
|
|
question.QuestionText,
|
|
question.Type,
|
|
isCorrect,
|
|
correctAnswer,
|
|
userAnswerText));
|
|
}
|
|
|
|
var scorePercentage = totalQuestions > 0 ? (double)correctAnswers / totalQuestions * 100 : 0;
|
|
var passed = scorePercentage >= 80; // Passing score is 80%
|
|
|
|
return new QuizResultDto(
|
|
dto.LessonId,
|
|
totalQuestions,
|
|
correctAnswers,
|
|
scorePercentage,
|
|
passed,
|
|
TimeSpan.Zero, // Would be calculated based on actual time taken
|
|
questionResults);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the number of quiz questions for a lesson.
|
|
/// </summary>
|
|
public virtual async Task<int> GetQuizQuestionCountForLessonAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
|
|
return questions.Count;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the difficulty distribution for a lesson.
|
|
/// </summary>
|
|
public virtual async Task<IDictionary<int, int>> GetDifficultyDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
|
|
var distribution = new Dictionary<int, int>();
|
|
|
|
for (int i = 1; i <= 5; i++)
|
|
{
|
|
distribution[i] = questions.Count(q => q.Difficulty == i);
|
|
}
|
|
|
|
return distribution;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the type distribution for a lesson.
|
|
/// </summary>
|
|
public virtual async Task<IDictionary<string, int>> GetTypeDistributionAsync(int lessonId, CancellationToken cancellationToken = default)
|
|
{
|
|
var questions = await _quizQuestionRepository.GetActiveByLessonAsync(lessonId, cancellationToken);
|
|
var distribution = new Dictionary<string, int>();
|
|
|
|
foreach (var type in Enum.GetValues(typeof(QuestionType)).Cast<QuestionType>())
|
|
{
|
|
distribution[type.ToString()] = questions.Count(q => q.Type == type);
|
|
}
|
|
|
|
return distribution;
|
|
}
|
|
}
|