diff --git a/GermanApp/Application/DTOs/QuizDto.cs b/GermanApp/Application/DTOs/QuizDto.cs new file mode 100644 index 0000000..dcccaae --- /dev/null +++ b/GermanApp/Application/DTOs/QuizDto.cs @@ -0,0 +1,151 @@ +using GermanApp.Domain.Entities; + +namespace GermanApp.Application.DTOs; + +/// +/// Data Transfer Object for Quiz - used for API responses. +/// This is a read-only representation of a Quiz entity. +/// +public record QuizDto( + int Id, + int LessonId, + string LessonTitle, + string Title, + string? Description, + int PassingScore, + int TimeLimitMinutes, + bool ShuffleQuestions, + bool IsActive, + int QuestionCount, + int TotalPoints, + DateTime CreatedAt, + DateTime? UpdatedAt); + +/// +/// Data Transfer Object for creating a new Quiz. +/// +public record CreateQuizDto( + int LessonId, + string Title, + string? Description, + int PassingScore, + int TimeLimitMinutes, + bool ShuffleQuestions); + +/// +/// Data Transfer Object for updating an existing Quiz. +/// +public record UpdateQuizDto( + int LessonId, + string Title, + string? Description, + int PassingScore, + int TimeLimitMinutes, + bool ShuffleQuestions, + bool IsActive); + +/// +/// Data Transfer Object for quiz with its questions. +/// +public record QuizWithQuestionsDto( + int Id, + int LessonId, + string LessonTitle, + string Title, + string? Description, + int PassingScore, + int TimeLimitMinutes, + bool ShuffleQuestions, + bool IsActive, + DateTime CreatedAt, + DateTime? UpdatedAt, + IReadOnlyList Questions); + +/// +/// Data Transfer Object for quiz list item (summary). +/// +public record QuizListItemDto( + int Id, + int LessonId, + string LessonTitle, + string Title, + bool IsActive, + int QuestionCount, + DateTime CreatedAt); + +/// +/// Data Transfer Object for submitting quiz answers. +/// +public record SubmitQuizDto( + int QuizId, + IReadOnlyList Answers); + +/// +/// Extension methods for mapping between Quiz entity and DTOs. +/// +public static class QuizDtoExtensions +{ + public static QuizDto ToDto(this Quiz quiz) => new( + quiz.Id, + quiz.LessonId, + quiz.Lesson?.Title ?? "Unknown", + quiz.Title, + quiz.Description, + quiz.PassingScore, + quiz.TimeLimitMinutes, + quiz.ShuffleQuestions, + quiz.IsActive, + quiz.Questions?.Count ?? 0, + quiz.Questions?.Sum(q => q.Points) ?? 0, + quiz.CreatedAt, + quiz.UpdatedAt); + + public static QuizListItemDto ToListItemDto(this Quiz quiz) => new( + quiz.Id, + quiz.LessonId, + quiz.Lesson?.Title ?? "Unknown", + quiz.Title, + quiz.IsActive, + quiz.Questions?.Count ?? 0, + quiz.CreatedAt); + + public static QuizWithQuestionsDto ToQuizWithQuestionsDto(this Quiz quiz) => new( + quiz.Id, + quiz.LessonId, + quiz.Lesson?.Title ?? "Unknown", + quiz.Title, + quiz.Description, + quiz.PassingScore, + quiz.TimeLimitMinutes, + quiz.ShuffleQuestions, + quiz.IsActive, + quiz.CreatedAt, + quiz.UpdatedAt, + quiz.Questions?.OrderBy(q => q.Order).Select(q => q.ToDto()).ToList() ?? new List()); + + public static Quiz ToEntity(this CreateQuizDto dto) => + Quiz.Create( + dto.LessonId, + dto.Title, + dto.Description, + dto.PassingScore, + dto.TimeLimitMinutes); + + public static void UpdateFromDto(this Quiz quiz, UpdateQuizDto dto) + { + quiz.UpdateLesson(dto.LessonId); + quiz.UpdateTitle(dto.Title); + quiz.UpdateDescription(dto.Description); + quiz.UpdatePassingScore(dto.PassingScore); + quiz.UpdateTimeLimit(dto.TimeLimitMinutes); + quiz.UpdateShuffleQuestions(dto.ShuffleQuestions); + + if (dto.IsActive != quiz.IsActive) + { + if (dto.IsActive) + quiz.Activate(); + else + quiz.Deactivate(); + } + } +} diff --git a/GermanApp/Application/DTOs/QuizQuestionDto.cs b/GermanApp/Application/DTOs/QuizQuestionDto.cs index ccfc6db..e23d12f 100644 --- a/GermanApp/Application/DTOs/QuizQuestionDto.cs +++ b/GermanApp/Application/DTOs/QuizQuestionDto.cs @@ -8,11 +8,14 @@ namespace GermanApp.Application.DTOs; /// public record QuizQuestionDto( int Id, + int QuizId, int LessonId, + string QuizTitle, string LessonTitle, string QuestionText, QuestionType Type, string? CorrectAnswer, + int Points, int Difficulty, int Order, bool IsActive, @@ -24,10 +27,11 @@ public record QuizQuestionDto( /// Data Transfer Object for creating a new QuizQuestion. /// public record CreateQuizQuestionDto( - int LessonId, + int QuizId, string QuestionText, QuestionType Type, string? CorrectAnswer, + int Points, int Difficulty, int Order, IReadOnlyList? Options); @@ -36,10 +40,11 @@ public record CreateQuizQuestionDto( /// Data Transfer Object for updating an existing QuizQuestion. /// public record UpdateQuizQuestionDto( - int LessonId, + int QuizId, string QuestionText, QuestionType Type, string? CorrectAnswer, + int Points, int Difficulty, int Order, bool IsActive, @@ -77,9 +82,11 @@ public record UpdateQuizOptionDto( /// public record QuizQuestionWithAnswerDto( int QuizQuestionId, + int QuizId, int LessonId, string QuestionText, QuestionType Type, + int Points, IReadOnlyList Options, string? UserAnswer, bool IsCorrect); @@ -88,7 +95,7 @@ public record QuizQuestionWithAnswerDto( /// Data Transfer Object for submitting quiz answers. /// public record SubmitQuizAnswersDto( - int LessonId, + int QuizId, IReadOnlyList Answers); /// @@ -103,9 +110,12 @@ public record QuizAnswerDto( /// Data Transfer Object for quiz result. /// public record QuizResultDto( + int QuizId, int LessonId, + string QuizTitle, int TotalQuestions, - int CorrectAnswers, + int TotalPoints, + int ScorePoints, double ScorePercentage, bool Passed, TimeSpan TimeTaken, @@ -118,6 +128,7 @@ public record QuizQuestionResultDto( int QuizQuestionId, string QuestionText, QuestionType Type, + int Points, bool IsCorrect, string? CorrectAnswer, string? UserAnswer); @@ -129,11 +140,14 @@ public static class QuizQuestionDtoExtensions { public static QuizQuestionDto ToDto(this QuizQuestion quizQuestion) => new( quizQuestion.Id, - quizQuestion.LessonId, - quizQuestion.Lesson?.Title ?? "Unknown", + quizQuestion.QuizId, + quizQuestion.Quiz?.LessonId ?? 0, + quizQuestion.Quiz?.Title ?? "Unknown", + quizQuestion.Quiz?.Lesson?.Title ?? "Unknown", quizQuestion.QuestionText, quizQuestion.Type, quizQuestion.CorrectAnswer, + quizQuestion.Points, quizQuestion.Difficulty, quizQuestion.Order, quizQuestion.IsActive, @@ -143,19 +157,21 @@ public static class QuizQuestionDtoExtensions public static QuizQuestion ToEntity(this CreateQuizQuestionDto dto) => QuizQuestion.Create( - dto.LessonId, + dto.QuizId, dto.QuestionText, dto.Type, dto.CorrectAnswer ?? string.Empty, + dto.Points, dto.Difficulty, dto.Order); public static void UpdateFromDto(this QuizQuestion quizQuestion, UpdateQuizQuestionDto dto) { - quizQuestion.UpdateLesson(dto.LessonId); + quizQuestion.UpdateQuiz(dto.QuizId); quizQuestion.UpdateQuestionText(dto.QuestionText); quizQuestion.UpdateType(dto.Type); quizQuestion.UpdateCorrectAnswer(dto.CorrectAnswer ?? string.Empty); + quizQuestion.UpdatePoints(dto.Points); quizQuestion.UpdateDifficulty(dto.Difficulty); quizQuestion.UpdateOrder(dto.Order); diff --git a/GermanApp/Application/Services/QuizQuestionService.cs b/GermanApp/Application/Services/QuizQuestionService.cs index cefe363..afc42d3 100644 --- a/GermanApp/Application/Services/QuizQuestionService.cs +++ b/GermanApp/Application/Services/QuizQuestionService.cs @@ -1,5 +1,6 @@ using GermanApp.Application.DTOs; using GermanApp.Domain.Entities; +using GermanApp.Domain.Exceptions; using GermanApp.Domain.Interfaces; namespace GermanApp.Application.Services; @@ -12,15 +13,18 @@ public class QuizQuestionService { private readonly IQuizQuestionRepository _quizQuestionRepository; private readonly IQuizOptionRepository _quizOptionRepository; + private readonly IQuizRepository _quizRepository; private readonly ILessonRepository _lessonRepository; public QuizQuestionService( IQuizQuestionRepository quizQuestionRepository, IQuizOptionRepository quizOptionRepository, + IQuizRepository quizRepository, ILessonRepository lessonRepository) { _quizQuestionRepository = quizQuestionRepository; _quizOptionRepository = quizOptionRepository; + _quizRepository = quizRepository; _lessonRepository = lessonRepository; } @@ -92,13 +96,13 @@ public class QuizQuestionService /// public virtual async Task 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 that the quiz exists + var quiz = await _quizRepository.GetByIdAsync(dto.QuizId, cancellationToken); + if (quiz == null) + throw new NotFoundException("Quiz does not exist"); - // Validate question order uniqueness within the lesson - await ValidateQuestionOrderUniquenessAsync(0, dto.LessonId, dto.Order, cancellationToken); + // Validate question order uniqueness within the quiz + await ValidateQuestionOrderUniquenessAsync(0, dto.QuizId, dto.Order, cancellationToken); // Create the question var question = dto.ToEntity(); @@ -128,13 +132,13 @@ public class QuizQuestionService 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 that the quiz exists + var quiz = await _quizRepository.GetByIdAsync(dto.QuizId, cancellationToken); + if (quiz == null) + throw new NotFoundException("Quiz does not exist"); - // Validate question order uniqueness within the lesson - await ValidateQuestionOrderUniquenessAsync(id, dto.LessonId, dto.Order, cancellationToken); + // Validate question order uniqueness within the quiz + await ValidateQuestionOrderUniquenessAsync(id, dto.QuizId, dto.Order, cancellationToken); // Update the question question.UpdateFromDto(dto); @@ -160,13 +164,13 @@ public class QuizQuestionService } /// - /// Validates that the question order is unique within its lesson. + /// Validates that the question order is unique within its quiz. /// - private async Task ValidateQuestionOrderUniquenessAsync(int excludeQuestionId, int lessonId, int order, CancellationToken cancellationToken) + private async Task ValidateQuestionOrderUniquenessAsync(int excludeQuestionId, int quizId, int order, CancellationToken cancellationToken) { - var exists = await _quizQuestionRepository.OrderExistsInLessonAsync(lessonId, order, excludeQuestionId, cancellationToken); + var exists = await _quizQuestionRepository.OrderExistsInQuizAsync(quizId, order, excludeQuestionId, cancellationToken); if (exists) - throw new ArgumentException("A quiz question with this order already exists in the specified lesson"); + throw new ValidationException("A quiz question with this order already exists in the specified quiz"); } /// @@ -277,18 +281,21 @@ public class QuizQuestionService } /// - /// Submits quiz answers and returns the result. + /// Submits quiz answers for a specific quiz and returns the result. /// - public virtual async Task SubmitQuizAnswersAsync(int userId, SubmitQuizAnswersDto dto, CancellationToken cancellationToken = default) + public virtual async Task SubmitQuizAnswersForQuizAsync(int userId, SubmitQuizDto dto, CancellationToken cancellationToken = default) { - var questions = await _quizQuestionRepository.GetByLessonAsync(dto.LessonId, cancellationToken); - var questionDictionary = questions.ToDictionary(q => q.Id); + var quiz = await _quizRepository.GetWithQuestionsAsync(dto.QuizId, cancellationToken); + if (quiz == null) + throw new NotFoundException("Quiz does not exist"); + var questions = quiz.Questions?.Where(q => q.IsActive).OrderBy(q => q.Order).ToList() ?? new List(); var totalQuestions = questions.Count; - var correctAnswers = 0; + var totalPoints = questions.Sum(q => q.Points); + var scorePoints = 0; var questionResults = new List(); - // Get user's answers + // Get user's answers (using QuizAnswerDto which has the same fields as SubmitAnswerDto) var userAnswers = dto.Answers.ToDictionary(a => a.QuizQuestionId); foreach (var question in questions) @@ -299,7 +306,7 @@ public class QuizQuestionService if (userAnswers.TryGetValue(question.Id, out var userAnswer)) { - userAnswerText = userAnswer?.AnswerText ?? string.Empty; + userAnswerText = userAnswer.AnswerText ?? string.Empty; switch (question.Type) { @@ -341,7 +348,7 @@ public class QuizQuestionService // Case-insensitive comparison with trimming if (!string.IsNullOrEmpty(userAnswerText) && !string.IsNullOrEmpty(correctAnswer)) { - isCorrect = userAnswerText.Equals(correctAnswer, StringComparison.OrdinalIgnoreCase); + isCorrect = userAnswerText.Trim().Equals(correctAnswer.Trim(), StringComparison.OrdinalIgnoreCase); } break; @@ -353,30 +360,47 @@ public class QuizQuestionService } if (isCorrect) - correctAnswers++; + scorePoints += question.Points; questionResults.Add(new QuizQuestionResultDto( question.Id, question.QuestionText, question.Type, + question.Points, isCorrect, correctAnswer, userAnswerText)); } - var scorePercentage = totalQuestions > 0 ? (double)correctAnswers / totalQuestions * 100 : 0; - var passed = scorePercentage >= 80; // Passing score is 80% + var scorePercentage = totalPoints > 0 ? (double)scorePoints / totalPoints * 100 : 0; + var passed = quiz.IsPassed(scorePercentage); return new QuizResultDto( - dto.LessonId, + quiz.Id, + quiz.LessonId, + quiz.Title, totalQuestions, - correctAnswers, + totalPoints, + scorePoints, scorePercentage, passed, TimeSpan.Zero, // Would be calculated based on actual time taken questionResults); } + /// + /// Submits quiz answers and returns the result (legacy - converts SubmitQuizAnswersDto to SubmitQuizDto). + /// + public virtual async Task SubmitQuizAnswersAsync(int userId, SubmitQuizAnswersDto dto, CancellationToken cancellationToken = default) + { + // Convert legacy DTO to new format + // SubmitQuizAnswersDto has QuizId and Answers (IReadOnlyList) + // SubmitQuizDto has QuizId and Answers (IReadOnlyList) + // They're the same, so just create a new SubmitQuizDto + var newDto = new SubmitQuizDto(dto.QuizId, dto.Answers); + return await SubmitQuizAnswersForQuizAsync(userId, newDto, cancellationToken); + } + /// /// Gets the number of quiz questions for a lesson. /// @@ -417,4 +441,43 @@ public class QuizQuestionService return distribution; } + + // ============================================ + // Quiz-based methods (QuizId) + // ============================================ + + /// + /// Gets quiz questions by quiz ID. + /// + public virtual async Task> GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default) + { + var questions = await _quizQuestionRepository.GetByQuizAsync(quizId, cancellationToken); + return questions.Select(q => q.ToDto()).ToList(); + } + + /// + /// Gets active quiz questions by quiz ID. + /// + public virtual async Task> GetActiveQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default) + { + var questions = await _quizQuestionRepository.GetActiveByQuizAsync(quizId, cancellationToken); + return questions.Select(q => q.ToDto()).ToList(); + } + + /// + /// Gets a random set of quiz questions for a quiz. + /// + public virtual async Task> GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default) + { + var questions = await _quizQuestionRepository.GetRandomQuestionsForQuizAsync(quizId, count, cancellationToken); + return questions.Select(q => q.ToDto()).ToList(); + } + + /// + /// Gets the total points for a quiz. + /// + public virtual async Task GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default) + { + return await _quizQuestionRepository.GetTotalPointsForQuizAsync(quizId, cancellationToken); + } } diff --git a/GermanApp/Application/Services/QuizService.cs b/GermanApp/Application/Services/QuizService.cs new file mode 100644 index 0000000..b319b4c --- /dev/null +++ b/GermanApp/Application/Services/QuizService.cs @@ -0,0 +1,273 @@ +using GermanApp.Application.DTOs; +using GermanApp.Domain.Entities; +using GermanApp.Domain.Exceptions; +using GermanApp.Domain.Interfaces; + +namespace GermanApp.Application.Services; + +/// +/// Application service for managing quizzes. +/// This is part of the Application layer. +/// +public class QuizService +{ + private readonly IQuizRepository _quizRepository; + private readonly IQuizQuestionRepository _quizQuestionRepository; + private readonly IQuizOptionRepository _quizOptionRepository; + private readonly ILessonRepository _lessonRepository; + + public QuizService( + IQuizRepository quizRepository, + IQuizQuestionRepository quizQuestionRepository, + IQuizOptionRepository quizOptionRepository, + ILessonRepository lessonRepository) + { + _quizRepository = quizRepository; + _quizQuestionRepository = quizQuestionRepository; + _quizOptionRepository = quizOptionRepository; + _lessonRepository = lessonRepository; + } + + /// + /// Gets all quizzes. + /// + public virtual async Task> GetAllQuizzesAsync(CancellationToken cancellationToken = default) + { + var quizzes = await _quizRepository.GetAllOrderedAsync(cancellationToken); + return quizzes.Select(q => q.ToDto()).ToList(); + } + + /// + /// Gets all quizzes as list items (summary). + /// + public virtual async Task> GetAllQuizListItemsAsync(CancellationToken cancellationToken = default) + { + var quizzes = await _quizRepository.GetAllOrderedAsync(cancellationToken); + return quizzes.Select(q => q.ToListItemDto()).ToList(); + } + + /// + /// Gets a quiz by its ID. + /// + public virtual async Task GetQuizByIdAsync(int id, CancellationToken cancellationToken = default) + { + var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken); + return quiz?.ToDto(); + } + + /// + /// Gets a quiz with its questions by ID. + /// + public virtual async Task GetQuizWithQuestionsAsync(int id, CancellationToken cancellationToken = default) + { + var quiz = await _quizRepository.GetWithQuestionsAsync(id, cancellationToken); + return quiz?.ToQuizWithQuestionsDto(); + } + + /// + /// Gets quizzes by lesson ID. + /// + public virtual async Task> GetQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default) + { + var quizzes = await _quizRepository.GetByLessonAsync(lessonId, cancellationToken); + return quizzes.Select(q => q.ToDto()).ToList(); + } + + /// + /// Gets active quizzes by lesson ID. + /// + public virtual async Task> GetActiveQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default) + { + var quizzes = await _quizRepository.GetActiveByLessonAsync(lessonId, cancellationToken); + return quizzes.Select(q => q.ToDto()).ToList(); + } + + /// + /// Gets the first quiz for a lesson. + /// + public virtual async Task GetFirstQuizByLessonAsync(int lessonId, CancellationToken cancellationToken = default) + { + var quiz = await _quizRepository.GetFirstByLessonAsync(lessonId, cancellationToken); + return quiz?.ToDto(); + } + + /// + /// Creates a new quiz. + /// + public virtual async Task CreateQuizAsync(CreateQuizDto dto, CancellationToken cancellationToken = default) + { + // Validate that the lesson exists + var lesson = await _lessonRepository.GetByIdAsync(dto.LessonId, cancellationToken); + if (lesson == null) + throw new NotFoundException("Lesson does not exist"); + + // Validate business rules + if (dto.PassingScore < 0 || dto.PassingScore > 100) + { + throw new ValidationException("Passing score must be between 0 and 100"); + } + + if (dto.TimeLimitMinutes < 0) + { + throw new ValidationException("Time limit cannot be negative"); + } + + // Convert DTO to domain entity + var quiz = dto.ToEntity(); + + // Add to repository + var createdQuiz = await _quizRepository.AddAsync(quiz, cancellationToken); + + // Return DTO representation + return createdQuiz.ToDto(); + } + + /// + /// Updates an existing quiz. + /// + public virtual async Task UpdateQuizAsync(int id, UpdateQuizDto dto, CancellationToken cancellationToken = default) + { + var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken); + if (quiz == null) + return null; + + // Validate that the lesson exists + var lesson = await _lessonRepository.GetByIdAsync(dto.LessonId, cancellationToken); + if (lesson == null) + throw new NotFoundException("Lesson does not exist"); + + // Validate business rules + if (dto.PassingScore < 0 || dto.PassingScore > 100) + { + throw new ValidationException("Passing score must be between 0 and 100"); + } + + if (dto.TimeLimitMinutes < 0) + { + throw new ValidationException("Time limit cannot be negative"); + } + + // Update the quiz + quiz.UpdateFromDto(dto); + await _quizRepository.UpdateAsync(quiz, cancellationToken); + + // Reload and return + var updatedQuiz = await _quizRepository.GetByIdAsync(id, cancellationToken); + return updatedQuiz?.ToDto(); + } + + /// + /// Deletes a quiz by its ID. + /// + public virtual async Task DeleteQuizAsync(int id, CancellationToken cancellationToken = default) + { + var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken); + if (quiz == null) + return false; + + // First, delete all options for all questions in this quiz + var questions = await _quizQuestionRepository.GetByQuizAsync(id, cancellationToken); + foreach (var question in questions) + { + await _quizOptionRepository.DeleteByQuestionAsync(question.Id, cancellationToken); + } + + // Then delete all questions for this quiz + foreach (var question in questions) + { + await _quizQuestionRepository.DeleteAsync(question, cancellationToken); + } + + // Finally, delete the quiz itself + await _quizRepository.DeleteAsync(quiz, cancellationToken); + return true; + } + + /// + /// Checks if a quiz exists for a lesson. + /// + public virtual async Task ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default) + { + return await _quizRepository.ExistsByLessonAsync(lessonId, cancellationToken); + } + + /// + /// Checks if a quiz exists by ID. + /// + public virtual async Task ExistsAsync(int id, CancellationToken cancellationToken = default) + { + return await _quizRepository.ExistsAsync(id, cancellationToken); + } + + /// + /// Gets the total number of quizzes. + /// + public virtual async Task GetTotalQuizCountAsync(CancellationToken cancellationToken = default) + { + var quizzes = await _quizRepository.GetAllAsync(cancellationToken); + return quizzes.Count; + } + + /// + /// Gets the total number of quizzes for a lesson. + /// + public virtual async Task GetQuizCountByLessonAsync(int lessonId, CancellationToken cancellationToken = default) + { + var quizzes = await _quizRepository.GetByLessonAsync(lessonId, cancellationToken); + return quizzes.Count; + } + + /// + /// Gets quizzes by passing score range. + /// + public virtual async Task> GetQuizzesByPassingScoreRangeAsync(int minScore, int maxScore, CancellationToken cancellationToken = default) + { + var quizzes = await _quizRepository.GetAllAsync(cancellationToken); + return quizzes.Where(q => q.PassingScore >= minScore && q.PassingScore <= maxScore) + .Select(q => q.ToDto()) + .ToList(); + } + + /// + /// Gets active quizzes. + /// + public virtual async Task> GetActiveQuizzesAsync(CancellationToken cancellationToken = default) + { + var quizzes = await _quizRepository.GetAllAsync(cancellationToken); + return quizzes.Where(q => q.IsActive) + .Select(q => q.ToDto()) + .ToList(); + } + + /// + /// Activates a quiz. + /// + public virtual async Task ActivateQuizAsync(int id, CancellationToken cancellationToken = default) + { + var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken); + if (quiz == null) + return null; + + quiz.Activate(); + await _quizRepository.UpdateAsync(quiz, cancellationToken); + + var updatedQuiz = await _quizRepository.GetByIdAsync(id, cancellationToken); + return updatedQuiz?.ToDto(); + } + + /// + /// Deactivates a quiz. + /// + public virtual async Task DeactivateQuizAsync(int id, CancellationToken cancellationToken = default) + { + var quiz = await _quizRepository.GetByIdAsync(id, cancellationToken); + if (quiz == null) + return null; + + quiz.Deactivate(); + await _quizRepository.UpdateAsync(quiz, cancellationToken); + + var updatedQuiz = await _quizRepository.GetByIdAsync(id, cancellationToken); + return updatedQuiz?.ToDto(); + } +} diff --git a/GermanApp/Application/UseCases/Commands/CreateQuizCommand.cs b/GermanApp/Application/UseCases/Commands/CreateQuizCommand.cs new file mode 100644 index 0000000..036432a --- /dev/null +++ b/GermanApp/Application/UseCases/Commands/CreateQuizCommand.cs @@ -0,0 +1,162 @@ +using GermanApp.Application.DTOs; +using GermanApp.Domain.Entities; +using GermanApp.Domain.Exceptions; +using GermanApp.Domain.Interfaces; + +namespace GermanApp.Application.UseCases.Commands; + +/// +/// Command for creating a new quiz. +/// This follows the CQRS pattern - commands represent write operations. +/// +public record CreateQuizCommand(CreateQuizDto QuizData) : ICommand; + +/// +/// Handler for the CreateQuizCommand. +/// +public class CreateQuizCommandHandler : ICommandHandler +{ + private readonly IQuizRepository _quizRepository; + private readonly ILessonRepository _lessonRepository; + + public CreateQuizCommandHandler( + IQuizRepository quizRepository, + ILessonRepository lessonRepository) + { + _quizRepository = quizRepository; + _lessonRepository = lessonRepository; + } + + public async Task Handle(CreateQuizCommand command, CancellationToken cancellationToken) + { + // Validate that the lesson exists + var lesson = await _lessonRepository.GetByIdAsync(command.QuizData.LessonId, cancellationToken); + if (lesson == null) + throw new NotFoundException("Lesson does not exist"); + + // Validate business rules + if (command.QuizData.PassingScore < 0 || command.QuizData.PassingScore > 100) + { + throw new ValidationException("Passing score must be between 0 and 100"); + } + + if (command.QuizData.TimeLimitMinutes < 0) + { + throw new ValidationException("Time limit cannot be negative"); + } + + // Convert DTO to domain entity + var quiz = command.QuizData.ToEntity(); + + // Add to repository + var createdQuiz = await _quizRepository.AddAsync(quiz, cancellationToken); + + // Return DTO representation + return createdQuiz.ToDto(); + } +} + +/// +/// Command for updating an existing quiz. +/// +public record UpdateQuizCommand(int Id, UpdateQuizDto QuizData) : ICommand; + +/// +/// Handler for the UpdateQuizCommand. +/// +public class UpdateQuizCommandHandler : ICommandHandler +{ + private readonly IQuizRepository _quizRepository; + private readonly ILessonRepository _lessonRepository; + + public UpdateQuizCommandHandler( + IQuizRepository quizRepository, + ILessonRepository lessonRepository) + { + _quizRepository = quizRepository; + _lessonRepository = lessonRepository; + } + + public async Task Handle(UpdateQuizCommand command, CancellationToken cancellationToken) + { + var quiz = await _quizRepository.GetByIdAsync(command.Id, cancellationToken); + if (quiz == null) + return null; + + // Validate that the lesson exists + var lesson = await _lessonRepository.GetByIdAsync(command.QuizData.LessonId, cancellationToken); + if (lesson == null) + throw new NotFoundException("Lesson does not exist"); + + // Validate business rules + if (command.QuizData.PassingScore < 0 || command.QuizData.PassingScore > 100) + { + throw new ValidationException("Passing score must be between 0 and 100"); + } + + if (command.QuizData.TimeLimitMinutes < 0) + { + throw new ValidationException("Time limit cannot be negative"); + } + + // Update the quiz + quiz.UpdateFromDto(command.QuizData); + await _quizRepository.UpdateAsync(quiz, cancellationToken); + + // Reload and return + var updatedQuiz = await _quizRepository.GetByIdAsync(command.Id, cancellationToken); + return updatedQuiz?.ToDto(); + } +} + +/// +/// Command for deleting a quiz. +/// +public record DeleteQuizCommand(int Id) : ICommand; + +/// +/// Handler for the DeleteQuizCommand. +/// +public class DeleteQuizCommandHandler : ICommandHandler +{ + private readonly IQuizRepository _quizRepository; + + public DeleteQuizCommandHandler(IQuizRepository quizRepository) + { + _quizRepository = quizRepository; + } + + public async Task Handle(DeleteQuizCommand command, CancellationToken cancellationToken) + { + var quiz = await _quizRepository.GetByIdAsync(command.Id, cancellationToken); + if (quiz == null) + return false; + + await _quizRepository.DeleteAsync(quiz, cancellationToken); + return true; + } +} + +/// +/// Command for getting a quiz with its questions. +/// +public record GetQuizWithQuestionsCommand(int Id) : ICommand; + +/// +/// Handler for the GetQuizWithQuestionsCommand. +/// +public class GetQuizWithQuestionsCommandHandler : ICommandHandler +{ + private readonly IQuizRepository _quizRepository; + + public GetQuizWithQuestionsCommandHandler(IQuizRepository quizRepository) + { + _quizRepository = quizRepository; + } + + public async Task Handle(GetQuizWithQuestionsCommand command, CancellationToken cancellationToken) + { + var quiz = await _quizRepository.GetWithQuestionsAsync(command.Id, cancellationToken); + return quiz?.ToQuizWithQuestionsDto(); + } +} diff --git a/GermanApp/Domain/Entities/Quiz.cs b/GermanApp/Domain/Entities/Quiz.cs new file mode 100644 index 0000000..6d52f01 --- /dev/null +++ b/GermanApp/Domain/Entities/Quiz.cs @@ -0,0 +1,182 @@ +namespace GermanApp.Domain.Entities; + +/// +/// Represents a quiz that can be associated with a lesson. +/// A quiz contains multiple questions and has a passing score threshold. +/// +public class Quiz +{ + public int Id { get; private set; } + public int LessonId { get; private set; } + public string Title { get; private set; } = string.Empty; + public string? Description { get; private set; } + public int PassingScore { get; private set; } = 80; // Default 80% + public int TimeLimitMinutes { get; private set; } = 0; // 0 = no limit + public bool IsActive { get; private set; } = true; + public bool ShuffleQuestions { get; private set; } = true; + public DateTime CreatedAt { get; private set; } + public DateTime? UpdatedAt { get; private set; } + + // Navigation property + public virtual Lesson? Lesson { get; private set; } + public virtual ICollection Questions { get; private set; } = new List(); + + /// + /// Constructor for EF Core deserialization. + /// + private Quiz() { } + + /// + /// Factory method to create a new quiz. + /// + /// ID of the associated lesson + /// Title of the quiz + /// Optional description + /// Passing score percentage (default 80) + /// Time limit in minutes (0 for no limit) + public static Quiz Create( + int lessonId, + string title, + string? description = null, + int passingScore = 80, + int timeLimitMinutes = 0) + { + if (string.IsNullOrWhiteSpace(title)) + throw new ArgumentException("Quiz title cannot be empty", nameof(title)); + + if (passingScore < 0 || passingScore > 100) + throw new ArgumentOutOfRangeException(nameof(passingScore), "Passing score must be between 0 and 100"); + + if (timeLimitMinutes < 0) + throw new ArgumentOutOfRangeException(nameof(timeLimitMinutes), "Time limit cannot be negative"); + + return new Quiz + { + LessonId = lessonId, + Title = title, + Description = description, + PassingScore = passingScore, + TimeLimitMinutes = timeLimitMinutes, + CreatedAt = DateTime.UtcNow + }; + } + + /// + /// Updates the quiz title. + /// + public void UpdateTitle(string newTitle) + { + if (string.IsNullOrWhiteSpace(newTitle)) + throw new ArgumentException("Quiz title cannot be empty", nameof(newTitle)); + + Title = newTitle; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Updates the quiz description. + /// + public void UpdateDescription(string? newDescription) + { + Description = newDescription; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Updates the passing score. + /// + public void UpdatePassingScore(int newPassingScore) + { + if (newPassingScore < 0 || newPassingScore > 100) + throw new ArgumentOutOfRangeException(nameof(newPassingScore), "Passing score must be between 0 and 100"); + + PassingScore = newPassingScore; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Updates the time limit. + /// + public void UpdateTimeLimit(int newTimeLimitMinutes) + { + if (newTimeLimitMinutes < 0) + throw new ArgumentOutOfRangeException(nameof(newTimeLimitMinutes), "Time limit cannot be negative"); + + TimeLimitMinutes = newTimeLimitMinutes; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Updates the associated lesson. + /// + public void UpdateLesson(int newLessonId) + { + LessonId = newLessonId; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Updates the shuffle questions setting. + /// + public void UpdateShuffleQuestions(bool newShuffleQuestions) + { + ShuffleQuestions = newShuffleQuestions; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Activates the quiz. + /// + public void Activate() + { + IsActive = true; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Deactivates the quiz. + /// + public void Deactivate() + { + IsActive = false; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// Adds a question to this quiz. + /// + public void AddQuestion(QuizQuestion question) + { + if (question == null) + throw new ArgumentNullException(nameof(question)); + + Questions.Add(question); + } + + /// + /// Removes a question from this quiz. + /// + public void RemoveQuestion(QuizQuestion question) + { + if (question == null) + throw new ArgumentNullException(nameof(question)); + + Questions.Remove(question); + } + + /// + /// Checks if the quiz is passed based on a score percentage. + /// + public bool IsPassed(double scorePercentage) => scorePercentage >= PassingScore; + + /// + /// Calculates the score percentage based on correct answers. + /// + public double CalculateScore(int correctAnswers, int totalQuestions) + { + if (totalQuestions == 0) + return 0; + + return (double)correctAnswers / totalQuestions * 100; + } +} diff --git a/GermanApp/Domain/Entities/QuizQuestion.cs b/GermanApp/Domain/Entities/QuizQuestion.cs index 8758dd4..7e2af86 100644 --- a/GermanApp/Domain/Entities/QuizQuestion.cs +++ b/GermanApp/Domain/Entities/QuizQuestion.cs @@ -1,17 +1,18 @@ namespace GermanApp.Domain.Entities; /// -/// Represents a quiz question for a lesson. +/// Represents a quiz question for a quiz. /// Each question has a type (multiple choice, true/false, fill-in-the-blank, etc.) -/// and is associated with a specific lesson. +/// and is associated with a specific quiz. /// public class QuizQuestion { public int Id { get; private set; } - public int LessonId { get; private set; } + public int QuizId { get; private set; } public string QuestionText { get; private set; } = string.Empty; public QuestionType Type { get; private set; } public string? CorrectAnswer { get; private set; } + public int Points { get; private set; } = 1; // Points for this question public int Difficulty { get; private set; } // 1-5 scale public int Order { get; private set; } public bool IsActive { get; private set; } = true; @@ -19,7 +20,7 @@ public class QuizQuestion public DateTime? UpdatedAt { get; private set; } // Navigation property - public virtual Lesson? Lesson { get; private set; } + public virtual Quiz? Quiz { get; private set; } public virtual ICollection Options { get; private set; } = new List(); /// @@ -30,32 +31,38 @@ public class QuizQuestion /// /// Factory method to create a new quiz question. /// - /// ID of the parent lesson + /// ID of the parent quiz /// The question text /// The question type /// The correct answer - /// Difficulty level (1-5) - /// Sort order within the lesson + /// Points for this question (default 1) + /// Difficulty level (1-5, default 3) + /// Sort order within the quiz public static QuizQuestion Create( - int lessonId, + int quizId, string questionText, QuestionType type, string correctAnswer, + int points = 1, int difficulty = 3, int order = 1) { if (difficulty < 1 || difficulty > 5) throw new ArgumentOutOfRangeException(nameof(difficulty), "Difficulty must be between 1 and 5"); + if (points < 0) + throw new ArgumentOutOfRangeException(nameof(points), "Points must be positive"); + if (string.IsNullOrWhiteSpace(questionText)) throw new ArgumentException("Question text cannot be empty", nameof(questionText)); return new QuizQuestion { - LessonId = lessonId, + QuizId = quizId, QuestionText = questionText, Type = type, CorrectAnswer = correctAnswer, + Points = points, Difficulty = difficulty, Order = order, CreatedAt = DateTime.UtcNow @@ -83,6 +90,18 @@ public class QuizQuestion UpdatedAt = DateTime.UtcNow; } + /// + /// Updates the points for this question. + /// + public void UpdatePoints(int newPoints) + { + if (newPoints < 0) + throw new ArgumentOutOfRangeException(nameof(newPoints), "Points must be positive"); + + Points = newPoints; + UpdatedAt = DateTime.UtcNow; + } + /// /// Updates the difficulty level. /// @@ -114,11 +133,11 @@ public class QuizQuestion } /// - /// Updates the associated lesson. + /// Updates the associated quiz. /// - public void UpdateLesson(int newLessonId) + public void UpdateQuiz(int newQuizId) { - LessonId = newLessonId; + QuizId = newQuizId; UpdatedAt = DateTime.UtcNow; } diff --git a/GermanApp/Domain/Interfaces/IRepository.cs b/GermanApp/Domain/Interfaces/IRepository.cs index 5fe04fc..d2e0503 100644 --- a/GermanApp/Domain/Interfaces/IRepository.cs +++ b/GermanApp/Domain/Interfaces/IRepository.cs @@ -173,6 +173,76 @@ public interface IQuizQuestionRepository : IRepository /// Checks if a quiz question with the given order exists in a lesson. /// Task OrderExistsInLessonAsync(int lessonId, int order, int? excludeId = null, CancellationToken cancellationToken = default); + + // ============================================ + // Quiz-based methods (QuizId) + // ============================================ + + /// + /// Gets quiz questions by quiz ID. + /// + Task> GetByQuizAsync(int quizId, CancellationToken cancellationToken = default); + + /// + /// Gets active quiz questions by quiz ID. + /// + Task> GetActiveByQuizAsync(int quizId, CancellationToken cancellationToken = default); + + /// + /// Gets quiz questions by quiz with options loaded. + /// + Task> GetByQuizWithOptionsAsync(int quizId, CancellationToken cancellationToken = default); + + /// + /// Gets a random set of quiz questions for a quiz. + /// + Task> GetRandomQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default); + + /// + /// Gets the total points for a quiz. + /// + Task GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default); + + /// + /// Checks if a quiz question with the given order exists in a quiz. + /// + Task OrderExistsInQuizAsync(int quizId, int order, int? excludeId = null, CancellationToken cancellationToken = default); +} + +/// +/// Repository interface for Quiz entities. +/// +public interface IQuizRepository : IRepository +{ + /// + /// Gets quizzes by lesson ID. + /// + Task> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default); + + /// + /// Gets all quizzes ordered by lesson and quiz order. + /// + Task> GetAllOrderedAsync(CancellationToken cancellationToken = default); + + /// + /// Gets active quizzes by lesson ID. + /// + Task> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default); + + /// + /// Gets a quiz with its questions and options. + /// + Task GetWithQuestionsAsync(int id, CancellationToken cancellationToken = default); + + /// + /// Gets the first quiz for a lesson. + /// + Task GetFirstByLessonAsync(int lessonId, CancellationToken cancellationToken = default); + + /// + /// Checks if a quiz exists for a lesson. + /// + Task ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default); } /// diff --git a/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs b/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs index f4023c1..6add88e 100644 --- a/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs +++ b/GermanApp/Infrastructure/Data/DbContext/AppDbContext.cs @@ -19,6 +19,7 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext public DbSet Users { get; set; } = null!; public DbSet UserProgress { get; set; } = null!; public DbSet RefreshTokens { get; set; } = null!; + public DbSet Quizzes { get; set; } = null!; public DbSet QuizQuestions { get; set; } = null!; public DbSet QuizOptions { get; set; } = null!; @@ -134,17 +135,17 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext .OnDelete(DeleteBehavior.Cascade); }); - // Configure QuizQuestion entity - modelBuilder.Entity(builder => + // Configure Quiz entity + modelBuilder.Entity(builder => { builder.HasKey(q => q.Id); builder.Property(q => q.LessonId).IsRequired(); - builder.Property(q => q.QuestionText).IsRequired().HasMaxLength(2000); - builder.Property(q => q.Type).IsRequired(); - builder.Property(q => q.CorrectAnswer).HasMaxLength(1000); - builder.Property(q => q.Difficulty).IsRequired().HasDefaultValue(3); - builder.Property(q => q.Order).IsRequired().HasDefaultValue(1); + builder.Property(q => q.Title).IsRequired().HasMaxLength(200); + builder.Property(q => q.Description).HasMaxLength(2000); + builder.Property(q => q.PassingScore).IsRequired().HasDefaultValue(80); + builder.Property(q => q.TimeLimitMinutes).HasDefaultValue(0); builder.Property(q => q.IsActive).HasDefaultValue(true); + builder.Property(q => q.ShuffleQuestions).HasDefaultValue(true); builder.Property(q => q.CreatedAt).IsRequired(); builder.Property(q => q.UpdatedAt).IsRequired(false); @@ -154,8 +155,36 @@ public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext .HasForeignKey(q => q.LessonId) .OnDelete(DeleteBehavior.Cascade); - // Unique constraint: one question per lesson per order - builder.HasIndex(q => new { q.LessonId, q.Order }).IsUnique(); + // Navigation to questions + builder.HasMany(q => q.Questions) + .WithOne(qq => qq.Quiz) + .HasForeignKey(qq => qq.QuizId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // Configure QuizQuestion entity + modelBuilder.Entity(builder => + { + builder.HasKey(q => q.Id); + builder.Property(q => q.QuizId).IsRequired(); + builder.Property(q => q.QuestionText).IsRequired().HasMaxLength(2000); + builder.Property(q => q.Type).IsRequired(); + builder.Property(q => q.CorrectAnswer).HasMaxLength(1000); + builder.Property(q => q.Points).IsRequired().HasDefaultValue(1); + builder.Property(q => q.Difficulty).IsRequired().HasDefaultValue(3); + builder.Property(q => q.Order).IsRequired().HasDefaultValue(1); + builder.Property(q => q.IsActive).HasDefaultValue(true); + builder.Property(q => q.CreatedAt).IsRequired(); + builder.Property(q => q.UpdatedAt).IsRequired(false); + + // Foreign key to Quiz + builder.HasOne(q => q.Quiz) + .WithMany(qq => qq.Questions) + .HasForeignKey(q => q.QuizId) + .OnDelete(DeleteBehavior.Cascade); + + // Unique constraint: one question per quiz per order + builder.HasIndex(q => new { q.QuizId, q.Order }).IsUnique(); // Navigation to options builder.HasMany(q => q.Options) diff --git a/GermanApp/Infrastructure/Data/Repositories/QuizQuestionRepository.cs b/GermanApp/Infrastructure/Data/Repositories/QuizQuestionRepository.cs index 96c8928..f3890de 100644 --- a/GermanApp/Infrastructure/Data/Repositories/QuizQuestionRepository.cs +++ b/GermanApp/Infrastructure/Data/Repositories/QuizQuestionRepository.cs @@ -21,7 +21,8 @@ public class QuizQuestionRepository : IQuizQuestionRepository public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) { return await _context.QuizQuestions - .Include(q => q.Lesson) + .Include(q => q.Quiz) + .ThenInclude(q => q.Lesson) .Include(q => q.Options.OrderBy(o => o.Order)) .FirstOrDefaultAsync(q => q.Id == id, cancellationToken); } @@ -29,7 +30,8 @@ public class QuizQuestionRepository : IQuizQuestionRepository public async Task> GetAllAsync(CancellationToken cancellationToken = default) { return await _context.QuizQuestions - .Include(q => q.Lesson) + .Include(q => q.Quiz) + .ThenInclude(q => q.Lesson) .Include(q => q.Options) .AsNoTracking() .ToListAsync(cancellationToken); @@ -63,9 +65,10 @@ public class QuizQuestionRepository : IQuizQuestionRepository public async Task> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default) { return await _context.QuizQuestions - .Include(q => q.Lesson) + .Include(q => q.Quiz) + .ThenInclude(q => q.Lesson) .Include(q => q.Options.OrderBy(o => o.Order)) - .Where(q => q.LessonId == lessonId) + .Where(q => q.Quiz != null && q.Quiz.LessonId == lessonId) .OrderBy(q => q.Order) .AsNoTracking() .ToListAsync(cancellationToken); @@ -74,9 +77,11 @@ public class QuizQuestionRepository : IQuizQuestionRepository public async Task> GetAllOrderedAsync(CancellationToken cancellationToken = default) { return await _context.QuizQuestions - .Include(q => q.Lesson) + .Include(q => q.Quiz) + .ThenInclude(q => q.Lesson) .Include(q => q.Options.OrderBy(o => o.Order)) - .OrderBy(q => q.Lesson.Order) + .OrderBy(q => q.Quiz.Lesson.Order) + .ThenBy(q => q.Quiz.Id) .ThenBy(q => q.Order) .AsNoTracking() .ToListAsync(cancellationToken); @@ -85,9 +90,10 @@ public class QuizQuestionRepository : IQuizQuestionRepository public async Task> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default) { return await _context.QuizQuestions - .Include(q => q.Lesson) + .Include(q => q.Quiz) + .ThenInclude(q => q.Lesson) .Include(q => q.Options.OrderBy(o => o.Order)) - .Where(q => q.LessonId == lessonId && q.IsActive) + .Where(q => q.Quiz != null && q.Quiz.LessonId == lessonId && q.IsActive) .OrderBy(q => q.Order) .AsNoTracking() .ToListAsync(cancellationToken); @@ -96,9 +102,10 @@ public class QuizQuestionRepository : IQuizQuestionRepository public async Task> GetRandomQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default) { return await _context.QuizQuestions - .Include(q => q.Lesson) + .Include(q => q.Quiz) + .ThenInclude(q => q.Lesson) .Include(q => q.Options.OrderBy(o => o.Order)) - .Where(q => q.LessonId == lessonId && q.IsActive) + .Where(q => q.Quiz != null && q.Quiz.LessonId == lessonId && q.IsActive) .OrderBy(q => Guid.NewGuid()) // Random ordering .Take(count) .AsNoTracking() @@ -108,7 +115,7 @@ public class QuizQuestionRepository : IQuizQuestionRepository public async Task> GetByDifficultyAsync(int difficulty, CancellationToken cancellationToken = default) { return await _context.QuizQuestions - .Include(q => q.Lesson) + .Include(q => q.Quiz) .Include(q => q.Options) .Where(q => q.Difficulty == difficulty) .OrderBy(q => q.Order) @@ -119,7 +126,7 @@ public class QuizQuestionRepository : IQuizQuestionRepository public async Task> GetByTypeAsync(QuestionType type, CancellationToken cancellationToken = default) { return await _context.QuizQuestions - .Include(q => q.Lesson) + .Include(q => q.Quiz) .Include(q => q.Options) .Where(q => q.Type == type) .OrderBy(q => q.Order) @@ -130,7 +137,79 @@ public class QuizQuestionRepository : IQuizQuestionRepository public async Task OrderExistsInLessonAsync(int lessonId, int order, int? excludeId = null, CancellationToken cancellationToken = default) { var query = _context.QuizQuestions - .Where(q => q.LessonId == lessonId && q.Order == order); + .Where(q => q.Quiz != null && q.Quiz.LessonId == lessonId && q.Order == order); + + if (excludeId.HasValue) + { + query = query.Where(q => q.Id != excludeId.Value); + } + + return await query.AnyAsync(cancellationToken); + } + + // ============================================ + // Quiz-based methods (QuizId) + // ============================================ + + public async Task> GetByQuizAsync(int quizId, CancellationToken cancellationToken = default) + { + return await _context.QuizQuestions + .Include(q => q.Quiz) + .Include(q => q.Options.OrderBy(o => o.Order)) + .Where(q => q.QuizId == quizId) + .OrderBy(q => q.Order) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task> GetActiveByQuizAsync(int quizId, CancellationToken cancellationToken = default) + { + return await _context.QuizQuestions + .Include(q => q.Quiz) + .Include(q => q.Options.OrderBy(o => o.Order)) + .Where(q => q.QuizId == quizId && q.IsActive) + .OrderBy(q => q.Order) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task> GetByQuizWithOptionsAsync(int quizId, CancellationToken cancellationToken = default) + { + return await _context.QuizQuestions + .Include(q => q.Quiz) + .Include(q => q.Options.OrderBy(o => o.Order)) + .Where(q => q.QuizId == quizId) + .OrderBy(q => q.Order) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task> GetRandomQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default) + { + return await _context.QuizQuestions + .Include(q => q.Quiz) + .Include(q => q.Options.OrderBy(o => o.Order)) + .Where(q => q.QuizId == quizId && q.IsActive) + .OrderBy(q => Guid.NewGuid()) // Random ordering + .Take(count) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default) + { + var questions = await _context.QuizQuestions + .Where(q => q.QuizId == quizId && q.IsActive) + .Select(q => q.Points) + .ToListAsync(cancellationToken); + + return questions.Sum(); + } + + public async Task OrderExistsInQuizAsync(int quizId, int order, int? excludeId = null, CancellationToken cancellationToken = default) + { + var query = _context.QuizQuestions + .Where(q => q.QuizId == quizId && q.Order == order); if (excludeId.HasValue) { diff --git a/GermanApp/Infrastructure/Data/Repositories/QuizRepository.cs b/GermanApp/Infrastructure/Data/Repositories/QuizRepository.cs new file mode 100644 index 0000000..4c08a7e --- /dev/null +++ b/GermanApp/Infrastructure/Data/Repositories/QuizRepository.cs @@ -0,0 +1,114 @@ +using GermanApp.Domain.Entities; +using GermanApp.Domain.Interfaces; +using GermanApp.Infrastructure.Data.DbContext; +using Microsoft.EntityFrameworkCore; + +namespace GermanApp.Infrastructure.Data.Repositories; + +/// +/// Entity Framework Core implementation of IQuizRepository. +/// This is part of the Infrastructure layer. +/// +public class QuizRepository : IQuizRepository +{ + private readonly AppDbContext _context; + + public QuizRepository(AppDbContext context) + { + _context = context; + } + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.Quizzes + .Include(q => q.Lesson) + .FirstOrDefaultAsync(q => q.Id == id, cancellationToken); + } + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + return await _context.Quizzes + .Include(q => q.Lesson) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task AddAsync(Quiz entity, CancellationToken cancellationToken = default) + { + await _context.Quizzes.AddAsync(entity, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + return entity; + } + + public async Task UpdateAsync(Quiz entity, CancellationToken cancellationToken = default) + { + _context.Quizzes.Update(entity); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteAsync(Quiz entity, CancellationToken cancellationToken = default) + { + _context.Quizzes.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task ExistsAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.Quizzes + .AnyAsync(q => q.Id == id, cancellationToken); + } + + public async Task> GetByLessonAsync(int lessonId, CancellationToken cancellationToken = default) + { + return await _context.Quizzes + .Include(q => q.Lesson) + .Where(q => q.LessonId == lessonId) + .OrderBy(q => q.Id) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task> GetAllOrderedAsync(CancellationToken cancellationToken = default) + { + return await _context.Quizzes + .Include(q => q.Lesson) + .OrderBy(q => q.Lesson.Order) + .ThenBy(q => q.Id) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task> GetActiveByLessonAsync(int lessonId, CancellationToken cancellationToken = default) + { + return await _context.Quizzes + .Include(q => q.Lesson) + .Where(q => q.LessonId == lessonId && q.IsActive) + .OrderBy(q => q.Id) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task GetWithQuestionsAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.Quizzes + .Include(q => q.Lesson) + .Include(q => q.Questions.OrderBy(qq => qq.Order)) + .ThenInclude(qq => qq.Options.OrderBy(o => o.Order)) + .FirstOrDefaultAsync(q => q.Id == id, cancellationToken); + } + + public async Task GetFirstByLessonAsync(int lessonId, CancellationToken cancellationToken = default) + { + return await _context.Quizzes + .Include(q => q.Lesson) + .Where(q => q.LessonId == lessonId && q.IsActive) + .OrderBy(q => q.Id) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default) + { + return await _context.Quizzes + .AnyAsync(q => q.LessonId == lessonId, cancellationToken); + } +} diff --git a/GermanApp/Presentation/Controllers/QuizQuestionsController.cs b/GermanApp/Presentation/Controllers/QuizQuestionsController.cs index 6c56470..4eeb360 100644 --- a/GermanApp/Presentation/Controllers/QuizQuestionsController.cs +++ b/GermanApp/Presentation/Controllers/QuizQuestionsController.cs @@ -35,7 +35,20 @@ public class QuizQuestionsController : ControllerBase } /// - /// Gets all active quiz questions for a specific lesson. + /// Gets all active quiz questions for a specific quiz. + /// + /// The quiz ID + /// List of quiz questions for the specified quiz + [HttpGet("by-quiz/{quizId}")] + [AllowAnonymous] + public async Task GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default) + { + var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken); + return Ok(questions); + } + + /// + /// Gets all active quiz questions for a specific lesson (legacy - uses first quiz). /// /// The lesson ID /// List of quiz questions for the specified lesson @@ -63,12 +76,26 @@ public class QuizQuestionsController : ControllerBase } /// - /// Gets a random set of quiz questions for a lesson. + /// Gets a random set of quiz questions for a quiz. + /// + /// The quiz ID + /// Number of questions to return + /// List of random quiz questions for the quiz + [HttpGet("random/{quizId}/{count}")] + [AllowAnonymous] + public async Task GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default) + { + var questions = await _quizQuestionService.GetRandomQuizQuestionsForQuizAsync(quizId, count, cancellationToken); + return Ok(questions); + } + + /// + /// Gets a random set of quiz questions for a lesson (legacy - uses first quiz). /// /// The lesson ID /// Number of questions to return /// List of random quiz questions for the lesson - [HttpGet("random/{lessonId}/{count}")] + [HttpGet("random/by-lesson/{lessonId}/{count}")] [AllowAnonymous] public async Task GetRandomQuizQuestionsForLessonAsync(int lessonId, int count, CancellationToken cancellationToken = default) { @@ -105,7 +132,7 @@ public class QuizQuestionsController : ControllerBase /// /// Creates a new quiz question. /// - /// The quiz question data + /// The quiz question data (requires QuizId) /// The created quiz question [HttpPost] [Authorize(Roles = "Admin")] @@ -236,13 +263,15 @@ public class QuizQuestionsController : ControllerBase /// Submits quiz answers and returns the result. /// /// The user ID - /// The quiz answers submission + /// The quiz answers submission (requires QuizId) /// The quiz result [HttpPost("submit/{userId}")] [Authorize] public async Task SubmitQuizAnswersAsync(int userId, SubmitQuizAnswersDto dto, CancellationToken cancellationToken = default) { - var result = await _quizQuestionService.SubmitQuizAnswersAsync(userId, dto, cancellationToken); + // Convert legacy DTO to new format + var newDto = new SubmitQuizDto(dto.QuizId, dto.Answers); + var result = await _quizQuestionService.SubmitQuizAnswersForQuizAsync(userId, newDto, cancellationToken); return Ok(result); } diff --git a/GermanApp/Presentation/Controllers/QuizzesController.cs b/GermanApp/Presentation/Controllers/QuizzesController.cs new file mode 100644 index 0000000..097c464 --- /dev/null +++ b/GermanApp/Presentation/Controllers/QuizzesController.cs @@ -0,0 +1,344 @@ +using GermanApp.Application.DTOs; +using GermanApp.Application.Services; +using GermanApp.Domain.Entities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace GermanApp.Presentation.Controllers; + +/// +/// API controller for managing quizzes. +/// This is part of the Presentation layer. +/// +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class QuizzesController : ControllerBase +{ + private readonly QuizService _quizService; + private readonly QuizQuestionService _quizQuestionService; + + public QuizzesController( + QuizService quizService, + QuizQuestionService quizQuestionService) + { + _quizService = quizService; + _quizQuestionService = quizQuestionService; + } + + /// + /// Gets all quizzes. + /// + /// List of all quizzes + [HttpGet] + [AllowAnonymous] + public async Task GetAllQuizzesAsync(CancellationToken cancellationToken = default) + { + var quizzes = await _quizService.GetAllQuizzesAsync(cancellationToken); + return Ok(quizzes); + } + + /// + /// Gets all quizzes as list items (summary). + /// + /// List of quiz summary items + [HttpGet("list")] + [AllowAnonymous] + public async Task GetAllQuizListItemsAsync(CancellationToken cancellationToken = default) + { + var quizzes = await _quizService.GetAllQuizListItemsAsync(cancellationToken); + return Ok(quizzes); + } + + /// + /// Gets a specific quiz by its ID. + /// + /// The quiz ID + /// The quiz with the specified ID + [HttpGet("{id}")] + [AllowAnonymous] + public async Task GetQuizByIdAsync(int id, CancellationToken cancellationToken = default) + { + var quiz = await _quizService.GetQuizByIdAsync(id, cancellationToken); + if (quiz == null) + return NotFound(); + return Ok(quiz); + } + + /// + /// Gets a quiz with its questions by ID. + /// + /// The quiz ID + /// The quiz with questions + [HttpGet("{id}/with-questions")] + [AllowAnonymous] + public async Task GetQuizWithQuestionsAsync(int id, CancellationToken cancellationToken = default) + { + var quiz = await _quizService.GetQuizWithQuestionsAsync(id, cancellationToken); + if (quiz == null) + return NotFound(); + return Ok(quiz); + } + + /// + /// Gets all quizzes for a specific lesson. + /// + /// The lesson ID + /// List of quizzes for the specified lesson + [HttpGet("by-lesson/{lessonId}")] + [AllowAnonymous] + public async Task GetQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default) + { + var quizzes = await _quizService.GetQuizzesByLessonAsync(lessonId, cancellationToken); + return Ok(quizzes); + } + + /// + /// Gets active quizzes for a specific lesson. + /// + /// The lesson ID + /// List of active quizzes for the specified lesson + [HttpGet("active/by-lesson/{lessonId}")] + [AllowAnonymous] + public async Task GetActiveQuizzesByLessonAsync(int lessonId, CancellationToken cancellationToken = default) + { + var quizzes = await _quizService.GetActiveQuizzesByLessonAsync(lessonId, cancellationToken); + return Ok(quizzes); + } + + /// + /// Gets the first quiz for a lesson. + /// + /// The lesson ID + /// The first quiz for the lesson + [HttpGet("first/by-lesson/{lessonId}")] + [AllowAnonymous] + public async Task GetFirstQuizByLessonAsync(int lessonId, CancellationToken cancellationToken = default) + { + var quiz = await _quizService.GetFirstQuizByLessonAsync(lessonId, cancellationToken); + if (quiz == null) + return NotFound(); + return Ok(quiz); + } + + /// + /// Gets all active quizzes. + /// + /// List of active quizzes + [HttpGet("active")] + [AllowAnonymous] + public async Task GetActiveQuizzesAsync(CancellationToken cancellationToken = default) + { + var quizzes = await _quizService.GetActiveQuizzesAsync(cancellationToken); + return Ok(quizzes); + } + + /// + /// Creates a new quiz. + /// + /// The quiz data + /// The created quiz + [HttpPost] + [Authorize(Roles = "Admin")] + public async Task CreateQuizAsync(CreateQuizDto dto, CancellationToken cancellationToken = default) + { + var quiz = await _quizService.CreateQuizAsync(dto, cancellationToken); + return CreatedAtAction(nameof(GetQuizByIdAsync), new { id = quiz.Id }, quiz); + } + + /// + /// Updates an existing quiz. + /// + /// The quiz ID + /// The updated quiz data + /// The updated quiz + [HttpPut("{id}")] + [Authorize(Roles = "Admin")] + public async Task UpdateQuizAsync(int id, UpdateQuizDto dto, CancellationToken cancellationToken = default) + { + var quiz = await _quizService.UpdateQuizAsync(id, dto, cancellationToken); + if (quiz == null) + return NotFound(); + return Ok(quiz); + } + + /// + /// Deletes a quiz by its ID. + /// + /// The quiz ID + /// No content on success + [HttpDelete("{id}")] + [Authorize(Roles = "Admin")] + public async Task DeleteQuizAsync(int id, CancellationToken cancellationToken = default) + { + var result = await _quizService.DeleteQuizAsync(id, cancellationToken); + if (!result) + return NotFound(); + return NoContent(); + } + + /// + /// Activates a quiz. + /// + /// The quiz ID + /// The activated quiz + [HttpPost("{id}/activate")] + [Authorize(Roles = "Admin")] + public async Task ActivateQuizAsync(int id, CancellationToken cancellationToken = default) + { + var quiz = await _quizService.ActivateQuizAsync(id, cancellationToken); + if (quiz == null) + return NotFound(); + return Ok(quiz); + } + + /// + /// Deactivates a quiz. + /// + /// The quiz ID + /// The deactivated quiz + [HttpPost("{id}/deactivate")] + [Authorize(Roles = "Admin")] + public async Task DeactivateQuizAsync(int id, CancellationToken cancellationToken = default) + { + var quiz = await _quizService.DeactivateQuizAsync(id, cancellationToken); + if (quiz == null) + return NotFound(); + return Ok(quiz); + } + + /// + /// Checks if a quiz exists by ID. + /// + /// The quiz ID + /// True if the quiz exists + [HttpGet("exists/{id}")] + [AllowAnonymous] + public async Task ExistsAsync(int id, CancellationToken cancellationToken = default) + { + var exists = await _quizService.ExistsAsync(id, cancellationToken); + return Ok(new { exists }); + } + + /// + /// Checks if a quiz exists for a lesson. + /// + /// The lesson ID + /// True if a quiz exists for the lesson + [HttpGet("exists/by-lesson/{lessonId}")] + [AllowAnonymous] + public async Task ExistsByLessonAsync(int lessonId, CancellationToken cancellationToken = default) + { + var exists = await _quizService.ExistsByLessonAsync(lessonId, cancellationToken); + return Ok(new { lessonId, exists }); + } + + /// + /// Gets the total number of quizzes. + /// + /// The total count of quizzes + [HttpGet("count")] + [AllowAnonymous] + public async Task GetTotalQuizCountAsync(CancellationToken cancellationToken = default) + { + var count = await _quizService.GetTotalQuizCountAsync(cancellationToken); + return Ok(new { count }); + } + + /// + /// Gets the number of quizzes for a lesson. + /// + /// The lesson ID + /// The count of quizzes for the lesson + [HttpGet("count/by-lesson/{lessonId}")] + [AllowAnonymous] + public async Task GetQuizCountByLessonAsync(int lessonId, CancellationToken cancellationToken = default) + { + var count = await _quizService.GetQuizCountByLessonAsync(lessonId, cancellationToken); + return Ok(new { lessonId, count }); + } + + /// + /// Gets quizzes by passing score range. + /// + /// Minimum passing score + /// Maximum passing score + /// List of quizzes in the score range + [HttpGet("by-passing-score/{minScore}/{maxScore}")] + [AllowAnonymous] + public async Task GetQuizzesByPassingScoreRangeAsync(int minScore, int maxScore, CancellationToken cancellationToken = default) + { + var quizzes = await _quizService.GetQuizzesByPassingScoreRangeAsync(minScore, maxScore, cancellationToken); + return Ok(quizzes); + } + + /// + /// Gets quiz questions for a specific quiz. + /// + /// The quiz ID + /// List of quiz questions for the quiz + [HttpGet("{quizId}/questions")] + [AllowAnonymous] + public async Task GetQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default) + { + var questions = await _quizQuestionService.GetQuizQuestionsByQuizAsync(quizId, cancellationToken); + return Ok(questions); + } + + /// + /// Gets active quiz questions for a specific quiz. + /// + /// The quiz ID + /// List of active quiz questions for the quiz + [HttpGet("{quizId}/questions/active")] + [AllowAnonymous] + public async Task GetActiveQuizQuestionsByQuizAsync(int quizId, CancellationToken cancellationToken = default) + { + var questions = await _quizQuestionService.GetActiveQuizQuestionsByQuizAsync(quizId, cancellationToken); + return Ok(questions); + } + + /// + /// Gets random quiz questions for a quiz. + /// + /// The quiz ID + /// Number of questions to return + /// List of random quiz questions + [HttpGet("{quizId}/questions/random/{count}")] + [AllowAnonymous] + public async Task GetRandomQuizQuestionsForQuizAsync(int quizId, int count, CancellationToken cancellationToken = default) + { + var questions = await _quizQuestionService.GetRandomQuizQuestionsForQuizAsync(quizId, count, cancellationToken); + return Ok(questions); + } + + /// + /// Submits quiz answers and returns the result. + /// + /// The user ID + /// The quiz ID + /// The quiz answers submission + /// The quiz result + [HttpPost("submit/{userId}/{quizId}")] + [Authorize] + public async Task SubmitQuizAnswersAsync(int userId, int quizId, SubmitQuizDto dto, CancellationToken cancellationToken = default) + { + // Update the submission to use the correct quizId + var submission = new SubmitQuizDto(quizId, dto.Answers); + var result = await _quizQuestionService.SubmitQuizAnswersForQuizAsync(userId, submission, cancellationToken); + return Ok(result); + } + + /// + /// Gets the total points for a quiz. + /// + /// The quiz ID + /// The total points for the quiz + [HttpGet("{quizId}/total-points")] + [AllowAnonymous] + public async Task GetTotalPointsForQuizAsync(int quizId, CancellationToken cancellationToken = default) + { + var points = await _quizQuestionService.GetTotalPointsForQuizAsync(quizId, cancellationToken); + return Ok(new { quizId, points }); + } +} diff --git a/GermanApp/Program.cs b/GermanApp/Program.cs index 7912aba..13647b4 100644 --- a/GermanApp/Program.cs +++ b/GermanApp/Program.cs @@ -134,6 +134,7 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -171,8 +172,17 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped, CreateLessonCommandHandler>(); + + // Note: QuizQuestionService requires IQuizRepository which is registered above + + // Register Quiz command handlers + builder.Services.AddScoped, CreateQuizCommandHandler>(); + builder.Services.AddScoped, UpdateQuizCommandHandler>(); + builder.Services.AddScoped, DeleteQuizCommandHandler>(); + builder.Services.AddScoped, GetQuizWithQuestionsCommandHandler>(); var app = builder.Build();